diff --git a/.gitattributes b/.gitattributes index 1cf541aa3a..5cb404146d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,4 +11,4 @@ *.wav filter=lfs diff=lfs merge=lfs -text openpilot/selfdrive/car/tests/test_models_segs.txt filter=lfs diff=lfs merge=lfs -text -openpilot/common/hardware/tici/updater filter=lfs diff=lfs merge=lfs -text +openpilot/common/hardware/comma/updater filter=lfs diff=lfs merge=lfs -text diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index b986273097..0000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - diff --git a/.github/workflows/build-all-tinygrad-models.yaml b/.github/workflows/build-all-tinygrad-models.yaml index 0eedb04703..7bddeab2bc 100644 --- a/.github/workflows/build-all-tinygrad-models.yaml +++ b/.github/workflows/build-all-tinygrad-models.yaml @@ -7,6 +7,19 @@ on: description: 'Minimum selector version required for the models (see helpers.py or readme.md)' required: true type: string + target_hardware: + description: 'Hardware target to compile for (qcom or chestnut)' + required: true + type: choice + default: 'qcom' + options: + - qcom + - chestnut + hf_repo: + description: 'Hugging Face dataset repository' + required: false + type: string + default: 'sunnypilot/sunnypilot_models_v1' jobs: setup: @@ -46,13 +59,14 @@ jobs: id: get-json run: | cd docs/docs - latest=$(ls driving_models_v*.json | sed -E 's/.*_v([0-9]+)\.json/\1/' | sort -n | tail -1) + 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="driving_models_v${next}.json" - cp "driving_models_v${latest}.json" "$json_file" + json_file="${PREFIX}${next}.json" + cp "${PREFIX}${latest}.json" "$json_file" echo "json_file=docs/docs/$json_file" >> $GITHUB_OUTPUT echo "json_version=$((next+0))" >> $GITHUB_OUTPUT - echo "SRC_JSON_FILE=docs/docs/driving_models_v${latest}.json" >> $GITHUB_ENV + echo "SRC_JSON_FILE=docs/docs/${PREFIX}${latest}.json" >> $GITHUB_ENV - name: Extract tinygrad models id: set-matrix @@ -61,45 +75,23 @@ jobs: jq -c '[.bundles[] | select(.runner=="tinygrad") | {ref, display_name: (.display_name | gsub(" \\([^)]*\\)"; "")), is_20hz}]' "$(basename "${SRC_JSON_FILE}")" > matrix.json echo "model_matrix=$(cat matrix.json)" >> $GITHUB_OUTPUT - - name: Set up SSH - uses: webfactory/ssh-agent@v0.9.0 - with: - ssh-private-key: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }} - - run: | - mkdir -p ~/.ssh - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts - - - name: Clone GitLab docs repo and create new recompiled dir + - name: Get next recompiled dir number id: create-recompiled-dir env: - GIT_SSH_COMMAND: 'ssh -o UserKnownHostsFile=~/.ssh/known_hosts' + HF_REPO: ${{ github.event.inputs.hf_repo }} run: | - git clone --depth 1 --filter=tree:0 --sparse git@gitlab.com:sunnypilot/public/${{ vars.MODELS_GITLAB }} gitlab_docs - cd gitlab_docs - git checkout main - git sparse-checkout set --no-cone models/ - cd models - latest_dir=$(ls -d recompiled* 2>/dev/null | sed -E 's/recompiled([0-9]+)/\1/' | sort -n | tail -1) - if [[ -z "$latest_dir" ]]; then - next_dir=1 - else - next_dir=$((latest_dir+1)) - fi - recompiled_dir="${next_dir}" - mkdir -p "recompiled${recompiled_dir}" - touch "recompiled${recompiled_dir}/.gitkeep" - cd ../.. + pip install huggingface_hub + recompiled_dir=$(python3 -c " + from huggingface_hub import HfApi + import re, sys + api = HfApi() + files = api.list_repo_files(repo_id=sys.argv[1], repo_type='dataset') + dirs = [re.search(r'models/recompiled([0-9]+)', f) for f in files] + nums = [int(m.group(1)) for m in dirs if m] + print(max(nums) + 1) + " "$HF_REPO") echo "recompiled_dir=$recompiled_dir" >> $GITHUB_OUTPUT - - name: Push empty recompiled dir to GitLab - run: | - cd gitlab_docs - git add models/recompiled${{ steps.create-recompiled-dir.outputs.recompiled_dir }} - git config --global user.name "GitHub Action" - git config --global user.email "action@github.com" - git commit -m "Add recompiled${{ steps.create-recompiled-dir.outputs.recompiled_dir }} for build-all" || echo "No changes to commit" - git push origin main - - name: Push new JSON to GitHub docs repo run: | cd docs @@ -123,25 +115,30 @@ jobs: is_20hz: ${{ matrix.model.is_20hz }} recompiled_dir: ${{ needs.setup.outputs.recompiled_dir }} json_version: ${{ needs.setup.outputs.json_version }} + target_hardware: ${{ github.event.inputs.target_hardware }} + hf_repo: ${{ github.event.inputs.hf_repo }} + set_min_version: ${{ github.event.inputs.set_min_version }} + tinygrad_ref: ${{ needs.setup.outputs.tinygrad_ref }} secrets: inherit retry_failed_models: needs: [setup, get_and_build] runs-on: ubuntu-latest - if: ${{ needs.setup.result != 'failure' && !cancelled() }} + if: ${{ !cancelled() && needs.setup.result == 'success' && (needs.get_and_build.result == 'success' || needs.get_and_build.result == 'failure') }} outputs: retry_matrix: ${{ steps.set-retry-matrix.outputs.retry_matrix }} steps: - uses: actions/download-artifact@v4 with: - pattern: model-* + pattern: artifact-name-* path: output + continue-on-error: true - id: set-retry-matrix run: | echo '${{ needs.setup.outputs.model_matrix }}' > matrix.json - built=(); while IFS= read -r line; do built+=("$line"); done < <( - find output -maxdepth 1 -name 'model-*' -printf "%f\n" | sed -E 's/^model-//' | sed -E 's/-[0-9]+$//' | sed -E 's/ \([^)]*\)//' | awk '{gsub(/^ +| +$/, ""); print}' + built=(); while IFS= read -r line; do [ -n "$line" ] && built+=("$line"); done < <( + find output -maxdepth 1 -name 'artifact-name-*' -printf "%f\n" 2>/dev/null | sed -E 's/^artifact-name-//' | awk '{gsub(/^ +| +$/, ""); print}' ) jq -c --argjson built "$(printf '%s\n' "${built[@]}" | jq -R . | jq -s .)" \ 'map(select(.display_name as $n | ($built | index($n | gsub("^ +| +$"; "")) | not)))' matrix.json > retry_matrix.json @@ -149,7 +146,7 @@ jobs: retry_get_and_build: needs: [setup, get_and_build, retry_failed_models] - if: ${{ needs.get_and_build.result == 'failure' || (needs.retry_failed_models.outputs.retry_matrix != '[]' && needs.retry_failed_models.outputs.retry_matrix != '') }} + if: ${{ !cancelled() && needs.retry_failed_models.result == 'success' && needs.retry_failed_models.outputs.retry_matrix != '[]' && needs.retry_failed_models.outputs.retry_matrix != '' }} strategy: matrix: model: ${{ fromJson(needs.retry_failed_models.outputs.retry_matrix) }} @@ -161,146 +158,9 @@ jobs: is_20hz: ${{ matrix.model.is_20hz }} recompiled_dir: ${{ needs.setup.outputs.recompiled_dir }} json_version: ${{ needs.setup.outputs.json_version }} + target_hardware: ${{ github.event.inputs.target_hardware }} artifact_suffix: -retry + hf_repo: ${{ github.event.inputs.hf_repo }} + set_min_version: ${{ github.event.inputs.set_min_version }} + tinygrad_ref: ${{ needs.setup.outputs.tinygrad_ref }} secrets: inherit - - publish_models: - name: Publish models sequentially - needs: [setup, get_and_build, retry_failed_models, retry_get_and_build] - if: ${{ !cancelled() && (needs.get_and_build.result != 'failure' || needs.retry_get_and_build.result == 'success' || (needs.retry_failed_models.outputs.retry_matrix != '[]' && needs.retry_failed_models.outputs.retry_matrix != '')) }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - max-parallel: 1 - matrix: - model: ${{ fromJson(needs.setup.outputs.model_matrix) }} - env: - RECOMPILED_DIR: recompiled${{ needs.setup.outputs.recompiled_dir }} - JSON_FILE: ${{ needs.setup.outputs.json_file }} - ARTIFACT_NAME_INPUT: ${{ matrix.model.display_name }} - steps: - - name: Set up SSH - uses: webfactory/ssh-agent@v0.9.0 - with: - ssh-private-key: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }} - - - name: Add GitLab.com SSH key to known_hosts - run: | - mkdir -p ~/.ssh - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts - - - name: Clone GitLab docs repo - env: - GIT_SSH_COMMAND: 'ssh -o UserKnownHostsFile=~/.ssh/known_hosts' - run: | - echo "Cloning GitLab" - git clone --depth 1 --filter=tree:0 --sparse git@gitlab.com:sunnypilot/public/${{ vars.MODELS_GITLAB }} gitlab_docs - cd gitlab_docs - echo "checkout models/${RECOMPILED_DIR}" - git sparse-checkout set --no-cone models/${RECOMPILED_DIR} - git checkout main - cd .. - - - name: Checkout docs repo - uses: actions/checkout@v4 - with: - repository: sunnypilot/sunnypilot-models - ref: gh-pages - path: docs - ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }} - - - name: Validate recompiled dir and JSON version - run: | - if [ ! -d "gitlab_docs/models/$RECOMPILED_DIR" ]; then - echo "Recompiled dir $RECOMPILED_DIR does not exist in GitLab repo" - exit 1 - fi - if [ ! -f "$JSON_FILE" ]; then - echo "JSON file $JSON_FILE does not exist!" - exit 1 - fi - - - name: Download artifact name file - uses: actions/download-artifact@v4 - with: - name: artifact-name-${{ env.ARTIFACT_NAME_INPUT }} - path: artifact_name - - - name: Read artifact name - id: read-artifact-name - 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.read-artifact-name.outputs.artifact_name }} - path: output - - - name: Remove onnx files bc not needed for recompiled dir since they already exist from single build - run: | - find output -type f -name '*.onnx' -delete - find output -type f -name 'big_*.pkl' -delete - find output -type f -name 'dmonitoring_model_tinygrad.pkl' -delete - - - name: Copy model artifacts to gitlab - env: - ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }} - run: | - ARTIFACT_DIR="gitlab_docs/models/${RECOMPILED_DIR}/${ARTIFACT_NAME}" - mkdir -p "$ARTIFACT_DIR" - for path in output/*; do - if [ "$(basename "$path")" = "artifact_name.txt" ]; then - continue - fi - name="$(basename "$path")" - if [ -d "$path" ]; then - mkdir -p "$ARTIFACT_DIR/$name" - cp -r "$path"/* "$ARTIFACT_DIR/$name/" - echo "Copied dir $name -> $ARTIFACT_DIR/$name" - else - cp "$path" "$ARTIFACT_DIR/" - echo "Copied file $name -> $ARTIFACT_DIR/" - fi - done - - - name: Push recompiled dir to GitLab - env: - GITLAB_SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }} - run: | - cd gitlab_docs - git checkout main - git pull origin main - for d in models/"$RECOMPILED_DIR"/*/; do - git sparse-checkout add "$d" - done - git add models/"$RECOMPILED_DIR" - git config --global user.name "GitHub Action" - git config --global user.email "action@github.com" - git commit -m "Update $RECOMPILED_DIR with model from build-all-tinygrad-models" || echo "No changes to commit" - git push origin main - - run: | - cd docs - git pull origin gh-pages - - - name: update json - run: | - ARGS="" - [ -n "${{ inputs.set_min_version }}" ] && ARGS="$ARGS --set-min-version \"${{ inputs.set_min_version }}\"" - ARGS="$ARGS --sort-by-date" - ARGS="$ARGS --tinygrad-ref \"${{ needs.setup.outputs.tinygrad_ref }}\"" - eval python3 docs/json_parser.py \ - --json-path "$JSON_FILE" \ - --recompiled-dir "gitlab_docs/models/$RECOMPILED_DIR" \ - $ARGS - - - name: Push updated json to GitHub - run: | - cd docs - git config --global user.name "GitHub Action" - git config --global user.email "action@github.com" - git checkout gh-pages - git add docs/"$(basename $JSON_FILE)" - git commit -m "Update $(basename $JSON_FILE) after recompiling model" || echo "No changes to commit" - git push origin gh-pages diff --git a/.github/workflows/build-default-models.yaml b/.github/workflows/build-default-models.yaml new file mode 100644 index 0000000000..8030c7133d --- /dev/null +++ b/.github/workflows/build-default-models.yaml @@ -0,0 +1,501 @@ +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 }} + 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 + + echo "model_name=${NAME}" >> $GITHUB_OUTPUT + echo "onnx_ref=${ONNX_REF}" >> $GITHUB_OUTPUT + echo "onnx_path=${ONNX_PATH}" >> $GITHUB_OUTPUT + echo "hf_defaults_path=${HF_DEFAULTS_PATH}" >> $GITHUB_OUTPUT + echo "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.model_name }} + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH=${{ github.workspace }} + MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models" + OUTPUT_DIR="${{ github.workspace }}/small_output" + PKL_BASE="driving_tinygrad.pkl" + mkdir -p "$OUTPUT_DIR" + + cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/" + cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/" + + python3 "${{ github.workspace }}/release/ci/model_generator.py" \ + --model-dir "$MODELS_DIR" \ + --output-dir "$OUTPUT_DIR" \ + --custom-name "$MODEL_NAME" \ + --upstream-branch "${{ needs.resolve.outputs.onnx_ref }}" + + echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt" + + - name: Upload small model artifact + uses: actions/upload-artifact@v4 + with: + name: model-${{ needs.resolve.outputs.model_name }}-${{ github.run_number }} + path: ${{ github.workspace }}/small_output/ + + - name: Upload artifact name file + uses: actions/upload-artifact@v4 + with: + name: artifact-name-${{ needs.resolve.outputs.model_name }} + path: ${{ github.workspace }}/small_output/artifact_name.txt + + - name: Re-enable powersave + if: always() + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable + + build_big_model: + needs: resolve + if: ${{ inputs.target == 'big' }} + runs-on: [self-hosted, 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.model_name }} + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH=${{ github.workspace }} + MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models" + OUTPUT_DIR="${{ github.workspace }}/big_output" + PKL_BASE="big_driving_tinygrad.pkl" + mkdir -p "$OUTPUT_DIR" + + cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/" + cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/" + + python3 "${{ github.workspace }}/release/ci/model_generator.py" \ + --model-dir "$MODELS_DIR" \ + --output-dir "$OUTPUT_DIR" \ + --custom-name "$MODEL_NAME" \ + --upstream-branch "${{ needs.resolve.outputs.onnx_ref }}" + + echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt" + + - name: Upload big model artifact + uses: actions/upload-artifact@v4 + with: + name: model-${{ needs.resolve.outputs.model_name }}-${{ github.run_number }} + path: ${{ github.workspace }}/big_output/ + + - name: Upload artifact name file + uses: actions/upload-artifact@v4 + with: + name: artifact-name-${{ needs.resolve.outputs.model_name }} + path: ${{ github.workspace }}/big_output/artifact_name.txt + + - name: Re-enable powersave + if: always() + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable + + upload_defaults: + needs: [ resolve, build_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.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 f10f1b71a3..c9d495c15f 100644 --- a/.github/workflows/build-single-tinygrad-model.yaml +++ b/.github/workflows/build-single-tinygrad-model.yaml @@ -12,11 +12,11 @@ on: required: false type: string recompiled_dir: - description: 'Existing recompiled directory number (e.g. 3 for recompiled3)' + description: 'Existing recompiled directory number (e.g. 1 for recompiled1)' required: true type: string json_version: - description: 'driving_models version number to update (e.g. 5 for driving_models_v5.json)' + description: 'driving_models version number to update (e.g. 18 for driving_models_v18.json)' required: true type: string artifact_suffix: @@ -29,11 +29,24 @@ on: required: false type: boolean default: true - bypass_push: - description: 'Bypass pushing to GitLab for build-all' + target_hardware: + description: 'Hardware target to compile for (qcom or chestnut)' required: false - default: true - type: boolean + type: string + default: 'qcom' + hf_repo: + description: 'Hugging Face dataset repository (e.g. sunnypilot/sunnypilot_models_v1)' + required: false + type: string + default: 'sunnypilot/sunnypilot_models_v1' + set_min_version: + description: 'Minimum selector version' + required: false + type: string + tinygrad_ref: + description: 'Tinygrad reference' + required: false + type: string workflow_dispatch: inputs: upstream_branch: @@ -63,12 +76,11 @@ on: default: 'None' options: - None - - Simple Plan Models - - Space Lab Models - - TR Models - - DTR Models + - Master Models + - Release Models + - 2026 World Models + - 2026 Deep RL Models - Custom Merge Models - - FOF series models - Other custom_model_folder: description: 'Custom model folder name (if "Other" selected)' @@ -82,9 +94,22 @@ on: description: 'Minimum selector version' required: false type: string + target_hardware: + description: 'Hardware target to compile for' + required: false + type: choice + default: 'qcom' + options: + - qcom + - chestnut + hf_repo: + description: 'Hugging Face dataset repository' + required: false + type: string + default: 'sunnypilot/sunnypilot_models_v1' env: RECOMPILED_DIR: recompiled${{ inputs.recompiled_dir }} - JSON_FILE: docs/docs/driving_models_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: @@ -94,38 +119,20 @@ jobs: custom_name: ${{ inputs.custom_name || inputs.upstream_branch }} is_20hz: ${{ inputs.is_20hz }} artifact_suffix: ${{ inputs.artifact_suffix }} + target_hardware: ${{ inputs.target_hardware }} secrets: inherit publish_model: - if: ${{ !inputs.bypass_push && !cancelled() }} + if: ${{ !cancelled() && needs.build_model.result == 'success' }} concurrency: - group: gitlab-push-${{ inputs.recompiled_dir }} + group: hf-push-${{ inputs.recompiled_dir }} cancel-in-progress: false needs: build_model runs-on: ubuntu-latest + permissions: + id-token: write + contents: write steps: - - name: Set up SSH - uses: webfactory/ssh-agent@v0.9.0 - with: - ssh-private-key: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }} - - - name: Add GitLab.com SSH key to known_hosts - run: | - mkdir -p ~/.ssh - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts - - - name: Clone GitLab docs repo - env: - GIT_SSH_COMMAND: 'ssh -o UserKnownHostsFile=~/.ssh/known_hosts' - run: | - echo "Cloning GitLab" - git clone --depth 1 --filter=tree:0 --sparse git@gitlab.com:sunnypilot/public/${{ vars.MODELS_GITLAB }} gitlab_docs - cd gitlab_docs - echo "checkout models/${RECOMPILED_DIR}" - git sparse-checkout set --no-cone models/${RECOMPILED_DIR} - git checkout main - cd .. - - name: Checkout docs repo uses: actions/checkout@v4 with: @@ -134,16 +141,28 @@ jobs: path: docs ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }} - - name: Validate recompiled dir and JSON version + - name: Install huggingface_hub + run: pip install --upgrade "huggingface_hub>=0.22.0" + + - name: Validate hf_repo and JSON version + env: + HF_OIDC_RESOURCE: datasets/${{ inputs.hf_repo }} run: | - if [ ! -d "gitlab_docs/models/$RECOMPILED_DIR" ]; then - echo "Recompiled dir $RECOMPILED_DIR does not exist in GitLab repo" - exit 1 - fi if [ ! -f "$JSON_FILE" ]; then echo "JSON file $JSON_FILE does not exist!" exit 1 fi + python3 -c " + import sys + from huggingface_hub import HfApi + try: + api = HfApi() + api.repo_info(repo_id=sys.argv[1], repo_type='dataset') + print(f'Success: Repo {sys.argv[1]} exists.') + except Exception as e: + print('HF validation failed:', e) + sys.exit(1) + " "${{ inputs.hf_repo }}" - name: Download artifact name file uses: actions/download-artifact@v4 @@ -163,49 +182,26 @@ jobs: name: ${{ steps.read-artifact-name.outputs.artifact_name }} path: output - - name: Remove unwanted files - run: | - find output -type f -name 'dmonitoring_model_tinygrad.pkl' -delete - find output -type f -name 'dmonitoring_model.onnx' -delete - - - name: Copy model artifact(s) to GitLab recompiled dir + - name: Create models folder env: ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }} run: | - ARTIFACT_DIR="gitlab_docs/models/${RECOMPILED_DIR}/${ARTIFACT_NAME}" - mkdir -p "$ARTIFACT_DIR" - for path in output/*; do - if [ "$(basename "$path")" = "artifact_name.txt" ]; then - continue - fi - name="$(basename "$path")" - if [ -d "$path" ]; then - mkdir -p "$ARTIFACT_DIR/$name" - cp -r "$path"/* "$ARTIFACT_DIR/$name/" - echo "Copied dir $name -> $ARTIFACT_DIR/$name" - else - cp "$path" "$ARTIFACT_DIR/" - echo "Copied file $name -> $ARTIFACT_DIR/" - fi - done + mkdir -p "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}" + cp -r output/* "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/" + rm -f "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/artifact_name.txt" - - name: Push recompiled dir to GitLab + - name: Upload to Hugging Face env: - GITLAB_SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }} + HF_OIDC_RESOURCE: datasets/${{ inputs.hf_repo }} + ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }} run: | - cd gitlab_docs - git checkout main - git pull origin main - for d in models/"$RECOMPILED_DIR"/*/; do - git sparse-checkout add "$d" - done - git add models/"$RECOMPILED_DIR" - git config --global user.name "GitHub Action" - git config --global user.email "action@github.com" - git commit -m "Create/Update $RECOMPILED_DIR with new/updated model from build-single-tinygrad-model" || echo "No changes to commit" - git push origin main + hf upload ${{ inputs.hf_repo }} \ + output/ \ + "models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/" \ + --repo-type=dataset - - run: | + - name: Pull gh-pages + run: | cd docs git pull origin gh-pages @@ -221,9 +217,11 @@ jobs: fi [ -n "${{ inputs.generation }}" ] && ARGS="$ARGS --generation \"${{ inputs.generation }}\"" [ -n "${{ inputs.version }}" ] && ARGS="$ARGS --version \"${{ inputs.version }}\"" + [ -n "${{ inputs.set_min_version }}" ] && ARGS="$ARGS --set-min-version \"${{ inputs.set_min_version }}\"" + [ -n "${{ inputs.tinygrad_ref }}" ] && ARGS="$ARGS --tinygrad-ref \"${{ inputs.tinygrad_ref }}\"" eval python3 docs/json_parser.py \ --json-path "$JSON_FILE" \ - --recompiled-dir "gitlab_docs/models/$RECOMPILED_DIR" \ + --recompiled-dir "local_models/$RECOMPILED_DIR" \ --sort-by-date \ $ARGS diff --git a/.github/workflows/diff_report.yaml b/.github/workflows/diff_report.yaml index 0f706ea2ed..202bc0ec79 100644 --- a/.github/workflows/diff_report.yaml +++ b/.github/workflows/diff_report.yaml @@ -40,6 +40,7 @@ jobs: echo "run-id=$run_id" >> "$GITHUB_OUTPUT" - name: Download diff if: steps.wait.outcome == 'success' + continue-on-error: true uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -48,7 +49,7 @@ jobs: name: diff_report_${{ github.event.number }} path: . - name: Comment on PR - if: steps.wait.outcome == 'success' + if: steps.wait.outcome == 'success' && hashFiles('diff_report.txt') != '' uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b with: file-path: diff_report.txt diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 0af94ba0da..5c1a8f4dde 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -15,6 +15,11 @@ concurrency: group: docs-tests-ci-run-${{ inputs.run_number }}-${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && github.run_id || github.head_ref || github.ref }}-${{ github.workflow }}-${{ github.event_name }} cancel-in-progress: true +env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: lfs.fetchexclude + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + jobs: docs: name: build docs @@ -30,8 +35,7 @@ jobs: - name: Build docs run: | git lfs pull - pip install zensical - python scripts/docs.py build + python docs/serve.py --build # Push to docs.comma.ai - uses: actions/checkout@v7 @@ -52,7 +56,7 @@ jobs: git rm -rf . # copy over docs - cp -r ../docs_site/ docs/ + cp -r ../docs/_site/ docs/ # GitHub pages config touch docs/.nojekyll 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/lfs-maintenance.yaml b/.github/workflows/lfs-maintenance.yaml index 8780abfbb3..3b92996f82 100644 --- a/.github/workflows/lfs-maintenance.yaml +++ b/.github/workflows/lfs-maintenance.yaml @@ -65,8 +65,21 @@ jobs: echo ' pushurl = ${{ env.LFS_PUSH_URL }}' >> .lfsconfig echo ' locksverify = false' >> .lfsconfig + - name: Configure LFS transfer settings + run: | + git config lfs.activitytimeout 300 + git config lfs.transfer.maxretries 5 + git config lfs.concurrenttransfers 4 + - name: Push LFS id: sync-and-commit run: | git lfs ls-files -l - git lfs push --all origin \ No newline at end of file + for attempt in 1 2 3; do + echo "Push attempt $attempt..." + git lfs push --all origin && exit 0 + echo "Attempt $attempt failed, retrying in 30s..." + sleep 30 + done + echo "All push attempts failed" + exit 1 diff --git a/.github/workflows/model_review.yaml b/.github/workflows/model_review.yaml deleted file mode 100644 index 82c732dc3b..0000000000 --- a/.github/workflows/model_review.yaml +++ /dev/null @@ -1,42 +0,0 @@ -name: "model review" - -on: - pull_request: - types: [opened, reopened, synchronize] - paths: - - 'openpilot/selfdrive/modeld/models/*.onnx' - workflow_dispatch: - -jobs: - comment: - permissions: - contents: read - pull-requests: write - runs-on: ubuntu-latest - if: github.repository == 'commaai/openpilot' - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - submodules: true - - name: Checkout master - uses: actions/checkout@v7 - with: - ref: master - path: base - - run: git lfs pull - - run: cd base && git lfs pull - - - name: scripts/reporter.py - id: report - run: | - echo "content<> $GITHUB_OUTPUT - echo "## Model Review" >> $GITHUB_OUTPUT - PYTHONPATH=${{ github.workspace }} MASTER_PATH=${{ github.workspace }}/base python scripts/reporter.py >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Post model report comment - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 - with: - header: model-review - message: ${{ steps.report.outputs.content }} \ No newline at end of file diff --git a/.github/workflows/prebuilt.yaml b/.github/workflows/prebuilt.yaml deleted file mode 100644 index aeb0f11d84..0000000000 --- a/.github/workflows/prebuilt.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: prebuilt -on: - schedule: - - cron: '0 * * * *' - workflow_dispatch: - -env: - DOCKER_LOGIN: docker login ghcr.io -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }} - BUILD: release/ci/docker_build_sp.sh - -jobs: - build_prebuilt: - name: build prebuilt - runs-on: ubuntu-latest - if: github.repository == 'sunnypilot/sunnypilot' - env: - PUSH_IMAGE: true - permissions: - checks: read - contents: read - packages: write - steps: - - name: Wait for green check mark - if: ${{ github.event_name != 'workflow_dispatch' }} - uses: lewagon/wait-on-check-action@ccfb013c15c8afb7bf2b7c028fb74dc5a068cccc - with: - ref: master - wait-interval: 30 - running-workflow-name: 'build prebuilt' - repo-token: ${{ secrets.GITHUB_TOKEN }} - check-regexp: ^((?!.*(build master-ci|create badges).*).)*$ - - uses: actions/checkout@v6 - with: - submodules: true - - run: git lfs pull - - name: Build and Push docker image - run: | - $DOCKER_LOGIN - eval "$BUILD" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 56aa126684..2240713bcb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -4,6 +4,11 @@ on: - cron: '0 9 * * *' workflow_dispatch: +env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: lfs.fetchexclude + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + jobs: build___nightly: name: build __nightly diff --git a/.github/workflows/repo-maintenance.yaml b/.github/workflows/repo-maintenance.yaml index becfb93627..731300512b 100644 --- a/.github/workflows/repo-maintenance.yaml +++ b/.github/workflows/repo-maintenance.yaml @@ -9,6 +9,9 @@ on: env: PYTHONPATH: ${{ github.workspace }} + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: lfs.fetchexclude + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx jobs: package_updates: @@ -81,7 +84,7 @@ jobs: labels: bot cleanup_closed_branches: - if: github.repository == 'commaai/openpilot' + if: github.repository == 'commaai/openpilot' && github.event_name == 'pull_request' runs-on: ubuntu-latest permissions: contents: write @@ -93,6 +96,14 @@ jobs: const { owner, repo } = context.repo; const upstream = `${owner}/${repo}`; + const closed = context.payload.pull_request; + if (closed) { + if (closed.head.repo?.full_name === upstream) { + await github.rest.git.deleteRef({ owner, repo, ref: `heads/${closed.head.ref}` }).catch(console.log); + } + return; + } + for await (const response of github.paginate.iterator(github.rest.pulls.list, { owner, repo, diff --git a/.github/workflows/sunnypilot-build-model.yaml b/.github/workflows/sunnypilot-build-model.yaml index acb75af55e..459fa74595 100644 --- a/.github/workflows/sunnypilot-build-model.yaml +++ b/.github/workflows/sunnypilot-build-model.yaml @@ -30,6 +30,11 @@ on: required: false type: string default: '' + target_hardware: + description: 'Hardware target to compile for (qcom or chestnut)' + required: false + type: string + default: 'qcom' workflow_dispatch: inputs: upstream_branch: @@ -46,6 +51,14 @@ on: required: false type: boolean default: true + target_hardware: + description: 'Hardware target to compile for' + required: true + type: choice + options: + - qcom + - chestnut + default: 'qcom' run-name: Build model [${{ inputs.custom_name || inputs.upstream_branch }}] from ref [${{ inputs.upstream_branch }}] @@ -67,6 +80,7 @@ jobs: with: repository: commaai/openpilot ref: ${{ inputs.upstream_branch }} + fetch-depth: 1 submodules: recursive path: openpilot @@ -76,26 +90,38 @@ jobs: with: repository: sunnypilot/sunnypilot ref: ${{ inputs.upstream_branch }} + fetch-depth: 1 submodules: recursive path: openpilot - name: Get commit date id: commit-date run: | - cd ${{ github.workspace }}/openpilot + cd ${{ github.workspace }}/openpilot/openpilot commit_date=$(git log -1 --format=%cd --date=format:'%B %d, %Y') echo "model_date=${commit_date}" >> $GITHUB_OUTPUT cat $GITHUB_OUTPUT - run: | - cd ${{ github.workspace }}/openpilot - git lfs pull + cd ${{ github.workspace }}/openpilot/openpilot + 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 + git lfs pull -I "**/selfdrive/modeld/models/big_*.onnx" -X "" + find selfdrive/modeld/models -name "*.onnx" ! -name "big_*.onnx" -delete + fi + if grep -lIF "version https://git-lfs.github.com/spec/v1" selfdrive/modeld/models/*.onnx; then + echo "::error::the ONNX files above are still LFS pointers, not real models" + exit 1 + fi - name: 'Upload Artifact' uses: actions/upload-artifact@v4 with: name: models-${{ env.REF }}${{ inputs.artifact_suffix }} path: ${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.onnx + if-no-files-found: error build_model: - runs-on: [self-hosted, tici] + runs-on: [self-hosted, chestnut] needs: get_model env: MODEL_NAME: ${{ inputs.custom_name || inputs.upstream_branch }} (${{ needs.get_model.outputs.model_date }}) @@ -103,24 +129,9 @@ jobs: steps: - uses: actions/checkout@v4 with: + fetch-depth: 1 submodules: recursive - - run: git lfs pull - - name: Cache SCons - uses: actions/cache@v4 - with: - path: ${{env.SCONS_CACHE_DIR}} - key: scons-${{ runner.os }}-${{ runner.arch }}-${{ github.head_ref || github.ref_name }}-model-${{ github.sha }} - # Note: GitHub Actions enforces cache isolation between different build sources (PR builds, workflow dispatches, etc.) - # for security. Only caches from the default branch are shared across all builds. This is by design and cannot be overridden. - restore-keys: | - scons-${{ runner.os }}-${{ runner.arch }}-${{ github.head_ref || github.ref_name }}-model - scons-${{ runner.os }}-${{ runner.arch }}-${{ github.head_ref || github.ref_name }} - scons-${{ runner.os }}-${{ runner.arch }}-${{ env.MASTER_NEW_BRANCH }}-model - scons-${{ runner.os }}-${{ runner.arch }}-${{ env.MASTER_BRANCH }}-model - scons-${{ runner.os }}-${{ runner.arch }}-${{ env.MASTER_NEW_BRANCH }} - scons-${{ runner.os }}-${{ runner.arch }}-${{ env.MASTER_BRANCH }} - scons-${{ runner.os }}-${{ runner.arch }} - name: Set environment variables id: set-env @@ -131,7 +142,7 @@ jobs: export UV_PYTHON_PREFERENCE=managed export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT - uv sync + uv sync --frozen printenv >> $GITHUB_ENV if [[ "${{ runner.debug }}" == "1" ]]; then cat $GITHUB_OUTPUT @@ -153,40 +164,69 @@ jobs: fi source ${UV_PROJECT_ENVIRONMENT}/bin/activate PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable - rm -rf ${{ env.MODELS_DIR }}/*.onnx + rm -rf ${{ env.MODELS_DIR }}/*.onnx* - name: Download model artifacts uses: actions/download-artifact@v4 with: name: models-${{ env.REF }}${{ inputs.artifact_suffix }} path: ${{ env.MODELS_DIR }} - - run: | - rm -f ${{ env.MODELS_DIR }}/{dmonitoring_model,big_driving_policy,big_driving_vision}.onnx - name: Build Model run: | source /etc/profile export UV_PROJECT_ENVIRONMENT=${HOME}/venv export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT + source ${UV_PROJECT_ENVIRONMENT}/bin/activate export PYTHONPATH="${PYTHONPATH}:${{ env.TINYGRAD_PATH }}:${{ github.workspace }}" COMPILE_MODELD="${{ github.workspace }}/openpilot/sunnypilot/modeld_v2/compile_modeld.py" 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}')") - TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1" + + TG_FLAGS_QCOM="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1" + 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" + TG_FLAGS="$TG_FLAGS_QCOM" + OUTPUT_PKL="${{ env.MODELS_DIR }}/driving_tinygrad.pkl" + fi # Generate metadata for all ONNX files find "${{ env.MODELS_DIR }}" -maxdepth 1 -name '*.onnx' | while IFS= read -r onnx_file; do echo "Generating metadata: $onnx_file" - env ${TG_FLAGS} python3 "${{ env.MODELS_DIR }}/../get_model_metadata.py" "$onnx_file" || true + env ${TG_FLAGS_QCOM} python3 "${{ env.MODELS_DIR }}/../get_model_metadata.py" "$onnx_file" || true done # Detect model type and build compile args - VISION_ONNX="${{ env.MODELS_DIR }}/driving_vision.onnx" - POLICY_ONNX="${{ env.MODELS_DIR }}/driving_policy.onnx" - OFF_POLICY_ONNX="${{ env.MODELS_DIR }}/driving_off_policy.onnx" - ON_POLICY_ONNX="${{ env.MODELS_DIR }}/driving_on_policy.onnx" - SUPERCOMBO_ONNX="${{ env.MODELS_DIR }}/supercombo.onnx" + VISION_ONNX="" + for f in "${{ env.MODELS_DIR }}/driving_vision.onnx" "${{ env.MODELS_DIR }}/big_driving_vision.onnx"; do + [ -f "$f" ] && VISION_ONNX="$f" && break + done + + POLICY_ONNX="" + for f in "${{ env.MODELS_DIR }}/driving_policy.onnx" "${{ env.MODELS_DIR }}/big_driving_policy.onnx"; do + [ -f "$f" ] && POLICY_ONNX="$f" && break + done + + OFF_POLICY_ONNX="" + for f in "${{ env.MODELS_DIR }}/driving_off_policy.onnx" "${{ env.MODELS_DIR }}/big_driving_off_policy.onnx"; do + [ -f "$f" ] && OFF_POLICY_ONNX="$f" && break + done + + ON_POLICY_ONNX="" + for f in "${{ env.MODELS_DIR }}/driving_on_policy.onnx" "${{ env.MODELS_DIR }}/big_driving_on_policy.onnx"; do + [ -f "$f" ] && ON_POLICY_ONNX="$f" && break + done + + SUPERCOMBO_ONNX="" + for f in "${{ env.MODELS_DIR }}/supercombo.onnx" "${{ env.MODELS_DIR }}/driving_supercombo.onnx" "${{ env.MODELS_DIR }}/big_supercombo.onnx" "${{ env.MODELS_DIR }}/big_driving_supercombo.onnx"; do + [ -f "$f" ] && SUPERCOMBO_ONNX="$f" && break + done MODEL_TYPE="" ONNX_ARGS="" OUTPUT_NAME="" if [ -f "$VISION_ONNX" ]; then @@ -207,24 +247,15 @@ jobs: fi if [ -n "$MODEL_TYPE" ]; then - echo "Detected: $MODEL_TYPE -> driving_tinygrad.pkl" + echo "Detected: $MODEL_TYPE -> $OUTPUT_PKL" env ${TG_FLAGS} python3 "$COMPILE_MODELD" \ --model-type $MODEL_TYPE \ --model-size $MODEL_SIZE \ --camera-resolutions $CAMERA_RES \ $ONNX_ARGS \ - --output "${{ env.MODELS_DIR }}/driving_tinygrad.pkl" + --output "$OUTPUT_PKL" fi - - name: Validate Model Outputs - run: | - source /etc/profile - export UV_PROJECT_ENVIRONMENT=${HOME}/venv - export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT - python3 "${{ github.workspace }}/release/ci/model_generator.py" \ - --validate-only \ - --model-dir "${{ env.MODELS_DIR }}" - - name: Prepare Output run: | sudo rm -rf ${{ env.OUTPUT_DIR }} @@ -233,10 +264,8 @@ jobs: # Copy the model files rsync -avm \ --include='*.dlc' \ - --include='*.pkl' \ --include='*.chunk*' \ --include='*.chunkmanifest' \ - --include='*.onnx' \ --exclude='*' \ --delete-excluded \ --chown=comma:comma \ diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index 12a3a7bce0..c93dbe02c1 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -4,12 +4,8 @@ env: BUILD_DIR: "/data/openpilot" OUTPUT_DIR: ${{ github.workspace }}/output CI_DIR: ${{ github.workspace }}/release/ci - SCONS_CACHE_DIR: ${{ github.workspace }}/release/ci/scons_cache PUBLIC_REPO_URL: "https://github.com/sunnypilot/sunnypilot" - # Branch configurations - STAGING_SOURCE_BRANCH: 'master' - # Runtime configuration SOURCE_BRANCH: "${{ github.head_ref || github.ref_name }}" @@ -40,8 +36,11 @@ jobs: publish_concurrency_group: ${{ steps.strategy.outputs.publish_concurrency_group }} is_stable_branch: ${{ steps.strategy.outputs.is_stable_branch }} build: ${{ steps.strategy.outputs.build }} + 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: | @@ -82,6 +81,9 @@ jobs: stable_version=$(cat openpilot/sunnypilot/common/version.h | grep SUNNYPILOT_VERSION | sed -e 's/[^0-9|.]//g'); echo "version=$([ "$is_stable_branch" = "true" ] && echo "$stable_version" || echo "$BUILD")" >> $GITHUB_OUTPUT echo "extra_version_identifier=${environment}" >> $GITHUB_OUTPUT + + include_big_model="$(echo "$CONFIG" | jq -r '.include_big_model // false')"; + echo "include_big_model=$include_big_model" >> $GITHUB_OUTPUT fi echo "build=$BUILD" >> $GITHUB_OUTPUT cat $GITHUB_OUTPUT @@ -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: @@ -109,11 +113,6 @@ jobs: group: build-${{ github.head_ref || github.ref_name }} cancel-in-progress: false runs-on: [self-hosted, tici] - outputs: - new_branch: ${{ needs.prepare_strategy.outputs.new_branch }} - version: ${{ needs.prepare_strategy.outputs.version }} - extra_version_identifier: ${{ needs.prepare_strategy.outputs.extra_version_identifier }} - commit_sha: ${{ github.sha }} if: ${{ (always() && !cancelled() && !failure()) && needs.prepare_strategy.result == 'success' && @@ -124,31 +123,14 @@ 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 }} - run: git lfs pull - - name: Cache SCons - uses: actions/cache@v4 - with: - path: ${{env.SCONS_CACHE_DIR}} - key: scons-${{ runner.os }}-${{ runner.arch }}-${{ env.SOURCE_BRANCH }}-${{ github.sha }} - # Note: GitHub Actions enforces cache isolation between different build sources (PR builds, workflow dispatches, etc.) - # for security. Only caches from the default branch are shared across all builds. This is by design and cannot be overridden. - restore-keys: | - scons-${{ runner.os }}-${{ runner.arch }}-${{ env.SOURCE_BRANCH }} - scons-${{ runner.os }}-${{ runner.arch }}-${{ env.STAGING_SOURCE_BRANCH }} - scons-${{ runner.os }}-${{ runner.arch }} - - name: Set environment variables - id: set-env run: | - echo "new_branch=${{ needs.prepare_strategy.outputs.new_branch }}" >> $GITHUB_OUTPUT - echo "version=${{ needs.prepare_strategy.outputs.version }}" >> $GITHUB_OUTPUT - echo "extra_version_identifier=${{ needs.prepare_strategy.outputs.extra_version_identifier }}" >> $GITHUB_OUTPUT - echo "commit_sha=${{ github.sha }}" >> $GITHUB_OUTPUT - # Set up common environment source /etc/profile; export UV_PROJECT_ENVIRONMENT=${HOME}/venv @@ -157,9 +139,6 @@ jobs: export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT uv sync printenv >> $GITHUB_ENV - if [[ "${{ runner.debug }}" == "1" ]]; then - cat $GITHUB_OUTPUT - fi - name: Setup build environment run: | @@ -168,7 +147,7 @@ jobs: echo "Starting build stage..." echo "BUILD_DIR: ${BUILD_DIR}" echo "CI_DIR: ${CI_DIR}" - echo "VERSION: ${{ steps.set-env.outputs.version }}" + echo "VERSION: ${{ needs.prepare_strategy.outputs.version }}" echo "UV_PROJECT_ENVIRONMENT: ${UV_PROJECT_ENVIRONMENT}" echo "VIRTUAL_ENV: ${VIRTUAL_ENV}" echo "-------" @@ -180,61 +159,44 @@ jobs: - name: Build Main Project run: | - export PYTHONPATH="$BUILD_DIR" - ./tools/release/release_files.py | sort | uniq | rsync -rRl${RUNNER_DEBUG:+v} --files-from=- . $BUILD_DIR/ + export PYTHONPATH="$BUILD_DIR:$BUILD_DIR/msgq_repo:$BUILD_DIR/opendbc_repo:$BUILD_DIR/rednose_repo:$BUILD_DIR/teleoprtc_repo:$BUILD_DIR/tinygrad_repo" + ./tools/release/release_files.py | xargs -0 cp -pR --parents -t "$BUILD_DIR" -- + # outside the checkout, which is wiped each run. /data/scons_cache is the device's, not ours. + SCONS_CACHE="$RUNNER_WORKSPACE/scons_cache" + mkdir -p "$SCONS_CACHE" cd $BUILD_DIR - ln -sfn msgq_repo/msgq msgq - ln -sfn opendbc_repo/opendbc opendbc - ln -sfn rednose_repo/rednose rednose - ln -sfn teleoprtc_repo/teleoprtc teleoprtc - ln -sfn tinygrad_repo/tinygrad tinygrad - sed -i '/from .board.jungle import PandaJungle, PandaJungleDFU/s/^/#/' panda/__init__.py - echo "Building sunnypilot's modeld_v2..." - scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal openpilot/sunnypilot/modeld_v2 - echo "Building sunnypilot's locationd..." - scons -j2 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal openpilot/sunnypilot/selfdrive/locationd - echo "Building openpilot's locationd..." - scons -j1 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal openpilot/selfdrive/locationd + echo "Building locationd..." + # -j1: parallel rednose generators OOM the device + scons -j1 cache_dir="$SCONS_CACHE" --minimal \ + openpilot/selfdrive/locationd openpilot/sunnypilot/selfdrive/locationd echo "Building rest of sunnypilot" - scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --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} fi - - name: Prepare Output + - name: Strip release tree run: | - sudo rm -rf ${OUTPUT_DIR} - mkdir -p ${OUTPUT_DIR} - rsync -am${RUNNER_DEBUG:+v} \ - --exclude='.sconsign.dblite' \ - --exclude='*.a' \ - --exclude='*.o' \ - --exclude='*.os' \ - --exclude='*.pyc' \ - --exclude='moc_*' \ - --exclude='__pycache__' \ - --exclude='Jenkinsfile' \ - --exclude='**/release/' \ - --exclude='**/.github/' \ - --exclude='**/openpilot/selfdrive/ui/replay/' \ - --exclude='**/__pycache__/' \ - --exclude='${{env.SCONS_CACHE_DIR}}' \ - --exclude='**/.git/' \ - --exclude='**/SConstruct' \ - --exclude='**/SConscript' \ - --exclude='**/.venv/' \ - --exclude='openpilot/selfdrive/modeld/models/*.onnx*' \ - --exclude='openpilot/sunnypilot/modeld*/models/*.onnx*' \ - --exclude='openpilot/third_party/*x86*' \ - --exclude='openpilot/third_party/*Darwin*' \ - --delete-excluded \ - --chown=comma:comma \ - ${BUILD_DIR}/ ${OUTPUT_DIR}/ + cd $BUILD_DIR + find . -name '*.a' -delete + find . -name '*.o' -delete + find . -name '*.os' -delete + find . -name '*.pyc' -delete + find . -name 'moc_*' -delete + find . -name '__pycache__' -type d -exec rm -rf {} + + find . -name 'SConstruct' -delete + find . -name 'SConscript' -delete + rm -rf .sconsign.dblite Jenkinsfile tools/release/ release/ + rm -f openpilot/selfdrive/modeld/models/*.onnx* + rm -f openpilot/sunnypilot/modeld*/models/*.onnx* + find openpilot/third_party/ -name '*x86*' -exec rm -r {} + + find openpilot/third_party/ -name '*Darwin*' -exec rm -r {} + + cd - - name: 'Tar.gz files' run: | - tar czf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }} . + tar czf prebuilt.tar.gz -C ${{ env.BUILD_DIR }} . ls -la prebuilt.tar.gz - name: 'Upload Artifact' @@ -242,6 +204,7 @@ jobs: with: name: prebuilt path: prebuilt.tar.gz + compression-level: 0 - name: Re-enable powersave if: always() @@ -249,22 +212,212 @@ jobs: source ${UV_PROJECT_ENVIRONMENT}/bin/activate PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable + prepare_chestnut: + needs: [ prepare_strategy ] + runs-on: ubuntu-24.04 + if: ${{ needs.prepare_strategy.outputs.include_big_model == 'true' }} + 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: + - name: Resolve ONNX hash and tinygrad ref via API + id: resolve + run: | + 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_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 + BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null) + [ -n "$BUNDLE" ] && [ "$BUNDLE" != "null" ] + } + + 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 }} + + - name: Cancel run on failure + if: failure() + run: gh run cancel ${{ github.run_id }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} publish: concurrency: - # We do a bit of a hack here to avoid canceling the publishing job if a new commit comes in while we're publishing by adding the sha to the group name. - # This means that if multiple commits come in while we're publishing, they will be queued up and publish one after the other. - # Otherwise, if a job is waiting to be published due to environment wait time, it would be canceled by a new commit and restart the wait time. group: ${{ needs.prepare_strategy.outputs.publish_concurrency_group }} cancel-in-progress: ${{ needs.prepare_strategy.outputs.cancel_publish_in_progress == 'true' }} - if: ${{ (always() && !cancelled() && !failure()) && needs.build.result == 'success' && needs.prepare_strategy.result == 'success' && (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) }} - needs: [ build, prepare_strategy ] + 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, 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 build artifacts + - name: Download prebuilt artifact uses: actions/download-artifact@v4 with: name: prebuilt @@ -274,6 +427,17 @@ jobs: 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"} + ] + - name: Configure Git run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" @@ -283,36 +447,95 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - echo '${{ toJSON(needs.build.outputs) }}' + echo '${{ toJSON(needs.prepare_strategy.outputs) }}' ls -la ${{ env.OUTPUT_DIR }} ${{ env.CI_DIR }}/publish.sh \ "${{ github.workspace }}" \ "${{ env.OUTPUT_DIR }}" \ - "${{ needs.build.outputs.new_branch }}" \ - "${{ needs.build.outputs.version }}" \ + "${{ needs.prepare_strategy.outputs.new_branch }}" \ + "${{ needs.prepare_strategy.outputs.version }}" \ "https://x-access-token:${{github.token}}@github.com/sunnypilot/sunnypilot.git" \ - "${{ needs.build.outputs.extra_version_identifier }}" - - echo "" - echo "---- ℹ️ To update the list of branches that auto deploy prebuilts -----" - echo "" - echo "1. Go to: ${{ github.server_url }}/${{ github.repository }}/settings/variables/actions/AUTO_DEPLOY_PREBUILT_BRANCHES" - echo "2. Current value: ${{ vars.AUTO_DEPLOY_PREBUILT_BRANCHES }}" - echo "3. Update as needed (JSON array with no spaces)" + "${{ 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: | TAG="${{ needs.prepare_strategy.outputs.environment }}/${{ needs.prepare_strategy.outputs.version }}/${{ needs.prepare_strategy.outputs.build }}" - git tag -f -a ${TAG} -m "${{ needs.prepare_strategy.outputs.environment }} @ ${{ needs.prepare_strategy.outputs.version }} of build ${{ needs.build.outputs.build }}." + 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' @@ -320,11 +543,12 @@ 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 run: | - TEMPLATE='${{ vars.DISCOURSE_GENERAL_UPDATE_NOTICE }}' export VERSION="${{ needs.prepare_strategy.outputs.version }}" export branch_name="${{ env.SOURCE_BRANCH }}" export new_branch="${{ needs.prepare_strategy.outputs.new_branch }}" @@ -333,6 +557,7 @@ jobs: export commit_short_sha="${commit_short_sha:0:7}" export extra_version_identifier="${{ needs.prepare_strategy.outputs.extra_version_identifier || github.run_number }}" export PUBLIC_REPO_URL="${{ env.PUBLIC_REPO_URL }}" + export chestnut_branch="${{ needs.prepare_chestnut.result == 'success' && format('{0}-chestnut', needs.prepare_strategy.outputs.new_branch) || '' }}" MESSAGE=$(cat << 'EOF' | envsubst ${{ vars.DISCOURSE_GENERAL_UPDATE_NOTICE }} @@ -373,7 +598,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - name: process.env.LABELf + name: process.env.LABEL }); console.log(`Removed '${process.env.LABEL}' label from PR #${prNumber}`); diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b5f941fc76..b2e4096960 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,7 +20,9 @@ concurrency: env: CI: 1 PYTHONPATH: ${{ github.workspace }} - PYTEST: pytest --continue-on-collection-errors --durations=0 -n logical + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: lfs.fetchexclude + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx jobs: build_release: @@ -125,14 +127,10 @@ jobs: timeout-minutes: ${{ contains(runner.name, 'nsc') && 2 || 999 }} env: RAYLIB_BACKEND: headless - run: | - # Pre-compile Python bytecode so each pytest worker doesn't need to - $PYTEST --collect-only -m 'not slow' -qq - MAX_EXAMPLES=1 $PYTEST -m 'not slow' + run: tools/op.sh test process_replay: name: process replay - if: false # disable process_replay for forks runs-on: ${{ (github.repository == 'commaai/openpilot') && ((github.event_name != 'pull_request') || @@ -169,14 +167,14 @@ jobs: name: diff_report_${{ github.event.number }} path: openpilot/selfdrive/test/process_replay/diff_report.txt - name: Checkout ci-artifacts - if: github.repository == 'commaai/openpilot' && github.ref == 'refs/heads/master' + if: github.repository == 'sunnypilot/sunnypilot' && github.ref == 'refs/heads/master' uses: actions/checkout@v7 with: - repository: commaai/ci-artifacts + repository: sunnypilot/ci-artifacts ssh-key: ${{ secrets.CI_ARTIFACTS_DEPLOY_KEY }} path: ${{ github.workspace }}/ci-artifacts - name: Prepare refs - if: github.repository == 'commaai/openpilot' && github.ref == 'refs/heads/master' + if: github.repository == 'sunnypilot/sunnypilot' && github.ref == 'refs/heads/master' working-directory: ${{ github.workspace }}/ci-artifacts run: | git config user.name "GitHub Actions Bot" @@ -188,18 +186,12 @@ jobs: git add . git commit -m "process-replay refs for ${{ github.repository }}@${{ github.sha }}" || echo "No changes to commit" - name: Push refs - if: github.repository == 'commaai/openpilot' && github.ref == 'refs/heads/master' + if: github.repository == 'sunnypilot/sunnypilot' && github.ref == 'refs/heads/master' uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 with: timeout_minutes: 2 max_attempts: 3 command: cd ${{ github.workspace }}/ci-artifacts && git push origin process-replay --force - - name: Run regen - if: false - timeout-minutes: 4 - env: - ONNXCPU: 1 - run: $PYTEST openpilot/selfdrive/test/process_replay/test_regen.py simulator_driving: name: simulator driving @@ -220,7 +212,7 @@ jobs: env: # MetaDrive renders offscreen through panda3d's EGL pipe on llvmpipe EGL_PLATFORM: surfaceless - run: pytest -s openpilot/tools/sim/tests/test_metadrive_bridge.py + run: python openpilot/tools/sim/tests/test_metadrive_bridge.py create_ui_report: name: Create UI Report diff --git a/.github/workflows/ui_preview.yaml b/.github/workflows/ui_preview.yaml index 8b7b63f344..c6bc63c8ac 100644 --- a/.github/workflows/ui_preview.yaml +++ b/.github/workflows/ui_preview.yaml @@ -25,7 +25,8 @@ env: jobs: preview: - if: github.repository == 'sunnypilot/sunnypilot' + if: false # tmp disable due to GH API rate limiting flakiness + #if: github.repository == 'sunnypilot/sunnypilot' name: preview runs-on: ubuntu-latest timeout-minutes: 20 diff --git a/.gitignore b/.gitignore index a033f70507..9dee0af9af 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ a.out .cache/ bin/ -# created at launch for TICI PYTHONPATH (PC uses editable installs via pyproject.toml) +# created at launch for comma hardware PYTHONPATH (PC uses editable installs via pyproject.toml) /msgq /opendbc /rednose @@ -39,6 +39,8 @@ bin/ *.os-* *.so *.a +st[0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z] +*.unchunked *.clb *.class *.pyxbldc @@ -54,7 +56,7 @@ compare_runtime*.html openpilot/selfdrive/modeld/models/tg_input_devices.json # build artifacts -docs_site/ +docs/_site/ openpilot/selfdrive/pandad/pandad openpilot/cereal/services.h openpilot/cereal/gen diff --git a/Jenkinsfile b/Jenkinsfile index d57e9502aa..af33edb821 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -22,6 +22,7 @@ shopt -s huponexit # kill all child processes when the shell exits export CI=1 export PYTHONWARNINGS=error +export COMMA_CACHE=/data/tmp/comma_download_cache #export LOGPRINT=debug # this has gotten too spammy... export TEST_DIR=${env.TEST_DIR} export SOURCE_DIR=${env.SOURCE_DIR} @@ -30,14 +31,14 @@ export GIT_COMMIT=${env.GIT_COMMIT} export CI_ARTIFACTS_TOKEN=${env.CI_ARTIFACTS_TOKEN} export GITHUB_COMMENTS_TOKEN=${env.GITHUB_COMMENTS_TOKEN} export AZURE_TOKEN='${env.AZURE_TOKEN}' -# only use 1 thread for tici tests since most require HIL +# only use 1 thread since most require real hardware that can't be shared export PYTEST_ADDOPTS="-n0 -s" export GIT_SSH_COMMAND="ssh -i /data/gitkey" source ~/.bash_profile -if [ -f /TICI ]; then +if [ -f /AGNOS ]; then source /etc/profile rm -rf /tmp/tmp* @@ -206,35 +207,35 @@ node { deviceStage("onroad", "tizi-needs-can", ["UNSAFE=1"], [ step("build openpilot", "cd openpilot/system/manager && ./build.py"), step("check dirty", "tools/release/check-dirty.sh"), - step("onroad tests", "pytest openpilot/selfdrive/test/test_onroad.py -s", [timeout: 60]), + step("onroad tests", "./openpilot/selfdrive/test/test_onroad.py", [timeout: 60]), ]) }, 'HW + Unit Tests': { deviceStage("tizi-hardware", "tizi-common", ["UNSAFE=1"], [ step("build", "cd openpilot/system/manager && ./build.py"), - step("test power draw", "pytest -s openpilot/selfdrive/test//test_power_draw.py"), - step("test encoder", "pytest openpilot/system/loggerd/tests/test_encoder.py", [diffPaths: ["openpilot/system/loggerd/"]]), - step("test manager", "pytest openpilot/system/manager/test/test_manager.py"), + step("test power draw", "./openpilot/selfdrive/test/test_power_draw.py"), + step("test encoder", "./openpilot/system/loggerd/tests/test_encoder.py", [diffPaths: ["openpilot/system/loggerd/"]]), + step("test manager", "./openpilot/system/manager/test/test_manager.py"), ]) }, 'camerad OX03C10': { deviceStage("OX03C10", "tizi-ox03c10", ["UNSAFE=1"], [ step("build", "cd openpilot/system/manager && ./build.py"), - step("test pandad", "pytest openpilot/selfdrive/pandad/tests/test_pandad.py"), - step("test camerad", "pytest openpilot/system/camerad/test/test_camerad.py", [timeout: 90]), + step("test pandad", "./openpilot/selfdrive/pandad/tests/test_pandad.py"), + step("test camerad", "./openpilot/system/camerad/test/test_camerad.py", [timeout: 90]), ]) }, 'camerad OS04C10': { deviceStage("OS04C10", "tici-os04c10", ["UNSAFE=1"], [ step("build", "cd openpilot/system/manager && ./build.py"), - step("test pandad", "pytest openpilot/selfdrive/pandad/tests/test_pandad.py"), - step("test camerad", "pytest openpilot/system/camerad/test/test_camerad.py", [timeout: 90]), + step("test pandad", "./openpilot/selfdrive/pandad/tests/test_pandad.py"), + step("test camerad", "./openpilot/system/camerad/test/test_camerad.py", [timeout: 90]), ]) }, 'sensord': { deviceStage("LSM + MMC", "tizi-lsmc", ["UNSAFE=1"], [ step("build", "cd openpilot/system/manager && ./build.py"), - step("test sensord", "pytest openpilot/system/sensord/tests/test_sensord.py"), + step("test sensord", "./openpilot/system/sensord/tests/test_sensord.py"), ]) }, 'replay': { @@ -246,9 +247,9 @@ node { 'tizi': { deviceStage("tizi", "tizi", ["UNSAFE=1"], [ step("build openpilot", "cd openpilot/system/manager && ./build.py"), - step("test pandad loopback", "pytest openpilot/selfdrive/pandad/tests/test_pandad_loopback.py"), - step("test pandad spi", "pytest openpilot/selfdrive/pandad/tests/test_pandad_spi.py"), - step("test amp", "pytest openpilot/common/hardware/tici/tests/test_amplifier.py"), + step("test pandad loopback", "./openpilot/selfdrive/pandad/tests/test_pandad_loopback.py"), + step("test pandad spi", "./openpilot/selfdrive/pandad/tests/test_pandad_spi.py"), + step("test amp", "./openpilot/common/hardware/comma/tests/test_amplifier.py"), ]) }, diff --git a/RELEASES.md b/RELEASES.md index 770a7c40fe..36ddadb431 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,6 +1,14 @@ -Version 0.11.2 (2026-06-15) -======================== - +Version 0.11.2 (2026-08-12) +======================= +* New driving model + * Big model with 880M parameters +* Support for big models running on an external GPU +* Live stream cameras from comma connect +* Generate dashcam clips from comma connect +* Remote comma body control from comma connect +* New alert sounds +* CUPRA Born 2021-2023 support thanks to DaHansi! +* Volkswagen ID.4 2021-2025 support thanks to DaHansi! Version 0.11.1 (2026-05-18) ======================== diff --git a/SConstruct b/SConstruct index fa3ba952c3..4e9dedd947 100644 --- a/SConstruct +++ b/SConstruct @@ -10,7 +10,7 @@ import numpy as np import SCons.Errors from SCons.Defaults import _stripixes -TICI = os.path.isfile('/TICI') +COMMA_HARDWARE = os.path.isfile('/AGNOS') SCons.Warnings.warningAsException(True) @@ -24,7 +24,7 @@ release = not os.path.exists(File('#.gitattributes').abspath) # file absent on r AddOption('--minimal', action='store_false', dest='extras', - default=(not TICI and not release), + default=(not COMMA_HARDWARE and not release), help='the minimum build to run openpilot. no tests, tools, etc.') submodule_python_paths = [ @@ -46,22 +46,22 @@ if external_pythonpath := os.environ.get("PYTHONPATH"): arch = subprocess.check_output(["uname", "-m"], encoding='utf8').rstrip() if platform.system() == "Darwin": arch = "Darwin" -elif arch == "aarch64" and TICI: - arch = "larch64" +elif arch == "aarch64" and COMMA_HARDWARE: + arch = "comma_arm64" assert arch in [ - "larch64", # linux tici arm64 - "aarch64", # linux pc arm64 - "x86_64", # linux pc x64 - "Darwin", # macOS arm64 (x86 not supported) + "comma_arm64", # linux comma hardware (AGNOS) arm64 + "aarch64", # linux pc arm64 + "x86_64", # linux pc x64 + "Darwin", # macOS arm64 (x86 not supported) ] -pkg_names = ['acados', 'bzip2', 'capnproto', 'catch2', 'eigen', 'ffmpeg', 'json11', 'ncurses', 'zeromq', 'zstd'] +pkg_names = ['acados', 'capnproto', 'eigen', 'ffmpeg', 'json11', 'ncurses', 'zeromq', 'zstd'] pkgs = [importlib.import_module(name) for name in pkg_names] acados = pkgs[pkg_names.index('acados')] ffmpeg = pkgs[pkg_names.index('ffmpeg')] # Shared package ships .so/.dylib; older device venvs still have static .a only. # Keep static link deps (x264/z/va/drm) when the installed package is static so -# TICI CI works without upgrading the device venv yet. +# COMMA_HARDWARE CI works without upgrading the device venv yet. # TODO: drop the static fallback once device venvs have comma-deps-ffmpeg>=7.1.0.post94 _ffmpeg_lib_names = os.listdir(ffmpeg.LIB_DIR) if os.path.isdir(ffmpeg.LIB_DIR) else [] ffmpeg_shared = any( @@ -129,10 +129,11 @@ env = Environment( CCFLAGS=[ "-g", "-fPIC", + "-pipe", "-O2", "-Wunused", "-Werror", - "-Wshadow" if arch in ("Darwin", "larch64") else "-Wshadow=local", + "-Wshadow" if arch in ("Darwin", "comma_arm64") else "-Wshadow=local", "-Wno-unknown-warning-option", "-Wno-inconsistent-missing-override", "-Wno-c99-designator", @@ -164,19 +165,24 @@ env = Environment( COMPILATIONDB_USE_ABSPATH=True, REDNOSE_ROOT="#rednose_repo", tools=["default", "cython", "compilation_db", "rednose_filter"], - toolpath=["#site_scons/site_tools", "#rednose_repo/site_scons/site_tools"], + toolpath=["#msgq_repo/site_scons/site_tools", "#rednose_repo/site_scons/site_tools"], ) -if arch != "larch64": +# SCons' Darwin linker tool doesn't define the variables used to expand RPATH. +if arch == "Darwin": + env["RPATHPREFIX"] = "-Wl,-rpath," + env["RPATHSUFFIX"] = "" + env["_RPATH"] = "${_concat(RPATHPREFIX, RPATH, RPATHSUFFIX, __env__)}" +if arch != "comma_arm64": env['_LIBFLAGS'] = _libflags # Arch-specific flags and paths -if arch == "larch64": +if arch == "comma_arm64": env["CC"] = "clang" env["CXX"] = "clang++" env.Append(LIBPATH=[ "/usr/lib/aarch64-linux-gnu", ]) - arch_flags = ["-D__TICI__", "-mcpu=cortex-a57", "-DQCOM2"] + arch_flags = ["-D__COMMA_HARDWARE__", "-mcpu=cortex-a57"] env.Append(CCFLAGS=arch_flags) env.Append(CXXFLAGS=arch_flags) elif arch == "Darwin": @@ -228,7 +234,7 @@ Export('envCython', 'np_version') Export('env', 'arch', 'acados', 'release', 'ffmpeg_libs') # Setup cache dir -default_cache_dir = '/data/scons_cache' if arch == "larch64" else '/tmp/scons_cache' +default_cache_dir = '/data/scons_cache' if arch == "comma_arm64" else '/tmp/scons_cache' cache_dir = ARGUMENTS.get('cache_dir', default_cache_dir) cache_size_limit = 4e9 if "CI" in os.environ else 2e9 CacheDir(cache_dir) @@ -275,7 +281,7 @@ SConscript([ 'openpilot/system/loggerd/SConscript', ]) -if arch == "larch64": +if arch == "comma_arm64": SConscript(['openpilot/system/camerad/SConscript']) # Build selfdrive @@ -290,7 +296,7 @@ SConscript([ SConscript(['openpilot/sunnypilot/SConscript']) # Build desktop-only tools -if GetOption('extras') and arch != "larch64": +if GetOption('extras') and arch != "comma_arm64": SConscript([ 'openpilot/tools/replay/SConscript', 'openpilot/tools/cabana/SConscript', diff --git a/conftest.py b/conftest.py deleted file mode 100644 index d7bc7a6bf1..0000000000 --- a/conftest.py +++ /dev/null @@ -1,99 +0,0 @@ -import contextlib -import gc -import os -import pytest - -from openpilot.common.prefix import OpenpilotPrefix -from openpilot.system.manager import manager -from openpilot.common.hardware import TICI, HARDWARE - -# these are heavy CI-only tests, invoked explicitly in .github/workflows/tests.yaml -collect_ignore = [ - "openpilot/selfdrive/test/process_replay/test_processes.py", - "openpilot/selfdrive/test/process_replay/test_regen.py", - - "openpilot/tools/sim/", - - # tinygrad JIT has process-global state. Other test files import modeld → tinygrad, - # which corrupts JIT captures for test_warp.py in the same process. Run separately in CI. - "openpilot/sunnypilot/modeld_v2/tests/test_warp.py", -] - - -def pytest_sessionstart(session): - # TODO: fix tests and enable test order randomization - if session.config.pluginmanager.hasplugin('randomly'): - session.config.option.randomly_reorganize = False - - -@pytest.hookimpl(hookwrapper=True, trylast=True) -def pytest_runtest_call(item): - # ensure we run as a hook after capturemanager's - if item.get_closest_marker("nocapture") is not None: - capmanager = item.config.pluginmanager.getplugin("capturemanager") - with capmanager.global_and_fixture_disabled(): - yield - else: - yield - - -@contextlib.contextmanager -def clean_env(): - starting_env = dict(os.environ) - yield - os.environ.clear() - os.environ.update(starting_env) - - -@pytest.fixture(scope="function", autouse=True) -def openpilot_function_fixture(request): - with clean_env(): - # setup a clean environment for each test - with OpenpilotPrefix(shared_download_cache=request.node.get_closest_marker("shared_download_cache") is not None) as prefix: - prefix = os.environ["OPENPILOT_PREFIX"] - - yield - - # ensure the test doesn't change the prefix - assert "OPENPILOT_PREFIX" in os.environ and prefix == os.environ["OPENPILOT_PREFIX"] - - # cleanup any started processes - manager.manager_cleanup() - - # some processes disable gc for performance, re-enable here - if not gc.isenabled(): - gc.enable() - gc.collect() - -# If you use setUpClass, the environment variables won't be cleared properly, -# so we need to hook both the function and class pytest fixtures -@pytest.fixture(scope="class", autouse=True) -def openpilot_class_fixture(): - with clean_env(): - yield - - -@pytest.fixture(scope="function") -def tici_setup_fixture(request, openpilot_function_fixture): - """Ensure a consistent state for tests on-device. Needs the openpilot function fixture to run first.""" - if 'skip_tici_setup' in request.keywords: - return - HARDWARE.initialize_hardware() - HARDWARE.set_power_save(False) - os.system("pkill -9 -f athena") - - -@pytest.hookimpl(tryfirst=True) -def pytest_collection_modifyitems(config, items): - skipper = pytest.mark.skip(reason="Skipping tici test on PC") - for item in items: - if "tici" in item.keywords: - if not TICI: - item.add_marker(skipper) - else: - item.fixturenames.append('tici_setup_fixture') - - if "xdist_group_class_property" in item.keywords: - class_property_name = item.get_closest_marker('xdist_group_class_property').args[0] - class_property_value = getattr(item.cls, class_property_name) - item.add_marker(pytest.mark.xdist_group(class_property_value)) diff --git a/docs/AI_POLICY.md b/docs/AI_POLICY.md new file mode 100644 index 0000000000..a32eb96bac --- /dev/null +++ b/docs/AI_POLICY.md @@ -0,0 +1,44 @@ +# AI policy + +## Why this exists + +We use AI tools ourselves, so this isn't an anti-AI stance. The problem is people submitting code, issues, or comments they don't actually understand. AI makes that very easy to do, and it creates real work for reviewers who have to figure out what you meant when you can't explain it yourself. + +If you're not going to put effort into understanding and verifying your submission, we're not going to put effort into reviewing it. + +## The rule + +You are responsible for everything you submit: code, PR descriptions, issues, bug reports, comments. + +1. Understand what you submit. If a reviewer asks why you did something, you answer from your own understanding, not by re-prompting. If you can't do that, don't submit it. + +2. Test your change. AI gets things wrong all the time. Run it, break it, confirm it actually works. + +3. Driving fixes need real evidence. Attach a dongle ID, upload logs, and include segments that show the fix working. A route hash by itself proves nothing. + +4. No AI-generated media (images, diagrams, videos) in issues or PRs. + +## Disclosure + +If AI tools helped you write something, say so. Add an `Assisted-by:` line in your commit message: + +``` +Assisted-by: GitHub Copilot +Assisted-by: Claude +``` + +Disclosing won't count against your PR. It helps reviewers know where to look. Hiding it and getting caught will. + +## How we review + +Reviewers are looking at whether you understand your own change. Can you explain it? Can you respond to feedback without re-prompting? Does your PR description say why you made the change, not just list what changed? + +Good code from someone who used AI and understands what they wrote is fine. How you got there doesn't matter as long as you can stand behind it. + +## What happens + +Submissions that don't meet this bar get closed. If it keeps happening, you get blocked. + +## Maintainers + +Maintainers use AI at their discretion. They've earned that through sustained contribution and they know the codebase. diff --git a/docs/CARS.md b/docs/CARS.md index c52707f4f3..0866436c0d 100644 --- a/docs/CARS.md +++ b/docs/CARS.md @@ -1,10 +1,10 @@ - + # Supported Cars A supported vehicle is one that just works when you install a comma device. All supported cars provide a better experience than any stock system. Supported vehicles reference the US market unless otherwise specified. -# 341 Supported Cars +# 345 Supported Cars |Make|Model|Supported Package|ACC|No ACC accel below|No ALC below|Steering Torque|Resume from stop|Hardware Needed
 |Video|Setup Video| |---|---|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| @@ -34,6 +34,7 @@ A supported vehicle is one that just works when you install a comma device. All |Chrysler|Pacifica Hybrid 2019-25|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |comma|body|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|None||| |CUPRA[12](#footnotes)|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|CUPRA[12](#footnotes)|Born 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Dodge|Durango 2020-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Bronco Sport 2021-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Escape 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -78,11 +79,11 @@ A supported vehicle is one that just works when you install a comma device. All |Honda|Accord 2018-22|All|openpilot available[1,5](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Accord 2023-25|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Accord Hybrid 2018-22|All|openpilot available[1,5](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Honda|Accord Hybrid 2023-25|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Honda|City (Brazil only) 2023|All|openpilot available[1,5](#footnotes)|0 mph|14 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Accord Hybrid 2023-26|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|City (Brazil only) 2023-25|All|openpilot available[1,5](#footnotes)|0 mph|14 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Civic 2016-18|Honda Sensing|openpilot|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Civic 2019-21|All|openpilot available[1,5](#footnotes)|0 mph|2 mph[4](#footnotes)|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Honda|Civic 2022-24|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic 2022-26|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Civic Hatchback 2017-18|Honda Sensing|openpilot available[1,5](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Civic Hatchback 2019-21|All|openpilot available[1,5](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Civic Hatchback 2022-24|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -158,7 +159,7 @@ A supported vehicle is one that just works when you install a comma device. All |Hyundai|Tucson Plug-in Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Veloster 2019-20|Smart Cruise Control (SCC)|Stock|5 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Jeep|Grand Cherokee 2016-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Jeep|Grand Cherokee 2019-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Jeep|Grand Cherokee 2019-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Carnival 2022-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Carnival (China only) 2023|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Ceed 2019-21|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -187,7 +188,7 @@ A supported vehicle is one that just works when you install a comma device. All |Kia|Niro Plug-in Hybrid 2022|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Optima 2017|Advanced Smart Cruise Control|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Optima 2019-20|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Kia|Optima Hybrid 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Optima Hybrid 2019|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Seltos 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Sorento 2018|Advanced Smart Cruise Control & LKAS|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Sorento 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -230,31 +231,32 @@ A supported vehicle is one that just works when you install a comma device. All |Mazda|CX-9 2021-23|All|Stock|0 mph|28 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Mazda connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Nissan[6](#footnotes)|Altima 2019-24|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Nissan[6](#footnotes)|Leaf 2018-23|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Nissan[6](#footnotes)|Leaf IC 2018-23|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Nissan[6](#footnotes)|Rogue 2018-20|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Nissan[6](#footnotes)|X-Trail 2017|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Ram|1500 2019-24|Adaptive Cruise Control (ACC)|Stock|32 mph|1 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ram|2500 2020-24|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ram|3500 2019-22|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Rivian|R1S 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Rivian|R1S 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Rivian|R1S 2025|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Rivian|R1T 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Rivian|R1T 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Rivian|R1T 2025|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |SEAT[12](#footnotes)|Ateca 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |SEAT[12](#footnotes)|Leon 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Subaru|Ascent 2019-21|All[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Crosstrek 2018-19|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Crosstrek 2020-23|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Ascent 2019-21|All[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Crosstrek 2018-19|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Crosstrek 2020-23|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Subaru|Forester 2017-18|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Forester 2019-21|All[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Impreza 2017-19|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Impreza 2020-22|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Forester 2019-21|All[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Impreza 2017-19|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Impreza 2020-22|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Subaru|Legacy 2015-18|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Subaru|Legacy 2020-22|All[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Subaru|Outback 2015-17|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Subaru|Outback 2018-19|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Subaru|Outback 2020-22|All[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|XV 2018-19|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|XV 2020-21|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|XV 2018-19|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|XV 2020-21|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Škoda|Fabia 2022-23[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[17](#footnotes)||| |Škoda|Kamiq 2021-23[13,15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[17](#footnotes)||| |Škoda[12](#footnotes)|Karoq 2019-23[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| @@ -333,6 +335,8 @@ A supported vehicle is one that just works when you install a comma device. All |Volkswagen[12](#footnotes)|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[12](#footnotes)|ID.4 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[12](#footnotes)|ID.4 2024-25|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Jetta 2019-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen|Passat 2015-22[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index cbeb5f6d3a..dd8d569821 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,3 +1,5 @@ +> sunnypilot follows [commaai/openpilot](https://github.com/commaai/openpilot)'s contributing guidelines. The following applies to all contributions here. + # How to contribute Our software is open source so you can solve your own problems without needing help from others. And if you solve a problem and are so kind, you can upstream it for the rest of the world to use. Check out our [post about externalization](https://blog.comma.ai/a-2020-theme-externalization/). @@ -35,6 +37,7 @@ All of these are examples of good PRs: * **UI design**: we do not have a good review process for this yet * **New features**: We believe openpilot is mostly feature-complete, and the rest is a matter of refinement and fixing bugs. As a result of this, most feature PRs will be immediately closed, however the beauty of open source is that forks can and do offer features that upstream openpilot doesn't. * **Negative expected value**: This is a class of PRs that makes an improvement, but the risk or validation costs more than the improvement. The risk can be mitigated by first getting a failing test merged. +* **AI-generated contributions**: see our [AI policy](AI_POLICY.md) ### First contribution @@ -58,7 +61,7 @@ A good pull request has all of the following: * Report bugs in GitHub issues. * Report driving issues in the `#driving-feedback` Discord channel. -* Consider opting into driver camera uploads to improve the driver monitoring model. +* Consider opting into cabin camera uploads to improve the driver monitoring model. * Connect your device to Wi-Fi regularly, so that we can pull data for training better driving models. * Run the `nightly` branch and report issues. This branch is like `master` but it's built just like a release. * Annotate images in the [comma10k dataset](https://github.com/commaai/comma10k). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md deleted file mode 100644 index e803a3fb8a..0000000000 --- a/docs/DEVELOPMENT.md +++ /dev/null @@ -1,24 +0,0 @@ -# Docs development - -The `docs/` tree is the source for [docs.comma.ai](https://docs.comma.ai). -The site is updated on pushes to master by this [workflow](../.github/workflows/docs.yaml). - -Those commands must be run in the root directory of openpilot, **not /docs** - -**1. Install the docs dependencies** -``` bash -uv pip install .[docs] -``` - -**2. Build the new site** -``` bash -docs build -``` - -**3. Run the new site locally** -``` bash -docs serve -``` - -References: -* https://zensical.org/docs/ diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 8c112659c2..5f9b7517bd 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -52,7 +52,7 @@ Many factors can impact the performance of openpilot DM, causing it to be unable * Low light conditions, such as driving at night or in dark tunnels. * Bright light (due to oncoming headlights, direct sunlight, etc.). -* The driver's face is partially or completely outside field of view of the driver facing camera. -* The driver facing camera is obstructed, covered, or damaged. +* The driver's face is partially or completely outside field of view of the cabin camera. +* The cabin camera is obstructed, covered, or damaged. The list above does not represent an exhaustive list of situations that may interfere with proper operation of openpilot components. A driver should not rely on openpilot DM to assess their level of attention. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000..d6a0126b39 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,14 @@ +# Docs development + +The `docs/` tree is the source for [docs.comma.ai](https://docs.comma.ai). +The site is updated on pushes to master by this [workflow](../.github/workflows/docs.yaml). + +**1. Build the site** +``` bash +python docs/serve.py --build +``` + +**2. Run the site locally** (rebuilds on change) +``` bash +python docs/serve.py +``` diff --git a/docs/assets/comma-logo.png b/docs/assets/comma-logo.png index 2838d92bfb..19b67d0739 120000 --- a/docs/assets/comma-logo.png +++ b/docs/assets/comma-logo.png @@ -1 +1 @@ -../../selfdrive/assets/icons_mici/settings/comma_icon.png \ No newline at end of file +../../openpilot/selfdrive/assets/icons_mici/settings/comma_icon.png \ No newline at end of file diff --git a/docs/assets/favicon.svg b/docs/assets/favicon.svg new file mode 100644 index 0000000000..304454837a --- /dev/null +++ b/docs/assets/favicon.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e019ed5af500e8820d05934d47e0380728e1b30e01a179f786dc4edb1eccbd7 +size 349 diff --git a/docs/concepts/glossary.md b/docs/concepts/glossary.md deleted file mode 100644 index 4f4dd54756..0000000000 --- a/docs/concepts/glossary.md +++ /dev/null @@ -1,3 +0,0 @@ -# openpilot glossary - -{{GLOSSARY_DEFINITIONS}} diff --git a/docs/concepts/logs.md b/docs/concepts/logs.md index dd954d4c76..a1e655935d 100644 --- a/docs/concepts/logs.md +++ b/docs/concepts/logs.md @@ -10,13 +10,13 @@ For each segment, openpilot records the following log types: rlogs contain all the messages passed amongst openpilot's processes. See [openpilot/cereal/services.py](https://github.com/commaai/openpilot/blob/master/openpilot/cereal/services.py) for a list of all the logged services. They're a zstd archive of the serialized [Cap’n Proto](https://capnproto.org/) messages. -## {f,e,d}camera.hevc +## camera video files Each camera stream is H.265 encoded and written to its respective file. -* `fcamera.hevc` is the road camera +* `fcamera.hevc` is the narrow road camera (the main forward camera) * `ecamera.hevc` is the wide road camera -* `dcamera.hevc` is the driver camera +* `dcamera.hevc` is the cabin camera ## qlog.zst & qcamera.ts diff --git a/docs/contributing/feedback.md b/docs/contributing/feedback.md index 335d24e13a..587816d140 100644 --- a/docs/contributing/feedback.md +++ b/docs/contributing/feedback.md @@ -21,14 +21,14 @@ In general, driver monitoring feedback is very actionable, and we can fix your c To post your feedback: 1. Join the [community Discord](https://discord.comma.ai). -2. If driver camera recording is toggled off, temporarily enable driver camera recording in the settings until you reproduce the issue. -3. Using comma connect, identify the relevant segment and upload the segment's logs and driver camera. +2. If cabin camera recording is toggled off, temporarily enable cabin camera recording in the settings until you reproduce the issue. +3. Using comma connect, identify the relevant segment and upload the segment's logs and cabin camera. 4. Post the segment in the `#openpilot-experience` channel on Discord with a good description. Before posting feedback, please ensure: - **openpilot is up to date** you should be on the latest openpilot release or nightly -- **the driver camera has a clear view of the driver** ensure nothing blocks view of the driver (e.g. a cable), the lens is clean, etc. +- **the cabin camera has a clear view of the driver** ensure nothing blocks view of the driver (e.g. a cable), the lens is clean, etc. - **your device is mounted properly** your device must be mounted horizontally center and relatively high on the windshield ## Other bugs diff --git a/docs/ext/glossary.py b/docs/ext/glossary.py deleted file mode 100644 index 9bbf3c78d7..0000000000 --- a/docs/ext/glossary.py +++ /dev/null @@ -1,216 +0,0 @@ -import posixpath -import re -import tomllib -import xml.etree.ElementTree as ET -from pathlib import Path - -from markdown.extensions import Extension -from markdown.preprocessors import Preprocessor -from markdown.treeprocessors import Treeprocessor - -from zensical.extensions.links import LinksTreeprocessor - -GlossaryTerm = tuple[str, re.Pattern[str], str] - -GLOSSARY_FILE = Path(__file__).with_name("glossary.toml") -GLOSSARY_PAGE = "concepts/glossary.md" -GLOSSARY_PLACEHOLDER = "{{GLOSSARY_DEFINITIONS}}" - -SKIP_TAGS = { - "a", - "code", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "kbd", - "pre", - "script", - "style", -} - -def clean_tooltip(description: str) -> str: - text = re.sub(r"\[([^\]]+)]\([^)]+\)", r"\1", description) - text = re.sub(r"`([^`]+)`", r"\1", text) - text = re.sub(r"[*_~]", "", text) - return re.sub(r"\s+", " ", text).strip() - - -def load_glossary() -> tuple[list[GlossaryTerm], str]: - with GLOSSARY_FILE.open("rb") as f: - glossary_data = tomllib.load(f).get("glossary", {}) - - glossary: list[GlossaryTerm] = [] - rendered = [] - for key, value in glossary_data.items(): - label = str(key).strip().replace("_", " ") - description = str(value).strip() - if not description: - continue - - slug = label.replace(" ", "-").replace("_", "-").lower() - glossary.append((slug, re.compile(rf"(?**{label}**: {description}') - - return glossary, "\n".join(rendered) - - -class GlossaryPreprocessor(Preprocessor): - def __init__(self, md, glossary: str): - super().__init__(md) - self.glossary = glossary - - def run(self, lines: list[str]) -> list[str]: - markdown = "\n".join(lines) - if GLOSSARY_PLACEHOLDER not in markdown: - return lines - return markdown.replace(GLOSSARY_PLACEHOLDER, self.glossary).splitlines() - - -class GlossaryTreeprocessor(Treeprocessor): - def __init__(self, md, glossary: list[GlossaryTerm]): - super().__init__(md) - self.glossary = glossary - self.seen: set[str] = set() - - def run(self, root: ET.Element) -> None: - at = self.md.treeprocessors.get_index_for_name("zrelpath") - processor = self.md.treeprocessors[at] - if not isinstance(processor, LinksTreeprocessor): - raise TypeError("Links processor not registered") - if processor.path == GLOSSARY_PAGE: - return - - self.seen.clear() - glossary_href = f"{posixpath.relpath(GLOSSARY_PAGE, posixpath.dirname(processor.path) or '.')}#" - self._walk(root, glossary_href) - - def _walk(self, element: ET.Element, glossary_href: str) -> None: - if element.tag in SKIP_TAGS or element.attrib.get("data-glossary-skip") is not None: - return - - self._replace(element, glossary_href) - - idx = 0 - while idx < len(element): - child = element[idx] - self._walk(child, glossary_href) - idx = self._replace(element, glossary_href, idx) + 1 - - def _replace(self, parent: ET.Element, glossary_href: str, index: int | None = None) -> int: - child = None if index is None else parent[index] - text = parent.text if child is None else child.tail - pieces = self._pieces(text or "", glossary_href) - if not pieces: - return -1 if index is None else index - - if child is None: - parent.text = pieces[0] if isinstance(pieces[0], str) else "" - # Insert replacements for parent.text before the first existing child. - insert_at = -1 - else: - assert index is not None - child.tail = pieces[0] if isinstance(pieces[0], str) else "" - insert_at = index - - start = 1 if isinstance(pieces[0], str) else 0 - previous = child - - for piece in pieces[start:]: - if isinstance(piece, str): - previous.tail = (previous.tail or "") + piece - continue - - insert_at += 1 - parent.insert(insert_at, piece) - previous = piece - - return insert_at - - def _pieces(self, text: str, glossary_href: str) -> list[str | ET.Element]: - if not text.strip(): - return [] - - pieces: list[str | ET.Element] = [] - cursor = 0 - - while True: - best = None - for slug, pattern, tooltip in self.glossary: - if slug in self.seen: - continue - - found = pattern.search(text, cursor) - if found is None: - continue - - candidate = (slug, tooltip, found.start(), found.end()) - if best is None: - best = candidate - continue - - _, _, best_start, best_end = best - _, _, current_start, current_end = candidate - if current_start < best_start: - best = candidate - continue - - if current_start == best_start and current_end - current_start > best_end - best_start: - best = candidate - - if best is None: - break - - slug, tooltip, start, end = best - if start > cursor: - pieces.append(text[cursor:start]) - - link = ET.Element( - "a", - { - "class": "glossary-term", - "data-glossary-term": "", - "href": f"{glossary_href}{slug}", - }, - ) - ET.SubElement(link, "span", {"class": "glossary-term__label"}).text = text[start:end] - ET.SubElement( - link, - "span", - { - "class": "glossary-term__tooltip", - "data-search-exclude": "", - }, - ).text = tooltip - pieces.append(link) - self.seen.add(slug) - cursor = end - - if not pieces: - return [] - if cursor < len(text): - pieces.append(text[cursor:]) - return pieces - - -class GlossaryExtension(Extension): - def extendMarkdown(self, md) -> None: - md.registerExtension(self) - glossary, rendered = load_glossary() - - md.preprocessors.register( - GlossaryPreprocessor(md, rendered), - "docs-ext-glossary-preprocessor", - 27, - ) - md.treeprocessors.register( - GlossaryTreeprocessor(md, glossary), - "docs-ext-glossary-treeprocessor", - 0, - ) - - -def makeExtension(**kwargs) -> GlossaryExtension: - return GlossaryExtension(**kwargs) diff --git a/docs/ext/glossary.toml b/docs/ext/glossary.toml deleted file mode 100644 index 62408d9ddd..0000000000 --- a/docs/ext/glossary.toml +++ /dev/null @@ -1,8 +0,0 @@ -[glossary] -onroad = "openpilot's system state while ignition is on." -offroad = "openpilot's system state while ignition is off." -route = "A route is a recording of an onroad session." -segment = "Routes are split into one minute chunks called segments." -"comma connect" = "The web viewer for all your routes; check it out at [connect.comma.ai](https://connect.comma.ai)." -panda = "The secondary processor on the device that implements the functional safety and directly talks to the car over CAN. See the [panda repo](https://github.com/commaai/panda)." -"comma four" = "The latest hardware by comma.ai for running openpilot. More info at [comma.ai/shop/comma-four](https://www.comma.ai/shop/comma-four)." diff --git a/docs/serve.py b/docs/serve.py new file mode 100644 index 0000000000..5cfff33dec --- /dev/null +++ b/docs/serve.py @@ -0,0 +1,555 @@ +import argparse +import functools +import html +import http.server +import json +import posixpath +import re +import shutil +import threading +import time +import urllib.parse +from pathlib import Path + +DOCS_DIR = Path(__file__).resolve().parent +SITE_DIR = DOCS_DIR / "_site" +TEMPLATE_FILE = DOCS_DIR / "template.html" +EXCLUDE_DIRS = {"_site", "__pycache__"} + +REPO_URL = "https://github.com/commaai/openpilot/" + +# (title, target) pairs. target is a page path or an absolute URL. +# A None target marks a section header. +NAV: list[tuple[str, str | None]] = [ + ("What is openpilot?", "index.md"), + ("How-to", None), + ("Turn the speed blue", "how-to/turn-the-speed-blue.md"), + ("Connect to a comma 3X or four", "how-to/connect-to-comma.md"), + ("Add support for a car", "how-to/car-port.md"), + ("Concepts", None), + ("Logs", "concepts/logs.md"), + ("Safety", "concepts/safety.md"), + ("Glossary", "concepts/glossary.md"), + ("Contributing", None), + ("Feedback", "contributing/feedback.md"), + ("Roadmap", "contributing/roadmap.md"), + ("Contributing Guide →", "https://github.com/commaai/openpilot/blob/master/docs/CONTRIBUTING.md"), + ("Links", None), + ("Blog →", "https://blog.comma.ai"), + ("Bounties →", "https://comma.ai/bounties"), + ("GitHub →", "https://github.com/commaai"), + ("Discord →", "https://discord.comma.ai"), + ("X →", "https://x.com/comma_ai"), +] + +GLOSSARY_DESCRIPTIONS = { + "onroad": "openpilot's system state while ignition is on.", + "offroad": "openpilot's system state while ignition is off.", + "route": "A route is a recording of an onroad session.", + "segment": "Routes are split into one minute chunks called segments.", + "comma connect": "The web viewer for all your routes; check it out at [connect.comma.ai](https://connect.comma.ai).", + "panda": "The secondary processor on the device that implements the functional safety and directly talks to the car over CAN. See the [panda repo](https://github.com/commaai/panda).", + "comma four": "The latest hardware by comma.ai for running openpilot. More info at [comma.ai/shop/comma-four](https://www.comma.ai/shop/comma-four).", +} +GLOSSARY_PAGE = "concepts/glossary.md" +GLOSSARY_ROUTE = GLOSSARY_PAGE.removesuffix(".md") +GLOSSARY_SKIP = frozenset("a code h1 h2 h3 h4 h5 h6 kbd pre script style".split()) + +_ENTITY = re.compile(r"&(?:#x?[0-9a-fA-F]+|[a-zA-Z]+);") +_LIST = re.compile(r"^(\s*)([*+-]|\d+\.)\s+(.*)$") +_HEADING = re.compile(r"^(#{1,6})\s+(.*)$") +_HR = re.compile(r"^(-{3,}|\*{3,}|_{3,})$") +_ATTR_URL = re.compile(r"""(?P
\b(?:href|src)=(?P["']))(?P.*?)(?P=q)""")
+_VOID = frozenset("br img hr meta link input".split())
+_URL = re.compile(r"https?://[^\s<>\[\]\"']+")
+_ADMONITION = re.compile(r"^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$", re.I)
+
+
+def page_route(path: str) -> str:
+  path = path.removesuffix(".md")
+  return posixpath.dirname(path) or "." if posixpath.basename(path) == "index" else path
+
+
+def page_href(current: str, target: str) -> str:
+  route = posixpath.relpath(page_route(target), page_route(current))
+  return ("." if route == "." else route) + "/"
+
+
+def rewrite_relative_url(value: str, page: str) -> str | None:
+  url = urllib.parse.urlparse(value)
+  if value.startswith(("#", "/")) or url.scheme or url.netloc or not url.path:
+    return None
+  target = posixpath.normpath(posixpath.join(posixpath.dirname(page), url.path))
+  if target == ".." or target.startswith("../"):
+    return None
+  path = page_href(page, target) if target.endswith(".md") else posixpath.relpath(target, page_route(page))
+  return url._replace(path=path).geturl()
+
+
+def rewrite_html_urls(fragment: str, page: str) -> str:
+  def repl(m: re.Match[str]) -> str:
+    r = rewrite_relative_url(m.group("url"), page)
+    return m.group(0) if r is None else f'{m.group("pre")}{r}{m.group("q")}'
+
+  return _ATTR_URL.sub(repl, fragment)
+
+
+def esc(text: str, attr: bool = False) -> str:
+  held: list[str] = []
+
+  def hold(m: re.Match[str]) -> str:
+    held.append(m.group(0))
+    return f"\0{len(held) - 1}\0"
+
+  return re.sub(r"\0(\d+)\0", lambda m: held[int(m.group(1))], html.escape(_ENTITY.sub(hold, text), quote=attr))
+
+
+def clean_tooltip(description: str) -> str:
+  text = re.sub(r"\[([^\]]+)]\([^)]+\)", r"\1", description)
+  return re.sub(r"\s+", " ", re.sub(r"[*_~]", "", re.sub(r"`([^`]+)`", r"\1", text))).strip()
+
+
+def glossary_slug(label: str) -> str:
+  return label.replace(" ", "-").replace("_", "-").lower()
+
+
+GLOSSARY_TERMS = [(glossary_slug(l), re.compile(rf"(?**{l}**: {d}' for l, d in GLOSSARY_DESCRIPTIONS.items())
+
+
+def inject_glossary(body: str, page: str) -> str:
+  if page == GLOSSARY_PAGE:
+    return body
+  route = "." if page == "index.md" else page.removesuffix(".md")
+  base, seen, out, skip, depth = f"{posixpath.relpath(GLOSSARY_ROUTE, route)}/#", set(), [], None, 0
+  for part in re.split(r"(<[^>]+>)", body):
+    if not part:
+      continue
+    if part.startswith("<"):
+      out.append(part)
+      if part.startswith("") or tag in _VOID
+      if closing and skip == tag and depth:
+        depth -= 1
+        skip = None if not depth else skip
+      elif not closing and not void:
+        skip, depth = (tag, 1) if skip is None else (skip, depth + (skip == tag))
+      continue
+    if depth:
+      out.append(part)
+      continue
+    cur, text = 0, part
+    while True:
+      best = None
+      for order, (slug, pat, tip) in enumerate(GLOSSARY_TERMS):
+        if slug in seen or (found := pat.search(text, cur)) is None:
+          continue
+        cand = (found.start(), found.start() - found.end(), order, slug, tip, found.end(), found.group(0))
+        if best is None or cand[:3] < best[:3]:
+          best = cand
+      if best is None:
+        out.append(text[cur:])
+        break
+      start, _, _, slug, tip, end, matched = best
+      out.append(text[cur:start])
+      out.append(
+        f''
+        + f'{matched}'
+        + f'{esc(tip)}'
+      )
+      seen.add(slug)
+      cur = end
+  return "".join(out)
+
+
+def slugify(text: str) -> str:
+  text = html.unescape(re.sub(r"<[^>]+>", "", text)).lower()
+  return re.sub(r"[-\s]+", "-", re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE)).strip("-")
+
+
+def _parse_link(text: str, start: int) -> tuple[str, str, int] | None:
+  if start >= len(text) or text[start] != "[":
+    return None
+  depth, i = 0, start
+  while i < len(text):
+    depth += (text[i] == "[") - (text[i] == "]")
+    if text[i] == "]" and depth == 0:
+      label = text[start + 1 : i]
+      if i + 1 >= len(text) or text[i + 1] != "(":
+        return None
+      j, dp = i + 2, 1
+      while j < len(text) and dp:
+        dp += (text[j] == "(") - (text[j] == ")")
+        j += 1
+      return None if dp else (label, text[i + 2 : j - 1], j)
+    i += 1
+  return None
+
+
+def autolink_plain(text: str) -> str:
+  parts: list[str] = []
+  last = 0
+  for m in _URL.finditer(text):
+    start = m.start()
+    if start > 0 and text[start - 1].isalnum():
+      continue
+    parts.append(esc(text[last:start]))
+    url = m.group(0).rstrip(".,;:!?)]")
+    parts.append(f'{esc(url)}')
+    last = start + len(url)
+  parts.append(esc(text[last:]))
+  return "".join(parts)
+
+
+def render_inline(text: str, page: str) -> str:
+  out, i, n = [], 0, len(text)
+  while i < n:
+    if text[i] == "\n" and i >= 2 and text[i - 2 : i] == "  " and out and out[-1].endswith("  "):
+      out[-1] = out[-1][:-2]
+      out.append("
\n") + i += 1 + continue + if text[i] == "`" and (end := text.find("`", i + 1)) != -1: + out.append(f"{esc(text[i + 1 : end])}") + i = end + 1 + continue + if text[i] == "!" and i + 1 < n and text[i + 1] == "[" and (p := _parse_link(text, i + 1)): + label, url, end = p + src = rewrite_relative_url(url, page) or url + out.append(f'{esc(label, True)}') + i = end + continue + if text[i] == "[" and (p := _parse_link(text, i)): + label, url, end = p + href = rewrite_relative_url(url, page) or url + out.append(f'{render_inline(label, page)}') + i = end + continue + if text[i] == "<": + if text.startswith("", i + 4) + end = n if end < 0 else end + 3 + out.append(rewrite_html_urls(text[i:end], page)) + i = end + continue + if m := re.match(r"<[^>]+>", text[i:]): + out.append(rewrite_html_urls(m.group(0), page)) + i += len(m.group(0)) + continue + if (text.startswith("**", i) or text.startswith("__", i)) and (end := text.find(text[i : i + 2], i + 2)) != -1: + out.append(f"{render_inline(text[i + 2 : end], page)}") + i = end + 2 + continue + if text[i] in "*_" and i + 1 < n and text[i + 1] not in " \t\n" and (end := text.find(text[i], i + 1)) > i + 1: + out.append(f"{render_inline(text[i + 1 : end], page)}") + i = end + 1 + continue + j = i + 1 + while j < n and text[j] not in "`[ list[str]: + return [c.strip() for c in line.strip().removeprefix("|").removesuffix("|").split("|")] + + +def _is_sep(line: str) -> bool: + return "|" in line and all(re.fullmatch(r":?-{3,}:?", c.strip()) for c in _trow(line)) + + +def _align(sep: str) -> str: + s = sep.strip() + if s.startswith(":") and s.endswith(":"): + return ' style="text-align: center;"' + if s.endswith(":"): + return ' style="text-align: right;"' + if s.startswith(":"): + return ' style="text-align: left;"' + return "" + + +def _list_info(line: str) -> tuple[int, str, str] | None: + m = _LIST.match(line) + return None if not m else (len(m.group(1)) // 4, "ol" if m.group(2)[-1] == "." else "ul", m.group(3)) + + +def _render_blocks(text: str, page: str) -> str: + lines, out, i, n = text.splitlines(), [], 0, 0 + n = len(lines) + while i < n: + line, s = lines[i], lines[i].strip() + if not s: + i += 1 + continue + + if s.startswith("```"): + lang, body = s[3:].strip(), [] + i += 1 + while i < n and not lines[i].strip().startswith("```"): + body.append(lines[i]) + i += 1 + if i < n: + i += 1 + code = html.escape("\n".join(body) + ("\n" if body else "")) + cls = f' class="language-{html.escape(lang)}"' if lang else "" + out.append(f"
{code}
") + continue + + if m := _HEADING.match(s): + content, level = m.group(2).rstrip("#").strip(), len(m.group(1)) + sid = slugify(content) + out.append(f'{render_inline(content, page)}#') + i += 1 + continue + + if _HR.fullmatch(s): + out.append("
") + i += 1 + continue + + if "|" in line and i + 1 < n and _is_sep(lines[i + 1]): + headers, aligns = _trow(line), [_align(c) for c in _trow(lines[i + 1])] + i += 2 + rows = [] + while i < n and "|" in lines[i] and lines[i].strip(): + rows.append(_trow(lines[i])) + i += 1 + parts = ( + ["", "", ""] + + [f"{render_inline(h, page)}" for j, h in enumerate(headers)] + + ["", "", ""] + ) + for row in rows: + parts.append("") + for j in range(len(headers)): + parts.append(f"{render_inline(row[j] if j < len(row) else '', page)}") + parts.append("") + out.append("\n".join(parts + ["", "
"])) + continue + + if _list_info(line): + items: list[tuple[int, str, list[str]]] = [] + while i < n: + if not lines[i].strip(): + if i + 1 < n and _list_info(lines[i + 1]): + i += 1 + continue + break + info = _list_info(lines[i]) + if not info: + break + level, kind, body = info + chunk = [body] + i += 1 + while i < n and lines[i].strip() and _list_info(lines[i]) is None: + t = lines[i].strip() + if t.startswith(("```", ">")) or _HEADING.match(t) or _HR.fullmatch(t): + break + chunk.append(lines[i]) + i += 1 + items.append((level, kind, chunk)) + + def render_list(items: list[tuple[int, str, list[str]]], start: int, min_level: int) -> tuple[str, int]: + if start >= len(items) or items[start][0] < min_level: + return "", start + kind, chunks, idx = items[start][1], [f"<{items[start][1]}>"], start + while idx < len(items) and items[idx][0] >= min_level: + level, ikind, body_lines = items[idx] + if level > min_level: + nested, idx = render_list(items, idx, level) + chunks[-1] = (chunks[-1][:-5] + nested + "") if chunks[-1].endswith("") else chunks[-1] + nested + continue + if ikind != kind: + chunks += [f"", f"<{ikind}>"] + kind = ikind + idx += 1 + body = render_inline("\n".join(body_lines), page) + nested = "" + if idx < len(items) and items[idx][0] > min_level: + nested, idx = render_list(items, idx, min_level + 1) + chunks.append(f"
  • {body}{nested}\n
  • " if nested else f"
  • {body}
  • ") + chunks.append(f"") + return "\n".join(chunks), idx + + out.append(render_list(items, 0, items[0][0])[0]) + continue + + if s.startswith(">"): + q = [] + while i < n and lines[i].strip().startswith(">"): + q.append(re.sub(r"^>\s?", "", lines[i].strip())) + i += 1 + m = _ADMONITION.match(q[0].strip()) if q else None + if m: + kind = m.group(1).lower() + title = m.group(1).capitalize() + body = _render_blocks("\n".join(q[1:]), page) + out.append(f'
    \n

    {title}

    \n{body}\n
    ') + else: + out.append(f"
    \n

    {render_inline(chr(10).join(q), page)}

    \n
    ") + continue + + if s.startswith(" # Supported Cars A supported vehicle is one that just works when you install a comma device. All supported cars provide a better experience than any stock system. Supported vehicles reference the US market unless otherwise specified. -# {{all_car_docs | selectattr('support_type', 'eq', SupportType.UPSTREAM) | list | length}} Supported Cars - -|{{Column | map(attribute='value') | join('|') | replace(hardware_col_name, wide_hardware_col_name)}}| -|---|---|---|{% for _ in range((Column | length) - 3) %}{{':---:|'}}{% endfor +%} -{% for car_docs in all_car_docs | selectattr('support_type', 'eq', SupportType.UPSTREAM) %} -|{% for column in Column %}{{car_docs.get_column(column, star_icon, video_icon, footnote_tag)}}|{% endfor %} - -{% endfor %} +# $supported_count Supported Cars +$table_header +$table_separator +$table_rows ### Footnotes -{% for footnote in footnotes %} -{{loop.index}}{{footnote | replace('
    ', '')}}
    -{% endfor %} - +$footnotes ## Community Maintained Cars Although they're not upstream, the community has openpilot running on other makes and models. See the 'Community Supported Models' section of each make [on our wiki](https://wiki.comma.ai/). @@ -71,4 +56,3 @@ openpilot does not yet support these Toyota models due to a new message authenti * Lexus NX 2022+ * Toyota bZ4x 2023+ * Subaru Solterra 2023+ - diff --git a/openpilot/selfdrive/car/car_specific.py b/openpilot/selfdrive/car/car_events.py similarity index 97% rename from openpilot/selfdrive/car/car_specific.py rename to openpilot/selfdrive/car/car_events.py index 244a8e3b07..21478e4d55 100644 --- a/openpilot/selfdrive/car/car_specific.py +++ b/openpilot/selfdrive/car/car_events.py @@ -13,7 +13,7 @@ EventName = log.OnroadEvent.EventName NetworkLocation = structs.CarParams.NetworkLocation -class CarSpecificEvents: +class CarEvents: def __init__(self, CP: structs.CarParams): self.CP = CP @@ -56,6 +56,9 @@ class CarSpecificEvents: if self.CP.minEnableSpeed > 0 and CS.vEgo < 0.001: events.add(EventName.manualRestart) + if CS.brakeHoldActive and CS.blockPcmEnable: # set by Nidec Hybrid which cannot resume from brakehold + events.add(EventName.belowEngageSpeed) + elif self.CP.brand == 'toyota': # TODO: when we check for unexpected disengagement, check gear not S1, S2, S3 if self.CP.openpilotLongitudinalControl: @@ -133,6 +136,8 @@ class CarSpecificEvents: events.add(EventName.parkBrake) if CS.accFaulted: events.add(EventName.accFaulted) + if CS.carNotReady: + events.add(EventName.carNotReady) if CS.steeringPressed: events.add(EventName.steerOverride) if CS.steeringDisengage and not CS_prev.steeringDisengage: diff --git a/openpilot/selfdrive/car/card.py b/openpilot/selfdrive/car/card.py index d9b7b4bb5a..e253788e5e 100755 --- a/openpilot/selfdrive/car/card.py +++ b/openpilot/selfdrive/car/card.py @@ -72,7 +72,7 @@ class Car: def __init__(self, CI=None, RI=None) -> None: self.can_sock = messaging.sub_sock('can', timeout=20) self.sm = messaging.SubMaster(['pandaStates', 'carControl', 'onroadEvents'] + ['carControlSP', 'longitudinalPlanSP']) - self.pm = messaging.PubMaster(['sendcan', 'carState', 'carParams', 'carOutput', 'liveTracks'] + ['carParamsSP', 'carStateSP']) + self.pm = messaging.PubMaster(['sendcan', 'carState', 'carParams', 'carOutput', 'radarTracks'] + ['carParamsSP', 'carStateSP']) self.can_rcv_cum_timeout_counter = 0 @@ -250,10 +250,10 @@ class Car: self.pm.send('carState', cs_send) if RD is not None: - tracks_msg = messaging.new_message('liveTracks') + tracks_msg = messaging.new_message('radarTracks') tracks_msg.valid = not any(RD.errors.to_dict().values()) - tracks_msg.liveTracks = RD - self.pm.send('liveTracks', tracks_msg) + tracks_msg.radarTracks = RD + self.pm.send('radarTracks', tracks_msg) # carParamsSP - logged every 50 seconds (> 1 per segment) if self.sm.frame % int(50. / DT_CTRL) == 0: diff --git a/openpilot/selfdrive/car/docs.py b/openpilot/selfdrive/car/docs.py index ea7a70688e..8d135d82ea 100755 --- a/openpilot/selfdrive/car/docs.py +++ b/openpilot/selfdrive/car/docs.py @@ -1,13 +1,68 @@ #!/usr/bin/env python3 import argparse import os +from string import Template from openpilot.common.basedir import BASEDIR -from opendbc.car.docs import get_all_car_docs, generate_cars_md +from opendbc.car.docs import get_all_car_docs, get_all_footnotes +from opendbc.car.docs_definitions import Column, SupportType CARS_MD_OUT = os.path.join(BASEDIR, "docs", "CARS.md") CARS_MD_TEMPLATE = os.path.join(BASEDIR, "openpilot/selfdrive", "car", "CARS_template.md") +FOOTNOTE_TAG = '[{}](#footnotes)' +STAR_ICON = '[![star](assets/icon-star-{}.svg)](##)' +VIDEO_ICON = '' +# Force hardware column wider by using a blank image with max width. +HARDWARE_COL_NAME = 'Hardware Needed' +WIDE_HARDWARE_COL_NAME = f'{HARDWARE_COL_NAME}
     ' + + +def _build_cars_table(upstream_cars) -> tuple[str, str, str]: + columns = list(Column) + header_cells = [ + WIDE_HARDWARE_COL_NAME if col.value == HARDWARE_COL_NAME else col.value + for col in columns + ] + table_header = "|" + "|".join(header_cells) + "|" + + # First three columns left-aligned (---), remaining centered (:---:) + sep_parts = ["---"] * min(3, len(columns)) + [":---:"] * max(0, len(columns) - 3) + table_separator = "|" + "|".join(sep_parts) + "|" + + rows = [] + for car_docs in upstream_cars: + cells = [car_docs.get_column(column, STAR_ICON, VIDEO_ICON, FOOTNOTE_TAG) for column in columns] + rows.append("|" + "|".join(cells) + "|") + table_rows = "\n".join(rows) + ("\n" if rows else "") + + return table_header, table_separator, table_rows + + +def generate_cars_md(all_car_docs, template_fn: str, **kwargs) -> str: + del kwargs # kept for call-site compatibility + + upstream_cars = [c for c in all_car_docs if c.support_type == SupportType.UPSTREAM] + table_header, table_separator, table_rows = _build_cars_table(upstream_cars) + + footnotes = [fn.value.text.replace('
    ', '') for fn in get_all_footnotes()] + footnotes_md = "\n".join( + f"{i}{text}
    " + for i, text in enumerate(footnotes, start=1) + ) + ("\n" if footnotes else "") + + with open(template_fn) as f: + template = Template(f.read()) + + return template.substitute( + supported_count=len(upstream_cars), + table_header=table_header, + table_separator=table_separator, + table_rows=table_rows, + footnotes=footnotes_md, + ) + + if __name__ == "__main__": parser = argparse.ArgumentParser(description="Auto generates supported cars documentation", formatter_class=argparse.ArgumentDefaultsHelpFormatter) diff --git a/openpilot/selfdrive/car/tests/big_cars_test.sh b/openpilot/selfdrive/car/tests/big_cars_test.sh deleted file mode 100755 index 456fc698c5..0000000000 --- a/openpilot/selfdrive/car/tests/big_cars_test.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -SCRIPT_DIR=$(dirname "$0") -BASEDIR=$(realpath "$SCRIPT_DIR/../../../../") -cd $BASEDIR - -export MAX_EXAMPLES=300 -export INTERNAL_SEG_CNT=300 -export INTERNAL_SEG_LIST=openpilot/selfdrive/car/tests/test_models_segs.txt - -cd openpilot/selfdrive/car/tests && pytest test_models.py test_car_interfaces.py diff --git a/openpilot/selfdrive/car/tests/test_car_interfaces.py b/openpilot/selfdrive/car/tests/test_car_interfaces.py index 3c4587cbc6..bf3e8d8ff6 100644 --- a/openpilot/selfdrive/car/tests/test_car_interfaces.py +++ b/openpilot/selfdrive/car/tests/test_car_interfaces.py @@ -1,13 +1,16 @@ import os -import hypothesis.strategies as st -from hypothesis import Phase, given, settings + +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized +from openpilot.common.fuzzy import capnp_random_dict, fuzzy_test from openpilot.cereal import custom from opendbc.car.structs import car from opendbc.car import DT_CTRL +from opendbc.car.car_helpers import interfaces +from opendbc.car.fingerprints import FW_VERSIONS +from opendbc.car.fw_versions import FW_QUERY_CONFIGS from opendbc.car.structs import CarParams -from opendbc.car.tests.test_car_interfaces import get_fuzzy_car_interface from opendbc.car.mock.values import CAR as MOCK from opendbc.car.values import PLATFORMS from openpilot.selfdrive.car.helpers import convert_carControlSP @@ -15,28 +18,41 @@ from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.selfdrive.controls.lib.longcontrol import LongControl -from openpilot.selfdrive.test.fuzzy_generation import FuzzyGenerator from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces MAX_EXAMPLES = int(os.environ.get('MAX_EXAMPLES', '60')) +ALL_ECUS = tuple(sorted({ecu for ecus in FW_VERSIONS.values() for ecu in ecus} | + {ecu for config in FW_QUERY_CONFIGS.values() for ecu in config.extra_ecus})) +ALL_REQUESTS = tuple(sorted({tuple(request.request) for config in FW_QUERY_CONFIGS.values() for request in config.requests})) +DLC_TO_LEN = (0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64) -class TestCarInterfaces: - # FIXME: Due to the lists used in carParams, Phase.target is very slow and will cause - # many generated examples to overrun when max_examples > ~20, don't use it - @parameterized.expand([(car,) for car in sorted(PLATFORMS)] + [MOCK.MOCK]) - @settings(max_examples=MAX_EXAMPLES, deadline=None, - phases=(Phase.reuse, Phase.generate, Phase.shrink)) - @given(data=st.data()) - def test_car_interfaces(self, car_name, data): - car_interface = get_fuzzy_car_interface(car_name, data.draw) +class TestCarInterfaces(OpenpilotTestCase): + @parameterized.expand([(car,) for car in sorted(PLATFORMS)] + [MOCK.MOCK], ids=lambda car_name: car_name) + @fuzzy_test(max_examples=60) + def test_car_interfaces(self, car_name, fuzzy): + fingerprint = dict(fuzzy.list(lambda: (fuzzy.integer(0, 0x800), fuzzy.choice(DLC_TO_LEN)))) + fingerprints = dict.fromkeys(range(7), fingerprint) + + def generate_car_fw(): + ecu, address, sub_address = fuzzy.choice(ALL_ECUS) + return CarParams.CarFw(ecu=ecu, address=address, subAddress=sub_address or 0, request=fuzzy.choice(ALL_REQUESTS)) + + CarInterface = interfaces[car_name] + car_fw = fuzzy.list(generate_car_fw) + alpha_long = fuzzy.boolean() + car_params = CarInterface.get_params(car_name, fingerprints, car_fw, + alpha_long=alpha_long, is_release=False, docs=False) + car_params_sp = CarInterface.get_params_sp(car_params, car_name, fingerprints, car_fw, + alpha_long=alpha_long, is_release_sp=False, docs=False) + car_interface = CarInterface(car_params, car_params_sp) car_params = car_interface.CP.as_reader() car_params_sp = car_interface.CP_SP sunnypilot_interfaces.setup_interfaces(car_interface) - cc_msg = FuzzyGenerator.get_random_msg(data.draw, car.CarControl, real_floats=True) - cc_sp_msg = FuzzyGenerator.get_random_msg(data.draw, custom.CarControlSP, real_floats=True) + cc_msg = capnp_random_dict(fuzzy, car.CarControl.schema, real_floats=True) + cc_sp_msg = capnp_random_dict(fuzzy, custom.CarControlSP.schema, real_floats=True) # Run car interface now_nanos = 0 CC = car.CarControl.new_message(**cc_msg) @@ -59,8 +75,6 @@ class TestCarInterfaces: now_nanos += DT_CTRL * 1e9 # 10ms # Test controller initialization - # TODO: wait until card refactor is merged to run controller a few times, - # hypothesis also slows down significantly with just one more message draw LongControl(car_params, car_params_sp) if car_params.steerControlType == CarParams.SteerControlType.angle: LatControlAngle(car_params, car_params_sp, car_interface, DT_CTRL) diff --git a/openpilot/selfdrive/car/tests/test_cruise_speed.py b/openpilot/selfdrive/car/tests/test_cruise_speed.py index c0b6ac979f..60af346838 100644 --- a/openpilot/selfdrive/car/tests/test_cruise_speed.py +++ b/openpilot/selfdrive/car/tests/test_cruise_speed.py @@ -1,7 +1,7 @@ -import pytest import itertools import numpy as np +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized_class from openpilot.cereal import log from openpilot.selfdrive.car.cruise import VCruiseHelper, V_CRUISE_MIN, V_CRUISE_MAX, V_CRUISE_INITIAL, IMPERIAL_INCREMENT @@ -36,18 +36,18 @@ def run_cruise_simulation(cruise, e2e, personality, t_end=20.): [True, False], # e2e log.LongitudinalPersonality.schema.enumerants, # personality [5,35])) # speed -class TestCruiseSpeed: +class TestCruiseSpeed(OpenpilotTestCase): def test_cruise_speed(self): print(f'Testing {self.speed} m/s') cruise_speed = float(self.speed) simulation_steady_state = run_cruise_simulation(cruise_speed, self.e2e, self.personality) - assert simulation_steady_state == pytest.approx(cruise_speed, abs=.01), f'Did not reach {self.speed} m/s' + self.assertAlmostEqual(simulation_steady_state, cruise_speed, delta=.01, msg=f'Did not reach {self.speed} m/s') # TODO: test pcmCruise and pcmCruiseSpeed @parameterized_class(('pcm_cruise', 'pcm_cruise_speed'), [(False, True)]) -class TestVCruiseHelper: +class TestVCruiseHelper(OpenpilotTestCase): def setup_method(self): self.CP = car.CarParams(pcmCruise=self.pcm_cruise) self.CP_SP = custom.CarParamsSP(pcmCruiseSpeed=self.pcm_cruise_speed) diff --git a/openpilot/selfdrive/car/tests/test_docs.py b/openpilot/selfdrive/car/tests/test_docs.py index 8ccbfb5a79..99438b4720 100644 --- a/openpilot/selfdrive/car/tests/test_docs.py +++ b/openpilot/selfdrive/car/tests/test_docs.py @@ -1,8 +1,9 @@ -from opendbc.car.docs import generate_cars_md, get_all_car_docs -from openpilot.selfdrive.car.docs import CARS_MD_TEMPLATE +from openpilot.common.test import OpenpilotTestCase +from openpilot.selfdrive.car.docs import CARS_MD_TEMPLATE, generate_cars_md +from opendbc.car.docs import get_all_car_docs -class TestCarDocs: +class TestCarDocs(OpenpilotTestCase): @classmethod def setup_class(cls): cls.all_cars = get_all_car_docs() diff --git a/openpilot/selfdrive/car/tests/test_models.py b/openpilot/selfdrive/car/tests/test_models.py deleted file mode 100644 index adf0509121..0000000000 --- a/openpilot/selfdrive/car/tests/test_models.py +++ /dev/null @@ -1,499 +0,0 @@ -import time -import copy -import os -import pytest -import random -import unittest # noqa: TID251 -from collections import defaultdict, Counter -import hypothesis.strategies as st -from hypothesis import Phase, given, settings -from openpilot.common.parameterized import parameterized_class -from opendbc.car import DT_CTRL, gen_empty_fingerprint, structs -from opendbc.car.can_definitions import CanData -from opendbc.car.car_helpers import FRAME_FINGERPRINT, interfaces -from opendbc.car.fingerprints import MIGRATION -from opendbc.car.honda.values import CAR as HONDA, HondaFlags -from opendbc.car.structs import car -from opendbc.car.tests.routes import non_tested_cars, routes, CarTestRoute -from opendbc.car.values import Platform, PLATFORMS -from opendbc.safety.tests.libsafety import libsafety_py -from openpilot.common.basedir import BASEDIR -from openpilot.selfdrive.pandad import can_capnp_to_list -from openpilot.selfdrive.test.helpers import read_segment_list -from openpilot.common.hardware.hw import DEFAULT_DOWNLOAD_CACHE_ROOT -from openpilot.tools.lib.logreader import LogReader, LogsUnavailable, openpilotci_source, internal_source, comma_api_source -from openpilot.tools.lib.route import SegmentName - -SafetyModel = car.CarParams.SafetyModel -SteerControlType = structs.CarParams.SteerControlType - -# panda safety stores angle_meas in brand-specific CAN units (angle_deg_to_can in opendbc/safety/modes/*.h). -ANGLE_DEG_TO_CAN = { - "tesla": -10, - "toyota": 17.452007, - "nissan": 100, - "psa": 10, -} - -NUM_JOBS = int(os.environ.get("NUM_JOBS", "1")) -JOB_ID = int(os.environ.get("JOB_ID", "0")) -INTERNAL_SEG_LIST = os.environ.get("INTERNAL_SEG_LIST", "") -INTERNAL_SEG_CNT = int(os.environ.get("INTERNAL_SEG_CNT", "0")) -MAX_EXAMPLES = int(os.environ.get("MAX_EXAMPLES", "300")) -CI = os.environ.get("CI", None) is not None - - -def get_test_cases() -> list[tuple[str, CarTestRoute | None]]: - # build list of test cases - test_cases = [] - if not len(INTERNAL_SEG_LIST): - routes_by_car = defaultdict(set) - for r in routes: - routes_by_car[str(r.car_model)].add(r) - - for i, c in enumerate(sorted(PLATFORMS)): - if i % NUM_JOBS == JOB_ID: - test_cases.extend(sorted((c, r) for r in routes_by_car.get(c, (None,)))) - - else: - segment_list = read_segment_list(os.path.join(BASEDIR, INTERNAL_SEG_LIST)) - segment_list = random.sample(segment_list, INTERNAL_SEG_CNT or len(segment_list)) - for platform, segment in segment_list: - platform = MIGRATION.get(platform, platform) - segment_name = SegmentName(segment) - test_cases.append((platform, CarTestRoute(segment_name.route_name.canonical_name, platform, - segment=segment_name.segment_num))) - return test_cases - - -@pytest.mark.slow -@pytest.mark.shared_download_cache -class TestCarModelBase(unittest.TestCase): - platform: Platform | None = None - test_route: CarTestRoute | None = None - - can_msgs: list[tuple[int, list[CanData]]] - fingerprint: dict[int, dict[int, int]] - elm_frame: int | None - car_safety_mode_frame: int | None - - @classmethod - def get_testing_data_from_logreader(cls, lr): - car_fw = [] - can_msgs = [] - cls.elm_frame = None - cls.car_safety_mode_frame = None - cls.fingerprint = gen_empty_fingerprint() - alpha_long = False - for msg in lr: - if msg.which() == "can": - can = can_capnp_to_list((msg.as_builder().to_bytes(),))[0] - can_msgs.append((can[0], [CanData(*can) for can in can[1]])) - if len(can_msgs) <= FRAME_FINGERPRINT: - for m in msg.can: - if m.src < 64: - cls.fingerprint[m.src][m.address] = len(m.dat) - - elif msg.which() == "carParams": - car_fw = msg.carParams.carFw - if msg.carParams.openpilotLongitudinalControl: - alpha_long = True - if cls.platform is None: - live_fingerprint = msg.carParams.carFingerprint - cls.platform = MIGRATION.get(live_fingerprint, live_fingerprint) - - # Log which can frame the panda safety mode left ELM327, for CAN validity checks - elif msg.which() == 'pandaStates': - for ps in msg.pandaStates: - if cls.elm_frame is None and ps.safetyModel != SafetyModel.elm327: - cls.elm_frame = len(can_msgs) - if cls.car_safety_mode_frame is None and ps.safetyModel not in \ - (SafetyModel.elm327, SafetyModel.noOutput): - cls.car_safety_mode_frame = len(can_msgs) - - elif msg.which() == 'pandaStateDEPRECATED': - if cls.elm_frame is None and msg.pandaStateDEPRECATED.safetyModel != SafetyModel.elm327: - cls.elm_frame = len(can_msgs) - if cls.car_safety_mode_frame is None and msg.pandaStateDEPRECATED.safetyModel not in \ - (SafetyModel.elm327, SafetyModel.noOutput): - cls.car_safety_mode_frame = len(can_msgs) - - assert len(can_msgs) > int(50 / DT_CTRL), "no can data found" - return car_fw, can_msgs, alpha_long - - @classmethod - def get_testing_data(cls): - test_segs = (2, 1, 0) - if cls.test_route.segment is not None: - test_segs = (cls.test_route.segment,) - - for seg in test_segs: - segment_range = f"{cls.test_route.route}/{seg}" - - try: - sources = [internal_source] if len(INTERNAL_SEG_LIST) else [openpilotci_source, comma_api_source] - lr = LogReader(segment_range, sources=sources, sort_by_time=True) - return cls.get_testing_data_from_logreader(lr) - except (LogsUnavailable, AssertionError): - pass - - raise Exception(f"Route: {repr(cls.test_route.route)} with segments: {test_segs} not found or no CAN msgs found. Is it uploaded and public?") - - - @classmethod - def setUpClass(cls): - if cls.__name__ == 'TestCarModel' or cls.__name__.endswith('Base'): - raise unittest.SkipTest - - if cls.test_route is None: - if cls.platform in non_tested_cars: - print(f"Skipping tests for {cls.platform}: missing route") - raise unittest.SkipTest - raise Exception(f"missing test route for {cls.platform}") - - car_fw, cls.can_msgs, alpha_long = cls.get_testing_data() - - # if relay is expected to be open in the route - cls.openpilot_enabled = cls.car_safety_mode_frame is not None - - cls.CarInterface = interfaces[cls.platform] - cls.CP = cls.CarInterface.get_params(cls.platform, cls.fingerprint, car_fw, alpha_long, False, docs=False) - cls.CP_SP = cls.CarInterface.get_params_sp(cls.CP, cls.platform, cls.fingerprint, car_fw, alpha_long, False, docs=False) - assert cls.CP - assert cls.CP_SP - assert cls.CP.carFingerprint == cls.platform - - os.environ["COMMA_CACHE"] = DEFAULT_DOWNLOAD_CACHE_ROOT - - @classmethod - def tearDownClass(cls): - del cls.can_msgs - - def setUp(self): - self.CI = self.CarInterface(self.CP.copy(), copy.deepcopy(self.CP_SP)) - assert self.CI - - # TODO: check safetyModel is in release panda build - self.safety = libsafety_py.libsafety - - safety_param_sp = self.CP_SP.safetyParam - self.safety.set_current_safety_param_sp(safety_param_sp) - - cfg = self.CP.safetyConfigs[-1] - set_status = self.safety.set_safety_hooks(cfg.safetyModel.raw, cfg.safetyParam) - self.assertEqual(0, set_status, f"failed to set safetyModel {cfg}") - self.safety.init_tests() - - def test_car_params(self): - if self.CP.dashcamOnly: - self.skipTest("no need to check carParams for dashcamOnly") - - # make sure car params are within a valid range - self.assertGreater(self.CP.mass, 1) - - if self.CP.steerControlType not in (SteerControlType.angle, SteerControlType.curvature): - tuning = self.CP.lateralTuning.which() - if tuning == 'pid': - self.assertTrue(len(self.CP.lateralTuning.pid.kpV)) - elif tuning == 'torque': - self.assertTrue(self.CP.lateralTuning.torque.latAccelFactor > 0) - else: - raise Exception("unknown tuning") - - def test_car_interface(self): - # TODO: also check for checksum violations from can parser - can_invalid_cnt = 0 - CC = structs.CarControl().as_reader() - CC_SP = structs.CarControlSP() - - for i, msg in enumerate(self.can_msgs): - CS, _ = self.CI.update(msg) - self.CI.apply(CC, CC_SP, msg[0]) - - # wait max of 2s for low frequency msgs to be seen - if i > 250: - can_invalid_cnt += not CS.canValid - - self.assertEqual(can_invalid_cnt, 0) - - def test_radar_interface(self): - RI = self.CarInterface.RadarInterface(self.CP, self.CP_SP) - assert RI - - # Since OBD port is multiplexed to bus 1 (commonly radar bus) while fingerprinting, - # start parsing CAN messages after we've left ELM mode and can expect CAN traffic - error_cnt = 0 - for i, msg in enumerate(self.can_msgs[self.elm_frame:]): - rr: structs.RadarData | None = RI.update(msg) - if rr is not None and i > 50: - error_cnt += rr.errors.canError - self.assertEqual(error_cnt, 0) - - def test_panda_safety_rx_checks(self): - if self.CP.dashcamOnly: - self.skipTest("no need to check panda safety for dashcamOnly") - - start_ts = self.can_msgs[0][0] - - failed_addrs = Counter() - for can in self.can_msgs: - # update panda timer - t = (can[0] - start_ts) / 1e3 - self.safety.set_timer(int(t)) - - # run all msgs through the safety RX hook - for msg in can[1]: - if msg.src >= 64: - continue - - to_send = libsafety_py.make_CANPacket(msg.address, msg.src % 4, msg.dat) - if self.safety.safety_rx_hook(to_send) != 1: - failed_addrs[hex(msg.address)] += 1 - - # ensure all msgs defined in the addr checks are valid - self.safety.safety_tick_current_safety_config() - if t > 1e6: - self.assertTrue(self.safety.safety_config_valid()) - - # Don't check relay malfunction on disabled routes (relay closed), - # or before fingerprinting is done (elm327 and noOutput) - if self.openpilot_enabled and t / 1e4 > self.car_safety_mode_frame: - self.assertFalse(self.safety.get_relay_malfunction()) - else: - self.safety.set_relay_malfunction(False) - - self.assertFalse(len(failed_addrs), f"panda safety RX check failed: {failed_addrs}") - - # ensure RX checks go invalid after small time with no traffic - self.safety.set_timer(int(t + (2*1e6))) - self.safety.safety_tick_current_safety_config() - self.assertFalse(self.safety.safety_config_valid()) - - def test_panda_safety_tx_cases(self, data=None): - """Asserts we can tx common messages""" - if self.CP.dashcamOnly: - self.skipTest("no need to check panda safety for dashcamOnly") - - if self.CP.notCar: - self.skipTest("Skipping test for notCar") - - def test_car_controller(car_control, car_control_sp): - now_nanos = 0 - msgs_sent = 0 - CI = self.CarInterface(self.CP, self.CP_SP) - for _ in range(round(10.0 / DT_CTRL)): # make sure we hit the slowest messages - CI.update([]) - _, sendcan = CI.apply(car_control, car_control_sp, now_nanos) - - now_nanos += DT_CTRL * 1e9 - msgs_sent += len(sendcan) - for addr, dat, bus in sendcan: - to_send = libsafety_py.make_CANPacket(addr, bus % 4, dat) - self.assertTrue(self.safety.safety_tx_hook(to_send), (addr, dat, bus)) - - # Make sure we attempted to send messages - self.assertGreater(msgs_sent, 50) - - # Make sure we can send all messages while inactive - CC = structs.CarControl() - CC_SP = structs.CarControlSP() - test_car_controller(CC.as_reader(), CC_SP) - - # Test cancel + general messages (controls_allowed=False & cruise_engaged=True) - self.safety.set_cruise_engaged_prev(True) - CC = structs.CarControl(cruiseControl=structs.CarControl.CruiseControl(cancel=True)) - test_car_controller(CC.as_reader(), CC_SP) - - # Test resume + general messages (controls_allowed=True & cruise_engaged=True) - self.safety.set_controls_allowed(True) - CC = structs.CarControl(cruiseControl=structs.CarControl.CruiseControl(resume=True)) - test_car_controller(CC.as_reader(), CC_SP) - - # Skip stdout/stderr capture with pytest, causes elevated memory usage - @pytest.mark.nocapture - @settings(max_examples=MAX_EXAMPLES, deadline=None, - phases=(Phase.reuse, Phase.generate, Phase.shrink)) - @given(data=st.data()) - def test_panda_safety_carstate_fuzzy(self, data): - """ - For each example, pick a random CAN message on the bus and fuzz its data, - checking for panda state mismatches. - """ - - if self.CP.dashcamOnly: - self.skipTest("no need to check panda safety for dashcamOnly") - - valid_addrs = [(addr, bus, size) for bus, addrs in self.fingerprint.items() for addr, size in addrs.items()] - address, bus, size = data.draw(st.sampled_from(valid_addrs)) - - msg_strategy = st.binary(min_size=size, max_size=size) - msgs = data.draw(st.lists(msg_strategy, min_size=20)) - - vehicle_speed_seen = self.CP.steerControlType == SteerControlType.angle and not self.CP.notCar - - for n, dat in enumerate(msgs): - # due to panda updating state selectively, only edges are expected to match - # TODO: warm up CarState with real CAN messages to check edge of both sources - # (eg. toyota's gasPressed is the inverse of a signal being set) - prev_panda_gas = self.safety.get_gas_pressed_prev() - prev_panda_brake = self.safety.get_brake_pressed_prev() - prev_panda_regen_braking = self.safety.get_regen_braking_prev() - prev_panda_steering_disengage = self.safety.get_steering_disengage_prev() - prev_panda_vehicle_moving = self.safety.get_vehicle_moving() - prev_panda_vehicle_speed_min = self.safety.get_vehicle_speed_min() - prev_panda_vehicle_speed_max = self.safety.get_vehicle_speed_max() - prev_panda_cruise_engaged = self.safety.get_cruise_engaged_prev() - prev_panda_acc_main_on = self.safety.get_acc_main_on() - - to_send = libsafety_py.make_CANPacket(address, bus, dat) - self.safety.safety_rx_hook(to_send) - - can = [(int(time.monotonic() * 1e9), [CanData(address=address, dat=dat, src=bus)])] - CS, _ = self.CI.update(can) - if n < 5: # CANParser warmup time - continue - - if self.safety.get_gas_pressed_prev() != prev_panda_gas: - self.assertEqual(CS.gasPressed, self.safety.get_gas_pressed_prev()) - - if self.safety.get_brake_pressed_prev() != prev_panda_brake: - # TODO: remove this exception once this mismatch is resolved - brake_pressed = CS.brakePressed - if CS.brakePressed and not self.safety.get_brake_pressed_prev(): - if self.CP.carFingerprint in (HONDA.HONDA_PILOT, HONDA.HONDA_RIDGELINE) and CS.brake > 0.05: - brake_pressed = False - - self.assertEqual(brake_pressed, self.safety.get_brake_pressed_prev()) - - if self.safety.get_regen_braking_prev() != prev_panda_regen_braking: - self.assertEqual(CS.regenBraking, self.safety.get_regen_braking_prev()) - - if self.safety.get_steering_disengage_prev() != prev_panda_steering_disengage: - self.assertEqual(CS.steeringDisengage, self.safety.get_steering_disengage_prev()) - - if self.safety.get_vehicle_moving() != prev_panda_vehicle_moving and not self.CP.notCar: - self.assertEqual(not CS.standstill, self.safety.get_vehicle_moving()) - - # check vehicle speed if angle control car or available - if self.safety.get_vehicle_speed_min() > 0 or self.safety.get_vehicle_speed_max() > 0: - vehicle_speed_seen = True - - if vehicle_speed_seen and (self.safety.get_vehicle_speed_min() != prev_panda_vehicle_speed_min or - self.safety.get_vehicle_speed_max() != prev_panda_vehicle_speed_max): - v_ego_raw = CS.vEgoRaw / self.CP.wheelSpeedFactor - self.assertFalse(v_ego_raw > (self.safety.get_vehicle_speed_max() + 1e-3) or - v_ego_raw < (self.safety.get_vehicle_speed_min() - 1e-3)) - - if not (self.CP.brand == "honda" and not (self.CP.flags & HondaFlags.BOSCH)): - if self.safety.get_cruise_engaged_prev() != prev_panda_cruise_engaged: - self.assertEqual(CS.cruiseState.enabled, self.safety.get_cruise_engaged_prev()) - - if self.CP.brand == "honda": - if self.safety.get_acc_main_on() != prev_panda_acc_main_on: - self.assertEqual(CS.cruiseState.available, self.safety.get_acc_main_on()) - - def test_panda_safety_carstate(self): - """ - Assert that panda safety matches openpilot's carState - """ - if self.CP.dashcamOnly: - self.skipTest("no need to check panda safety for dashcamOnly") - - # warm up pass, as initial states may be different - for can in self.can_msgs[:300]: - self.CI.update(can) - for msg in filter(lambda m: m.src < 64, can[1]): - to_send = libsafety_py.make_CANPacket(msg.address, msg.src % 4, msg.dat) - self.safety.safety_rx_hook(to_send) - - controls_allowed_prev = False - CS_prev = car.CarState.new_message() - checks = defaultdict(int) - vehicle_speed_seen = self.CP.steerControlType == SteerControlType.angle and not self.CP.notCar - for idx, can in enumerate(self.can_msgs): - CS, _ = self.CI.update(can) - CS = CS.as_reader() - for msg in filter(lambda m: m.src < 64, can[1]): - to_send = libsafety_py.make_CANPacket(msg.address, msg.src % 4, msg.dat) - ret = self.safety.safety_rx_hook(to_send) - self.assertEqual(1, ret, f"safety rx failed ({ret=}): {(msg.address, msg.src % 4)}") - - # Skip first frame so CS_prev is properly initialized - if idx == 0: - CS_prev = CS - # Button may be left pressed in warm up period - if not self.CP.pcmCruise: - self.safety.set_controls_allowed(0) - continue - - # TODO: check rest of panda's carstate (steering, ACC main on, etc.) - - checks['gasPressed'] += CS.gasPressed != self.safety.get_gas_pressed_prev() - checks['standstill'] += (CS.standstill == self.safety.get_vehicle_moving()) and not self.CP.notCar - - # check vehicle speed if angle control car or available - if self.safety.get_vehicle_speed_min() > 0 or self.safety.get_vehicle_speed_max() > 0: - vehicle_speed_seen = True - - if vehicle_speed_seen: - v_ego_raw = CS.vEgoRaw / self.CP.wheelSpeedFactor - checks['vEgoRaw'] += (v_ego_raw > (self.safety.get_vehicle_speed_max() + 1e-3) or - v_ego_raw < (self.safety.get_vehicle_speed_min() - 1e-3)) - - # check steering angle for angle control cars (panda stores angle_meas in CAN units) - # ford and VW MEB excluded since they track curvature, not steering angle - # TODO: add curvature check, standardize CAN units to rm brand specific ANGLE_DEG_TO_CAN - if self.CP.steerControlType == SteerControlType.angle and not self.CP.notCar and self.CP.brand not in ("ford", "volkswagen"): - angle_can = (CS.steeringAngleDeg + CS.steeringAngleOffsetDeg) * ANGLE_DEG_TO_CAN[self.CP.brand] - checks['steeringAngleDeg'] += (angle_can > (self.safety.get_angle_meas_max() + 1) or - angle_can < (self.safety.get_angle_meas_min() - 1)) - - # TODO: remove this exception once this mismatch is resolved - brake_pressed = CS.brakePressed - if CS.brakePressed and not self.safety.get_brake_pressed_prev(): - if self.CP.carFingerprint in (HONDA.HONDA_PILOT, HONDA.HONDA_RIDGELINE) and CS.brakeDEPRECATED > 0.05: - brake_pressed = False - checks['brakePressed'] += brake_pressed != self.safety.get_brake_pressed_prev() - checks['regenBraking'] += CS.regenBraking != self.safety.get_regen_braking_prev() - checks['steeringDisengage'] += CS.steeringDisengage != self.safety.get_steering_disengage_prev() - - if self.CP.pcmCruise: - # On most pcmCruise cars, openpilot's state is always tied to the PCM's cruise state. - # On Honda Nidec, we always engage on the rising edge of the PCM cruise state, but - # openpilot brakes to zero even if the min ACC speed is non-zero (i.e. the PCM disengages). - if self.CP.brand == "honda" and not (self.CP.flags & HondaFlags.BOSCH): - # only the rising edges are expected to match - if CS.cruiseState.enabled and not CS_prev.cruiseState.enabled: - checks['controlsAllowed'] += not self.safety.get_controls_allowed() - else: - checks['controlsAllowed'] += not CS.cruiseState.enabled and self.safety.get_controls_allowed() - - # TODO: fix notCar mismatch - if not self.CP.notCar: - checks['cruiseState'] += CS.cruiseState.enabled != self.safety.get_cruise_engaged_prev() - else: - # Check for user button enable on rising edge of controls allowed - button_enable = CS.buttonEnable and (not CS.brakePressed or CS.standstill) - mismatch = button_enable != (self.safety.get_controls_allowed() and not controls_allowed_prev) - checks['controlsAllowed'] += mismatch - controls_allowed_prev = self.safety.get_controls_allowed() - if button_enable and not mismatch: - self.safety.set_controls_allowed(False) - - if self.CP.brand == "honda": - checks['mainOn'] += CS.cruiseState.available != self.safety.get_acc_main_on() - - CS_prev = CS - - failed_checks = {k: v for k, v in checks.items() if v > 0} - self.assertFalse(len(failed_checks), f"panda safety doesn't agree with openpilot: {failed_checks}") - - -@parameterized_class(('platform', 'test_route'), get_test_cases()) -@pytest.mark.xdist_group_class_property('test_route') -class TestCarModel(TestCarModelBase): - pass - - -if __name__ == "__main__": - unittest.main() diff --git a/openpilot/selfdrive/car/tests/test_models_segs.txt b/openpilot/selfdrive/car/tests/test_models_segs.txt deleted file mode 100644 index c983fb08e7..0000000000 --- a/openpilot/selfdrive/car/tests/test_models_segs.txt +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0810a361ec5b5f5f9a2ee73b89ffb2df62ef40e8feff7e97ecb62f80fa53f6f5 -size 124950 diff --git a/openpilot/selfdrive/controls/controlsd.py b/openpilot/selfdrive/controls/controlsd.py index d9c0b445d8..f3c8782cb0 100755 --- a/openpilot/selfdrive/controls/controlsd.py +++ b/openpilot/selfdrive/controls/controlsd.py @@ -43,9 +43,9 @@ class Controls(ControlsExt): self.CI = interfaces[self.CP.carFingerprint](self.CP, self.CP_SP) - self.sm = messaging.SubMaster(['liveDelay', 'liveParameters', 'liveTorqueParameters', 'modelV2', 'selfdriveState', - 'liveCalibration', 'livePose', 'longitudinalPlan', 'lateralManeuverPlan', 'carState', 'carOutput', - 'driverMonitoringState', 'onroadEvents', 'driverAssistance', 'liveDelay'] + self.sm_services_ext, + self.sm = messaging.SubMaster(['lateralDelay', 'vehicleParameters', 'lateralTorqueParameters', 'modelV2', 'selfdriveState', + 'extrinsicsCalibration', 'deviceMotion', 'longitudinalPlan', 'lateralManeuverPlan', 'carState', 'carOutput', + 'driverMonitoringState', 'onroadEvents', 'driverAssistance'] + self.sm_services_ext, poll='selfdriveState') self.pm = messaging.PubMaster(['carControl', 'controlsState'] + self.pm_services_ext) @@ -72,17 +72,17 @@ class Controls(ControlsExt): def update(self): self.sm.update(15) - if self.sm.updated["liveCalibration"]: - self.pose_calibrator.feed_live_calib(self.sm['liveCalibration']) - if self.sm.updated["livePose"]: - device_pose = Pose.from_live_pose(self.sm['livePose']) - self.calibrated_pose = self.pose_calibrator.build_calibrated_pose(device_pose) + if self.sm.updated["extrinsicsCalibration"]: + self.pose_calibrator.feed_extrinsics_calibration(self.sm['extrinsicsCalibration']) + if self.sm.updated["deviceMotion"]: + device_motion = Pose.from_device_motion(self.sm['deviceMotion']) + self.calibrated_pose = self.pose_calibrator.build_calibrated_pose(device_motion) def state_control(self): CS = self.sm['carState'] # Update VehicleModel - lp = self.sm['liveParameters'] + lp = self.sm['vehicleParameters'] x = max(lp.stiffnessFactor, 0.1) sr = max(lp.steerRatio, 0.1) self.VM.update_params(x, sr) @@ -92,9 +92,9 @@ class Controls(ControlsExt): # Update Torque Params if self.CP.lateralTuning.which() == 'torque': - torque_params = self.sm['liveTorqueParameters'] - if self.sm.all_checks(['liveTorqueParameters']) and torque_params.useParams: - self.LaC.update_live_torque_params(torque_params.latAccelFactorFiltered, torque_params.latAccelOffsetFiltered, + torque_params = self.sm['lateralTorqueParameters'] + if self.sm.all_checks(['lateralTorqueParameters']) and torque_params.useParams: + self.LaC.update_torque_parameters(torque_params.latAccelFactorFiltered, torque_params.latAccelOffsetFiltered, torque_params.frictionCoefficientFiltered) self.LaC.extension.update_limits() @@ -144,7 +144,7 @@ class Controls(ControlsExt): else: new_desired_curvature = model_v2.action.desiredCurvature if CC.latActive else self.curvature self.desired_curvature, curvature_limited = clip_curvature(CS.vEgo, self.desired_curvature, new_desired_curvature, lp.roll) - lat_delay = self.sm["liveDelay"].lateralDelay + LAT_SMOOTH_SECONDS + lat_delay = self.sm["lateralDelay"].lateralDelay + LAT_SMOOTH_SECONDS actuators.curvature = self.desired_curvature steer, lateral_output, lac_log = self.LaC.update(CC.latActive, CS, self.VM, lp, diff --git a/openpilot/selfdrive/controls/lib/desire_helper.py b/openpilot/selfdrive/controls/lib/desire_helper.py index df4e5c56ab..334b360a62 100644 --- a/openpilot/selfdrive/controls/lib/desire_helper.py +++ b/openpilot/selfdrive/controls/lib/desire_helper.py @@ -33,7 +33,7 @@ class DesireHelper: def get_lane_change_direction(CS): return LaneChangeDirection.left if CS.leftBlinker else LaneChangeDirection.right - def update(self, carstate, lateral_active, lane_change_prob): + def update(self, carstate, lateral_active, lane_change_prob, left_edge_detected=False, right_edge_detected=False): self.alc.update_params() self.lane_turn_controller.update_params() v_ego = carstate.vEgo @@ -64,8 +64,8 @@ class DesireHelper: ((carstate.steeringTorque > 0 and self.lane_change_direction == LaneChangeDirection.left) or (carstate.steeringTorque < 0 and self.lane_change_direction == LaneChangeDirection.right)) - blindspot_detected = ((carstate.leftBlindspot and self.lane_change_direction == LaneChangeDirection.left) or - (carstate.rightBlindspot and self.lane_change_direction == LaneChangeDirection.right)) + blindspot_detected = (((carstate.leftBlindspot or left_edge_detected) and self.lane_change_direction == LaneChangeDirection.left) or + ((carstate.rightBlindspot or right_edge_detected) and self.lane_change_direction == LaneChangeDirection.right)) self.alc.update_lane_change(blindspot_detected, carstate.brakePressed) diff --git a/openpilot/selfdrive/controls/lib/drive_helpers.py b/openpilot/selfdrive/controls/lib/drive_helpers.py index 5392ff8875..35ca45e56e 100644 --- a/openpilot/selfdrive/controls/lib/drive_helpers.py +++ b/openpilot/selfdrive/controls/lib/drive_helpers.py @@ -7,7 +7,6 @@ CONTROL_N = 17 CAR_ROTATION_RADIUS = 0.0 # This is a turn radius smaller than most cars can achieve MAX_CURVATURE = 0.2 -MAX_VEL_ERR = 5.0 # m/s MIN_STABLE_DELAY = 0.3 # EU guidelines @@ -15,6 +14,9 @@ MAX_LATERAL_JERK = 5.0 # m/s^3 MAX_LATERAL_ACCEL_NO_ROLL = 3.0 # m/s^2 +def should_stop(v_ego: float, a_target: float) -> bool: + return bool(v_ego < 0.3 and a_target < 0.1) + def clamp(val, min_val, max_val): clamped_val = float(np.clip(val, min_val, max_val)) return clamped_val, clamped_val != val @@ -40,7 +42,7 @@ def clip_curvature(v_ego, prev_curvature, new_curvature, roll) -> tuple[float, b return float(new_curvature), limited_accel or limited_max_curv -def get_accel_from_plan(speeds, accels, t_idxs, action_t=DT_MDL, vEgoStopping=0.3): +def get_accel_from_plan(speeds, accels, t_idxs, action_t=DT_MDL): if len(speeds) == len(t_idxs): v_now = speeds[0] a_now = accels[0] @@ -50,11 +52,8 @@ def get_accel_from_plan(speeds, accels, t_idxs, action_t=DT_MDL, vEgoStopping=0. v_target = np.interp(action_t, t_idxs, speeds) a_target = 2 * (v_target - v_now) / (action_t) - a_now else: - v_now = 0.0 - v_target = 0.0 a_target = 0.0 - should_stop = (v_now < vEgoStopping and a_target < 0.1) - return a_target, should_stop + return a_target def curv_from_psis(psi_target, psi_rate, vego, action_t): vego = np.clip(vego, MIN_SPEED, np.inf) diff --git a/openpilot/selfdrive/controls/lib/latcontrol.py b/openpilot/selfdrive/controls/lib/latcontrol.py index 4207a188f4..5519d37296 100644 --- a/openpilot/selfdrive/controls/lib/latcontrol.py +++ b/openpilot/selfdrive/controls/lib/latcontrol.py @@ -14,7 +14,7 @@ class LatControl(ABC): self.steer_max = 1.0 @abstractmethod - def update(self, active: bool, CS, VM, params, steer_limited_by_safety: bool, desired_curvature: float, calibrated_pose: Pose, + def update(self, active: bool, CS, VM, params, steer_limited_by_safety: bool, desired_curvature: float, calibrated_pose: Pose | None, curvature_limited: bool, lat_delay: float): pass diff --git a/openpilot/selfdrive/controls/lib/latcontrol_torque.py b/openpilot/selfdrive/controls/lib/latcontrol_torque.py index 952234de99..6a9eebe867 100644 --- a/openpilot/selfdrive/controls/lib/latcontrol_torque.py +++ b/openpilot/selfdrive/controls/lib/latcontrol_torque.py @@ -50,7 +50,7 @@ class LatControlTorque(LatControl): self.extension = LatControlTorqueExt(self, CP, CP_SP, CI) - def update_live_torque_params(self, latAccelFactor, latAccelOffset, friction): + def update_torque_parameters(self, latAccelFactor, latAccelOffset, friction): self.torque_params.latAccelFactor = latAccelFactor self.torque_params.latAccelOffset = latAccelOffset self.torque_params.friction = friction diff --git a/openpilot/selfdrive/controls/lib/ldw.py b/openpilot/selfdrive/controls/lib/ldw.py index 6db59791cc..6164111190 100644 --- a/openpilot/selfdrive/controls/lib/ldw.py +++ b/openpilot/selfdrive/controls/lib/ldw.py @@ -1,5 +1,5 @@ from openpilot.cereal import log -from openpilot.common.realtime import DT_CTRL +from openpilot.common.realtime import DT_MDL from openpilot.common.constants import CV @@ -17,7 +17,7 @@ class LaneDepartureWarning: if CS.leftBlinker or CS.rightBlinker: self.last_blinker_frame = frame - recent_blinker = (frame - self.last_blinker_frame) * DT_CTRL < 5.0 # 5s blinker cooldown + recent_blinker = (frame - self.last_blinker_frame) * DT_MDL < 5.0 # 5s blinker cooldown ldw_allowed = CS.vEgo > LDW_MIN_SPEED and not recent_blinker and not CC.latActive desire_prediction = modelV2.meta.desirePrediction diff --git a/openpilot/selfdrive/controls/lib/longcontrol.py b/openpilot/selfdrive/controls/lib/longcontrol.py index 99a42b1bca..3a20601c48 100644 --- a/openpilot/selfdrive/controls/lib/longcontrol.py +++ b/openpilot/selfdrive/controls/lib/longcontrol.py @@ -10,7 +10,7 @@ CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N] LongCtrlState = car.CarControl.Actuators.LongControlState -def long_control_state_trans(CP, CP_SP, active, long_control_state, v_ego, +def long_control_state_trans(CP_SP, active, long_control_state, should_stop, brake_pressed, cruise_standstill): # Gas Interceptor cruise_standstill = cruise_standstill and not CP_SP.enableGasInterceptor @@ -26,22 +26,17 @@ def long_control_state_trans(CP, CP_SP, active, long_control_state, v_ego, if long_control_state == LongCtrlState.off: if not starting_condition: long_control_state = LongCtrlState.stopping - elif CP.startingState: - long_control_state = LongCtrlState.starting else: long_control_state = LongCtrlState.pid elif long_control_state == LongCtrlState.stopping: - if starting_condition and CP.startingState: - long_control_state = LongCtrlState.starting - elif starting_condition: + if starting_condition: long_control_state = LongCtrlState.pid - elif long_control_state in [LongCtrlState.starting, LongCtrlState.pid]: + elif long_control_state == LongCtrlState.pid: if should_stop: long_control_state = LongCtrlState.stopping - elif v_ego > CP.vEgoStarting: - long_control_state = LongCtrlState.pid + return long_control_state class LongControl: @@ -49,8 +44,7 @@ class LongControl: self.CP = CP self.CP_SP = CP_SP self.long_control_state = LongCtrlState.off - self.pid = PIDController((CP.longitudinalTuning.kpBP, CP.longitudinalTuning.kpV), - (CP.longitudinalTuning.kiBP, CP.longitudinalTuning.kiV), + self.pid = PIDController(0.0, (CP.longitudinalTuning.kiBP, CP.longitudinalTuning.kiV), rate=1 / DT_CTRL) self.last_output_accel = 0.0 @@ -62,7 +56,7 @@ class LongControl: self.pid.neg_limit = accel_limits[0] self.pid.pos_limit = accel_limits[1] - self.long_control_state = long_control_state_trans(self.CP, self.CP_SP, active, self.long_control_state, CS.vEgo, + self.long_control_state = long_control_state_trans(self.CP_SP, active, self.long_control_state, should_stop, CS.brakePressed, CS.cruiseState.standstill) if self.long_control_state == LongCtrlState.off: @@ -73,11 +67,8 @@ class LongControl: output_accel = self.last_output_accel if output_accel > self.CP.stopAccel: output_accel = min(output_accel, 0.0) - output_accel -= self.CP.stoppingDecelRate * DT_CTRL - self.reset() - - elif self.long_control_state == LongCtrlState.starting: - output_accel = self.CP.startAccel + # TODO: can we just go straight to stopAccel? + output_accel -= 1.0 * DT_CTRL # m/s^2/s while trying to stop self.reset() else: # LongCtrlState.pid diff --git a/openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/SConscript b/openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/SConscript index 636ef0fb21..fa249765bc 100644 --- a/openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/SConscript +++ b/openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/SConscript @@ -35,6 +35,7 @@ build_files = [f'{gen}/acados_solver_long.c'] + casadi_model + casadi_cost_y + c # extra generated files used to trigger a rebuild generated_files = [ + 'acados_ocp_long.json', f'{gen}/Makefile', f'{gen}/main_long.c', diff --git a/openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py b/openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py index de1ba0c278..0a0722fbc6 100755 --- a/openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py +++ b/openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py @@ -23,7 +23,7 @@ EXPORT_DIR = os.path.join(LONG_MPC_DIR, "c_generated_code") JSON_FILE = os.path.join(LONG_MPC_DIR, "acados_ocp_long.json") LongitudinalPlanSource = log.LongitudinalPlan.LongitudinalPlanSource -MPC_SOURCES = (LongitudinalPlanSource.lead0, LongitudinalPlanSource.lead1, LongitudinalPlanSource.cruise) +MPC_SOURCES = (LongitudinalPlanSource.lead0, LongitudinalPlanSource.lead1) X_DIM = 3 U_DIM = 1 @@ -55,8 +55,6 @@ FCW_IDXS = T_IDXS < 5.0 T_DIFFS = np.diff(T_IDXS, prepend=[0.]) COMFORT_BRAKE = 2.5 STOP_DISTANCE = 6.0 -CRUISE_MIN_ACCEL = -1.2 -CRUISE_MAX_ACCEL = 1.6 MIN_X_LEAD_FACTOR = 0.5 def get_jerk_factor(personality=log.LongitudinalPersonality.standard): @@ -104,7 +102,7 @@ def gen_long_model(): a_ego_dot = SX.sym('a_ego_dot') model.xdot = vertcat(x_ego_dot, v_ego_dot, a_ego_dot) - # live parameters + # runtime parameters a_min = SX.sym('a_min') a_max = SX.sym('a_max') x_obstacle = SX.sym('x_obstacle') @@ -309,9 +307,8 @@ class LongitudinalMpc: lead_xv = self.extrapolate_lead(x_lead, v_lead, a_lead, a_lead_tau) return lead_xv - def update(self, radarstate, v_cruise, personality=log.LongitudinalPersonality.standard): + def update(self, radarstate, personality=log.LongitudinalPersonality.standard): t_follow = get_T_FOLLOW(personality) - v_ego = self.x0[1] lead_xv_0 = self.process_lead(radarstate.leadOne) lead_xv_1 = self.process_lead(radarstate.leadTwo) @@ -322,15 +319,7 @@ class LongitudinalMpc: lead_0_obstacle = lead_xv_0[:,0] + get_stopped_equivalence_factor(lead_xv_0[:,1]) lead_1_obstacle = lead_xv_1[:,0] + get_stopped_equivalence_factor(lead_xv_1[:,1]) - # Fake an obstacle for cruise, this ensures smooth acceleration to set speed - # when the leads are no factor. - v_lower = v_ego + (T_IDXS * CRUISE_MIN_ACCEL * 1.05) - # TODO does this make sense when max_a is negative? - v_upper = v_ego + (T_IDXS * CRUISE_MAX_ACCEL * 1.05) - v_cruise_clipped = np.clip(v_cruise * np.ones(N+1), v_lower, v_upper) - cruise_obstacle = np.cumsum(T_DIFFS * v_cruise_clipped) + get_safe_obstacle_distance(v_cruise_clipped, t_follow) - - x_obstacles = np.column_stack([lead_0_obstacle, lead_1_obstacle, cruise_obstacle]) + x_obstacles = np.column_stack([lead_0_obstacle, lead_1_obstacle]) self.source = MPC_SOURCES[np.argmin(x_obstacles[0])] self.yref[:,:] = 0.0 diff --git a/openpilot/selfdrive/controls/lib/longitudinal_planner.py b/openpilot/selfdrive/controls/lib/longitudinal_planner.py index 4f7e1e92de..bf91c5e1f9 100755 --- a/openpilot/selfdrive/controls/lib/longitudinal_planner.py +++ b/openpilot/selfdrive/controls/lib/longitudinal_planner.py @@ -11,7 +11,7 @@ from openpilot.selfdrive.modeld.constants import ModelConstants from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalMpc, LongitudinalPlanSource from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import T_IDXS as T_IDXS_MPC -from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, get_accel_from_plan +from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, get_accel_from_plan, should_stop from openpilot.selfdrive.car.cruise import V_CRUISE_MAX, V_CRUISE_UNSET from openpilot.common.swaglog import cloudlog @@ -19,6 +19,8 @@ from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import Lon A_CRUISE_MAX_VALS = [1.6, 1.2, 0.8, 0.6] A_CRUISE_MAX_BP = [0., 10.0, 25., 40.] +J_CRUISE_VALS = [1.6, 1.2, 0.8, 0.6] +A_CRUISE_MIN = -1.2 CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N] ALLOW_THROTTLE_THRESHOLD = 0.4 MIN_ALLOW_THROTTLE_SPEED = 2.5 @@ -33,18 +35,24 @@ def get_max_accel(v_ego): def get_coast_accel(pitch): return np.sin(pitch) * -5.65 - 0.3 # fitted from data using xx/projects/allow_throttle/compute_coast_accel.py -def limit_accel_in_turns(v_ego, angle_steers, a_target, CP): - """ - This function returns a limited long acceleration allowed, depending on the existing lateral acceleration - this should avoid accelerating when losing the target in turns - """ - # FIXME: This function to calculate lateral accel is incorrect and should use the VehicleModel - # The lookup table for turns should also be updated if we do this - a_total_max = np.interp(v_ego, _A_TOTAL_MAX_BP, _A_TOTAL_MAX_V) - a_y = v_ego ** 2 * angle_steers * CV.DEG_TO_RAD / (CP.steerRatio * CP.wheelbase) - a_x_allowed = math.sqrt(max(a_total_max ** 2 - a_y ** 2, 0.)) +def get_cruise_accel(e2e, v_cruise, v_ego, a_cruise_prev, angle_steers, CP, dt, accel_coast, allow_throttle): + max_accel = ACCEL_MAX if e2e else get_max_accel(v_ego) - return [a_target[0], min(a_target[1], a_x_allowed)] + if not e2e: + a_total_max = np.interp(v_ego, _A_TOTAL_MAX_BP, _A_TOTAL_MAX_V) + a_y = v_ego ** 2 * angle_steers * CV.DEG_TO_RAD / (CP.steerRatio * CP.wheelbase) + a_x_allowed = math.sqrt(max(a_total_max ** 2 - a_y ** 2, 0.)) + max_accel = min(max_accel, a_x_allowed) + if not allow_throttle: + clipped_accel_coast = max(accel_coast, ACCEL_MIN) + coast_limit = np.interp(v_ego, [MIN_ALLOW_THROTTLE_SPEED, MIN_ALLOW_THROTTLE_SPEED*2], [max_accel, clipped_accel_coast]) + max_accel = min(max_accel, coast_limit) + + target_accel = np.clip(v_cruise - v_ego, A_CRUISE_MIN, max_accel) + j_cruise = np.interp(v_ego, A_CRUISE_MAX_BP, J_CRUISE_VALS) + target_accel = float(np.clip(target_accel, a_cruise_prev - j_cruise * dt, a_cruise_prev + j_cruise * dt)) + + return target_accel class LongitudinalPlanner(LongitudinalPlannerSP): @@ -56,10 +64,9 @@ class LongitudinalPlanner(LongitudinalPlannerSP): self.dt = dt self.allow_throttle = True - self.a_desired = init_a self.v_desired_filter = FirstOrderFilter(init_v, 2.0, self.dt) - self.prev_accel_clip = [ACCEL_MIN, ACCEL_MAX] - self.output_a_target = 0.0 + self.a_cruise = init_a + self.output_a_target = init_a self.output_should_stop = False self.v_desired_trajectory = np.zeros(CONTROL_N) @@ -77,49 +84,40 @@ class LongitudinalPlanner(LongitudinalPlannerSP): v_ego = sm['carState'].vEgo v_cruise_kph = min(sm['carState'].vCruise, V_CRUISE_MAX) v_cruise = v_cruise_kph * CV.KPH_TO_MS - v_cruise_initialized = sm['carState'].vCruise != V_CRUISE_UNSET + if sm['controlsState'].forceDecel: + v_cruise = 0.0 long_control_off = sm['controlsState'].longControlState == LongCtrlState.off - force_slow_decel = sm['controlsState'].forceDecel # Reset current state when not engaged, or user is controlling the speed reset_state = long_control_off if self.CP.openpilotLongitudinalControl else not sm['selfdriveState'].enabled # PCM cruise speed may be updated a few cycles later, check if initialized + v_cruise_initialized = sm['carState'].vCruise != V_CRUISE_UNSET reset_state = reset_state or not v_cruise_initialized + throttle_probs = sm['modelV2'].meta.disengagePredictions.gasPressProbs + throttle_prob = throttle_probs[1] if len(throttle_probs) > 1 else 1.0 + self.allow_throttle = throttle_prob > ALLOW_THROTTLE_THRESHOLD or v_ego <= MIN_ALLOW_THROTTLE_SPEED + + steer_angle_without_offset = sm['carState'].steeringAngleDeg - sm['vehicleParameters'].angleOffsetDeg + + if reset_state: + self.v_desired_filter.x = v_ego + self.output_a_target = np.clip(sm['carState'].aEgo, ACCEL_MIN, ACCEL_MAX) + self.a_cruise = self.output_a_target + + # Prevent divergence, smooth in current v_ego + self.v_desired_filter.x = max(0.0, self.v_desired_filter.update(v_ego)) + # No change cost when user is controlling the speed, or when standstill prev_accel_constraint = not (reset_state or sm['carState'].standstill) - accel_clip = [ACCEL_MIN, get_max_accel(v_ego)] - steer_angle_without_offset = sm['carState'].steeringAngleDeg - sm['liveParameters'].angleOffsetDeg - accel_clip = limit_accel_in_turns(v_ego, steer_angle_without_offset, accel_clip, self.CP) - - if reset_state: - self.v_desired_filter.x = v_ego - # Clip aEgo to cruise limits to prevent large accelerations when becoming active - self.a_desired = np.clip(sm['carState'].aEgo, accel_clip[0], accel_clip[1]) - - # Prevent divergence, smooth in current v_ego - self.v_desired_filter.x = max(0.0, self.v_desired_filter.update(v_ego)) - throttle_probs = sm['modelV2'].meta.disengagePredictions.gasPressProbs - throttle_prob = throttle_probs[1] if len(throttle_probs) > 1 else 1.0 - # Don't clip at low speeds since throttle_prob doesn't account for creep - self.allow_throttle = throttle_prob > ALLOW_THROTTLE_THRESHOLD or v_ego <= MIN_ALLOW_THROTTLE_SPEED - - if not self.allow_throttle: - clipped_accel_coast = max(accel_coast, accel_clip[0]) - clipped_accel_coast_interp = np.interp(v_ego, [MIN_ALLOW_THROTTLE_SPEED, MIN_ALLOW_THROTTLE_SPEED*2], [accel_clip[1], clipped_accel_coast]) - accel_clip[1] = min(accel_clip[1], clipped_accel_coast_interp) - - # Get new v_cruise and a_desired from Smart Cruise Control and Speed Limit Assist - v_cruise, self.a_desired = LongitudinalPlannerSP.update_targets(self, sm, self.v_desired_filter.x, self.a_desired, v_cruise) - - if force_slow_decel: - v_cruise = 0.0 + # Get new v_cruise and a_target from Smart Cruise Control and Speed Limit Assist + v_cruise, self.output_a_target = LongitudinalPlannerSP.update_targets(self, sm, self.v_desired_filter.x, self.output_a_target, v_cruise) self.mpc.set_weights(prev_accel_constraint, personality=sm['selfdriveState'].personality) - self.mpc.set_cur_state(self.v_desired_filter.x, self.a_desired) - self.mpc.update(sm['radarState'], v_cruise, personality=sm['selfdriveState'].personality) + self.mpc.set_cur_state(self.v_desired_filter.x, self.output_a_target) + self.mpc.update(sm['radarState'], personality=sm['selfdriveState'].personality) self.v_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.v_solution) self.a_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.a_solution) @@ -130,30 +128,33 @@ class LongitudinalPlanner(LongitudinalPlannerSP): if self.fcw: cloudlog.info("FCW triggered") - # Interpolate 0.05 seconds and save as starting point for next iteration - a_prev = self.a_desired - self.a_desired = float(np.interp(self.dt, CONTROL_N_T_IDX, self.a_desired_trajectory)) - self.v_desired_filter.x = self.v_desired_filter.x + self.dt * (self.a_desired + a_prev) / 2.0 + # Save starting point for next iteration + a_prev = self.output_a_target action_t = self.CP.longitudinalActuatorDelay + DT_MDL - output_a_target_mpc, output_should_stop_mpc = get_accel_from_plan(self.v_desired_trajectory, self.a_desired_trajectory, CONTROL_N_T_IDX, - action_t=action_t, vEgoStopping=self.CP.vEgoStopping) + output_a_target_mpc = get_accel_from_plan(self.v_desired_trajectory, self.a_desired_trajectory, CONTROL_N_T_IDX, + action_t=action_t) + output_should_stop_mpc = should_stop(v_ego, output_a_target_mpc) output_a_target_e2e = sm['modelV2'].action.desiredAcceleration output_should_stop_e2e = sm['modelV2'].action.shouldStop - if self.is_e2e(sm): - output_a_target = min(output_a_target_e2e, output_a_target_mpc) - self.output_should_stop = output_should_stop_e2e or output_should_stop_mpc - if output_a_target < output_a_target_mpc: - self.mpc.source = LongitudinalPlanSource.e2e - else: - output_a_target = output_a_target_mpc - self.output_should_stop = output_should_stop_mpc + is_e2e = self.is_e2e(sm) - for idx in range(2): - accel_clip[idx] = np.clip(accel_clip[idx], self.prev_accel_clip[idx] - 0.05, self.prev_accel_clip[idx] + 0.05) - self.output_a_target = np.clip(output_a_target, accel_clip[0], accel_clip[1]) - self.prev_accel_clip = accel_clip + self.a_cruise = get_cruise_accel(is_e2e, v_cruise, v_ego, + self.a_cruise, steer_angle_without_offset, self.CP, self.dt, + accel_coast, self.allow_throttle) + cruise_should_stop = should_stop(v_ego, self.a_cruise) + + candidates = [(output_a_target_mpc, self.mpc.source, output_should_stop_mpc), + (self.a_cruise, LongitudinalPlanSource.cruise, cruise_should_stop)] + if is_e2e: + candidates.append((output_a_target_e2e, LongitudinalPlanSource.e2e, output_should_stop_e2e)) + + output_a_target, self.mpc.source, _ = min(candidates, key=lambda c: c[0]) + self.output_should_stop = any(should_stop for _, _, should_stop in candidates) + self.output_a_target = np.clip(output_a_target, ACCEL_MIN, ACCEL_MAX) + + self.v_desired_filter.x = self.v_desired_filter.x + self.dt * (self.output_a_target + a_prev) / 2.0 def publish(self, sm, pm): plan_send = messaging.new_message('longitudinalPlan') diff --git a/openpilot/selfdrive/controls/plannerd.py b/openpilot/selfdrive/controls/plannerd.py index 5908e141f7..e7f36d3240 100755 --- a/openpilot/selfdrive/controls/plannerd.py +++ b/openpilot/selfdrive/controls/plannerd.py @@ -23,18 +23,18 @@ def main(): cloudlog.info("plannerd got CarParamsSP") gps_location_service = get_gps_location_service(params) - ignore_services = ["liveMapDataSP", gps_location_service] + ignore_services = ["liveMapDataSP", "carStateSP", "selfdriveStateSP", gps_location_service] ldw = LaneDepartureWarning() longitudinal_planner = LongitudinalPlanner(CP, CP_SP) pm = messaging.PubMaster(['longitudinalPlan', 'driverAssistance', 'longitudinalPlanSP']) - sm = messaging.SubMaster(['carControl', 'carState', 'controlsState', 'liveParameters', 'radarState', 'modelV2', 'selfdriveState', - 'liveMapDataSP', 'carStateSP', gps_location_service], - poll='carState', ignore_alive=ignore_services, ignore_avg_freq=ignore_services, ignore_valid=ignore_services) + sm = messaging.SubMaster(['carControl', 'carState', 'controlsState', 'vehicleParameters', 'radarState', 'modelV2', 'selfdriveState', + 'liveMapDataSP', 'carStateSP', 'selfdriveStateSP', gps_location_service], + poll='modelV2', ignore_alive=ignore_services, ignore_avg_freq=ignore_services, ignore_valid=ignore_services) while True: sm.update() - longitudinal_planner.sla.update_car_state(sm['carState']) + longitudinal_planner.sla.update_buttons(sm['selfdriveStateSP'].buttonsReleaseToggle) if sm.updated['modelV2']: longitudinal_planner.update(sm) longitudinal_planner.publish(sm, pm) diff --git a/openpilot/selfdrive/controls/radard.py b/openpilot/selfdrive/controls/radard.py index 5db5865e75..56a193f833 100755 --- a/openpilot/selfdrive/controls/radard.py +++ b/openpilot/selfdrive/controls/radard.py @@ -197,7 +197,6 @@ class RadarD: self.CP_SP = CP_SP self.current_time = 0.0 - self.tracks: dict[int, Track] = {} self.kalman_params = KalmanParams(DT_MDL) self.lead_prob_filters = [FirstOrderFilter(0.0, 0.2, DT_MDL) for _ in range(2)] @@ -213,7 +212,6 @@ class RadarD: def update(self, sm: messaging.SubMaster, rr: car.RadarData): self.ready = sm.seen['modelV2'] - self.current_time = 1e-9*max(sm.logMonoTime.values()) if sm.recv_frame['carState'] != self.last_v_ego_frame: self.v_ego = sm['carState'].vEgo @@ -287,7 +285,7 @@ def main() -> None: cloudlog.info("radard got CarParamsSP") # *** setup messaging - sm = messaging.SubMaster(['modelV2', 'carState', 'liveTracks'], poll='modelV2') + sm = messaging.SubMaster(['modelV2', 'carState', 'radarTracks'], poll='modelV2') pm = messaging.PubMaster(['radarState']) RD = RadarD(CP, CP_SP, CP.radarDelay) @@ -295,7 +293,7 @@ def main() -> None: while 1: sm.update() - RD.update(sm, sm['liveTracks']) + RD.update(sm, sm['radarTracks']) RD.publish(pm) diff --git a/openpilot/selfdrive/controls/tests/test_following_distance.py b/openpilot/selfdrive/controls/tests/test_following_distance.py index fcabce0387..1914769282 100644 --- a/openpilot/selfdrive/controls/tests/test_following_distance.py +++ b/openpilot/selfdrive/controls/tests/test_following_distance.py @@ -1,5 +1,5 @@ -import pytest import itertools +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized_class from openpilot.cereal import log @@ -36,11 +36,12 @@ def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personalit log.LongitudinalPersonality.standard, log.LongitudinalPersonality.aggressive], [0,10,35])) # speed -class TestFollowingDistance: +class TestFollowingDistance(OpenpilotTestCase): def test_following_distance(self): v_lead = float(self.speed) simulation_steady_state = run_following_distance_simulation(v_lead, e2e=self.e2e, personality=self.personality) correct_steady_state = desired_follow_distance(v_lead, v_lead, get_T_FOLLOW(self.personality)) err_ratio = 0.2 if self.e2e else 0.1 abs_err_margin = 0.5 if v_lead > 0.0 else 1.15 - assert simulation_steady_state == pytest.approx(correct_steady_state, abs=err_ratio * correct_steady_state + abs_err_margin) + self.assertAlmostEqual(simulation_steady_state, correct_steady_state, + delta=err_ratio * correct_steady_state + abs_err_margin) diff --git a/openpilot/selfdrive/controls/tests/test_latcontrol.py b/openpilot/selfdrive/controls/tests/test_latcontrol.py index 52e6912d6d..0dc26887e3 100644 --- a/openpilot/selfdrive/controls/tests/test_latcontrol.py +++ b/openpilot/selfdrive/controls/tests/test_latcontrol.py @@ -1,3 +1,4 @@ +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized from openpilot.cereal import log @@ -14,11 +15,11 @@ from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle from openpilot.selfdrive.locationd.helpers import Pose -from openpilot.common.mock.generators import generate_livePose +from openpilot.common.mock.generators import generate_deviceMotion from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces -class TestLatControl: +class TestLatControl(OpenpilotTestCase): @parameterized.expand([(HONDA.HONDA_CIVIC, LatControlPID), (TOYOTA.TOYOTA_RAV4, LatControlTorque), (NISSAN.NISSAN_LEAF, LatControlAngle), (GM.CHEVROLET_BOLT_EUV, LatControlTorque)]) @@ -37,10 +38,10 @@ class TestLatControl: CS.vEgo = 30 CS.steeringPressed = False - params = log.LiveParametersData.new_message() + params = log.VehicleParameters.new_message() - lp = generate_livePose() - pose = Pose.from_live_pose(lp.livePose) + lp = generate_deviceMotion() + pose = Pose.from_device_motion(lp.deviceMotion) # Saturate for curvature limited and controller limited for _ in range(1000): diff --git a/openpilot/selfdrive/controls/tests/test_latcontrol_torque_buffer.py b/openpilot/selfdrive/controls/tests/test_latcontrol_torque_buffer.py index dc634f20fd..cc6b9aef16 100644 --- a/openpilot/selfdrive/controls/tests/test_latcontrol_torque_buffer.py +++ b/openpilot/selfdrive/controls/tests/test_latcontrol_torque_buffer.py @@ -1,3 +1,4 @@ +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized from openpilot.cereal import log @@ -10,7 +11,7 @@ from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque, from openpilot.selfdrive.car.helpers import convert_to_capnp from openpilot.selfdrive.locationd.helpers import Pose -from openpilot.common.mock.generators import generate_livePose +from openpilot.common.mock.generators import generate_deviceMotion from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces def get_controller(car_name): @@ -24,7 +25,7 @@ def get_controller(car_name): controller = LatControlTorque(CP.as_reader(), CP_SP.as_reader(), CI, DT_CTRL) return controller, VM -class TestLatControlTorqueBuffer: +class TestLatControlTorqueBuffer(OpenpilotTestCase): @parameterized.expand([(TOYOTA.TOYOTA_COROLLA_TSS2,)]) def test_request_buffer_consistency(self, car_name): @@ -34,10 +35,10 @@ class TestLatControlTorqueBuffer: CS = car.CarState.new_message() CS.vEgo = 30 CS.steeringPressed = False - params = log.LiveParametersData.new_message() + params = log.VehicleParameters.new_message() - lp = generate_livePose() - pose = Pose.from_live_pose(lp.livePose) + lp = generate_deviceMotion() + pose = Pose.from_device_motion(lp.deviceMotion) for _ in range(buffer_steps): controller.update(True, CS, VM, params, False, 0.001, pose, False, 0.2) diff --git a/openpilot/selfdrive/controls/tests/test_leads.py b/openpilot/selfdrive/controls/tests/test_leads.py index 1956bb34ec..45257c545b 100644 --- a/openpilot/selfdrive/controls/tests/test_leads.py +++ b/openpilot/selfdrive/controls/tests/test_leads.py @@ -1,10 +1,11 @@ +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from opendbc.car.toyota.values import CAR as TOYOTA from openpilot.selfdrive.test.process_replay import replay_process_with_name -class TestLeads: +class TestLeads(OpenpilotTestCase): def test_radar_fault(self): # if there's no radar-related can traffic, radard should either not respond or respond with an error # this is tightly coupled with underlying car radar_interface implementation, but it's a good sanity check @@ -25,7 +26,7 @@ class TestLeads: msgs = [m for _ in range(3) for m in single_iter_pkg()] out = replay_process_with_name("card", msgs, fingerprint=TOYOTA.TOYOTA_COROLLA_TSS2) - states = [m for m in out if m.which() == "liveTracks"] + states = [m for m in out if m.which() == "radarTracks"] failures = [not state.valid for state in states] assert len(states) == 0 or all(failures) diff --git a/openpilot/selfdrive/controls/tests/test_longcontrol.py b/openpilot/selfdrive/controls/tests/test_longcontrol.py index 210384a3ea..9469545204 100644 --- a/openpilot/selfdrive/controls/tests/test_longcontrol.py +++ b/openpilot/selfdrive/controls/tests/test_longcontrol.py @@ -1,60 +1,44 @@ +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import custom -from opendbc.car.structs import car from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState, long_control_state_trans - - -class TestLongControlStateTransition: +class TestLongControlStateTransition(OpenpilotTestCase): def test_stay_stopped(self): - CP = car.CarParams.new_message() CP_SP = custom.CarParamsSP.new_message() active = True current_state = LongCtrlState.stopping - next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, + next_state = long_control_state_trans(CP_SP, active, current_state, should_stop=True, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, + next_state = long_control_state_trans(CP_SP, active, current_state, should_stop=False, brake_pressed=True, cruise_standstill=False) assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, + next_state = long_control_state_trans(CP_SP, active, current_state, should_stop=False, brake_pressed=False, cruise_standstill=True) assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=1.0, + next_state = long_control_state_trans(CP_SP, active, current_state, should_stop=False, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.pid active = False - next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=1.0, + next_state = long_control_state_trans(CP_SP, active, current_state, should_stop=False, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.off -def test_engage(): - CP = car.CarParams.new_message() - CP_SP = custom.CarParamsSP.new_message() - active = True - current_state = LongCtrlState.off - next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, + def test_engage(self): + CP_SP = custom.CarParamsSP.new_message() + active = True + current_state = LongCtrlState.off + next_state = long_control_state_trans(CP_SP, active, current_state, should_stop=True, brake_pressed=False, cruise_standstill=False) - assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, + assert next_state == LongCtrlState.stopping + next_state = long_control_state_trans(CP_SP, active, current_state, should_stop=False, brake_pressed=True, cruise_standstill=False) - assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, + assert next_state == LongCtrlState.stopping + next_state = long_control_state_trans(CP_SP, active, current_state, should_stop=False, brake_pressed=False, cruise_standstill=True) - assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, + assert next_state == LongCtrlState.stopping + next_state = long_control_state_trans(CP_SP, active, current_state, should_stop=False, brake_pressed=False, cruise_standstill=False) - assert next_state == LongCtrlState.pid - -def test_starting(): - CP = car.CarParams.new_message(startingState=True, vEgoStarting=0.5) - CP_SP = custom.CarParamsSP.new_message() - active = True - current_state = LongCtrlState.starting - next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, - should_stop=False, brake_pressed=False, cruise_standstill=False) - assert next_state == LongCtrlState.starting - next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=1.0, - should_stop=False, brake_pressed=False, cruise_standstill=False) - assert next_state == LongCtrlState.pid + assert next_state == LongCtrlState.pid diff --git a/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py b/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py index 93a57d7f60..011713dfb3 100644 --- a/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py +++ b/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py @@ -1,4 +1,5 @@ import numpy as np +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import messaging from opendbc.car.structs import car from opendbc.car import ACCELERATION_DUE_TO_GRAVITY @@ -7,8 +8,6 @@ from opendbc.car.lateral import get_friction, FRICTION_THRESHOLD from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.locationd.torqued import TorqueEstimator, MIN_BUCKET_POINTS, POINTS_PER_BUCKET, STEER_BUCKET_BOUNDS -np.random.seed(0) - LA_ERR_STD = 1.0 INPUT_NOISE_STD = 0.08 V_EGO = 30.0 @@ -42,7 +41,7 @@ def simulate_straight_road_msgs(est): carControl = messaging.new_message('carControl').carControl carOutput = messaging.new_message('carOutput').carOutput carState = messaging.new_message('carState').carState - livePose = messaging.new_message('livePose').livePose + deviceMotion = messaging.new_message('deviceMotion').deviceMotion carControl.latActive = True carState.vEgo = V_EGO carState.steeringPressed = False @@ -51,23 +50,24 @@ def simulate_straight_road_msgs(est): lat_accels = TORQUE_TUNE.latAccelFactor * steer_torques for t, steer_torque, lat_accel in zip(ts, steer_torques, lat_accels, strict=True): carOutput.actuatorsOutput.torque = float(-steer_torque) - livePose.orientationNED = {'x': float(np.deg2rad(ROLL_BIAS_DEG)), 'valid': True} - livePose.angularVelocityDevice = {'z': float(lat_accel / V_EGO), 'valid': True} - livePose.inputsOK, livePose.sensorsOK, livePose.posenetOK = True, True, True - livePose.timestamp = int(t * 1e9) - for which, msg in (('carControl', carControl), ('carOutput', carOutput), ('carState', carState), ('livePose', livePose)): + deviceMotion.orientationNED = {'x': float(np.deg2rad(ROLL_BIAS_DEG)), 'valid': True} + deviceMotion.angularVelocityDevice = {'z': float(lat_accel / V_EGO), 'valid': True} + deviceMotion.inputsOK, deviceMotion.sensorsOK, deviceMotion.posenetOK = True, True, True + deviceMotion.timestamp = int(t * 1e9) + for which, msg in (('carControl', carControl), ('carOutput', carOutput), ('carState', carState), ('deviceMotion', deviceMotion)): est.handle_log(t, which, msg) -def test_estimated_offset(): - steer_torques, lat_accels = generate_inputs(TORQUE_TUNE_BIASED, la_err_std=LA_ERR_STD, input_noise_std=INPUT_NOISE_STD) - est = get_warmed_up_estimator(steer_torques, lat_accels) - msg = est.get_msg() - # TODO add lataccelfactor and friction check when we have more accurate estimates - assert abs(msg.liveTorqueParameters.latAccelOffsetRaw - TORQUE_TUNE_BIASED.latAccelOffset) < 0.1 +class TestTorquedLatAccelOffset(OpenpilotTestCase): + def test_estimated_offset(self): + steer_torques, lat_accels = generate_inputs(TORQUE_TUNE_BIASED, la_err_std=LA_ERR_STD, input_noise_std=INPUT_NOISE_STD) + est = get_warmed_up_estimator(steer_torques, lat_accels) + msg = est.get_msg() + # TODO add lataccelfactor and friction check when we have more accurate estimates + assert abs(msg.lateralTorqueParameters.latAccelOffsetRaw - TORQUE_TUNE_BIASED.latAccelOffset) < 0.1 -def test_straight_road_roll_bias(): - steer_torques, lat_accels = generate_inputs(TORQUE_TUNE, la_err_std=LA_ERR_STD, input_noise_std=INPUT_NOISE_STD) - est = get_warmed_up_estimator(steer_torques, lat_accels) - simulate_straight_road_msgs(est) - msg = est.get_msg() - assert (msg.liveTorqueParameters.latAccelOffsetRaw < -0.05) and np.isfinite(msg.liveTorqueParameters.latAccelOffsetRaw) + def test_straight_road_roll_bias(self): + steer_torques, lat_accels = generate_inputs(TORQUE_TUNE, la_err_std=LA_ERR_STD, input_noise_std=INPUT_NOISE_STD) + est = get_warmed_up_estimator(steer_torques, lat_accels) + simulate_straight_road_msgs(est) + msg = est.get_msg() + assert (msg.lateralTorqueParameters.latAccelOffsetRaw < -0.05) and np.isfinite(msg.lateralTorqueParameters.latAccelOffsetRaw) diff --git a/openpilot/selfdrive/locationd/calibrationd.py b/openpilot/selfdrive/locationd/calibrationd.py index b9616bf632..ec9a225634 100755 --- a/openpilot/selfdrive/locationd/calibrationd.py +++ b/openpilot/selfdrive/locationd/calibrationd.py @@ -74,15 +74,15 @@ class Calibrator: wide_from_device_euler = WIDE_FROM_DEVICE_EULER_INIT height = HEIGHT_INIT valid_blocks = 0 - self.cal_status = log.LiveCalibrationData.Status.uncalibrated + self.cal_status = log.ExtrinsicsCalibration.Status.uncalibrated if param_put and calibration_params: try: with log.Event.from_bytes(calibration_params) as msg: - rpy_init = np.array(msg.liveCalibration.rpyCalib) - valid_blocks = msg.liveCalibration.validBlocks - wide_from_device_euler = np.array(msg.liveCalibration.wideFromDeviceEuler) - height = np.array(msg.liveCalibration.height) + rpy_init = np.array(msg.extrinsicsCalibration.rpyCalib) + valid_blocks = msg.extrinsicsCalibration.validBlocks + wide_from_device_euler = np.array(msg.extrinsicsCalibration.wideFromDeviceEuler) + height = np.array(msg.extrinsicsCalibration.height) except Exception: cloudlog.exception("Error reading cached CalibrationParams") @@ -149,22 +149,22 @@ class Calibrator: self.calib_spread = np.zeros(3) if self.valid_blocks < INPUTS_NEEDED: - if self.cal_status == log.LiveCalibrationData.Status.recalibrating: - self.cal_status = log.LiveCalibrationData.Status.recalibrating + if self.cal_status == log.ExtrinsicsCalibration.Status.recalibrating: + self.cal_status = log.ExtrinsicsCalibration.Status.recalibrating else: - self.cal_status = log.LiveCalibrationData.Status.uncalibrated + self.cal_status = log.ExtrinsicsCalibration.Status.uncalibrated elif is_calibration_valid(self.rpy): - self.cal_status = log.LiveCalibrationData.Status.calibrated + self.cal_status = log.ExtrinsicsCalibration.Status.calibrated else: - self.cal_status = log.LiveCalibrationData.Status.invalid + self.cal_status = log.ExtrinsicsCalibration.Status.invalid # If spread is too high, assume mounting was changed and reset to last block. # Make the transition smooth. Abrupt transitions are not good for feedback loop through supercombo model. # TODO: add height spread check with smooth transition too spread_too_high = self.calib_spread[1] > MAX_ALLOWED_PITCH_SPREAD or self.calib_spread[2] > MAX_ALLOWED_YAW_SPREAD - if spread_too_high and self.cal_status == log.LiveCalibrationData.Status.calibrated: + if spread_too_high and self.cal_status == log.ExtrinsicsCalibration.Status.calibrated: self.reset(self.rpys[self.block_idx - 1], valid_blocks=1, smooth_from=self.rpy) - self.cal_status = log.LiveCalibrationData.Status.recalibrating + self.cal_status = log.ExtrinsicsCalibration.Status.recalibrating write_this_cycle = (self.idx == 0) and (self.block_idx % (INPUTS_WANTED//5) == 5) if self.param_put and write_this_cycle: @@ -234,35 +234,35 @@ class Calibrator: def get_msg(self, valid: bool) -> capnp.lib.capnp._DynamicStructBuilder: smooth_rpy = self.get_smooth_rpy() - msg = messaging.new_message('liveCalibration') + msg = messaging.new_message('extrinsicsCalibration') msg.valid = valid - liveCalibration = msg.liveCalibration - liveCalibration.validBlocks = self.valid_blocks - liveCalibration.calStatus = self.cal_status - liveCalibration.calPerc = min(100 * (self.valid_blocks * BLOCK_SIZE + self.idx) // (INPUTS_NEEDED * BLOCK_SIZE), 100) - liveCalibration.rpyCalib = smooth_rpy.tolist() - liveCalibration.rpyCalibSpread = self.calib_spread.tolist() - liveCalibration.wideFromDeviceEuler = self.wide_from_device_euler.tolist() - liveCalibration.height = self.height.tolist() + extrinsicsCalibration = msg.extrinsicsCalibration + extrinsicsCalibration.validBlocks = self.valid_blocks + extrinsicsCalibration.calStatus = self.cal_status + extrinsicsCalibration.calPerc = min(100 * (self.valid_blocks * BLOCK_SIZE + self.idx) // (INPUTS_NEEDED * BLOCK_SIZE), 100) + extrinsicsCalibration.rpyCalib = smooth_rpy.tolist() + extrinsicsCalibration.rpyCalibSpread = self.calib_spread.tolist() + extrinsicsCalibration.wideFromDeviceEuler = self.wide_from_device_euler.tolist() + extrinsicsCalibration.height = self.height.tolist() if self.not_car: - liveCalibration.validBlocks = INPUTS_NEEDED - liveCalibration.calStatus = log.LiveCalibrationData.Status.calibrated - liveCalibration.calPerc = 100. - liveCalibration.rpyCalib = [0, 0, 0] - liveCalibration.rpyCalibSpread = self.calib_spread.tolist() + extrinsicsCalibration.validBlocks = INPUTS_NEEDED + extrinsicsCalibration.calStatus = log.ExtrinsicsCalibration.Status.calibrated + extrinsicsCalibration.calPerc = 100. + extrinsicsCalibration.rpyCalib = [0, 0, 0] + extrinsicsCalibration.rpyCalibSpread = self.calib_spread.tolist() return msg def send_data(self, pm: messaging.PubMaster, valid: bool) -> None: - pm.send('liveCalibration', self.get_msg(valid)) + pm.send('extrinsicsCalibration', self.get_msg(valid)) def main() -> NoReturn: config_realtime_process([0, 1, 2, 3], 5) - pm = messaging.PubMaster(['liveCalibration']) + pm = messaging.PubMaster(['extrinsicsCalibration']) sm = messaging.SubMaster(['cameraOdometry', 'carState'], poll='cameraOdometry') params_reader = Params() diff --git a/openpilot/selfdrive/locationd/helpers.py b/openpilot/selfdrive/locationd/helpers.py index fe7930c509..48e1ee25c2 100644 --- a/openpilot/selfdrive/locationd/helpers.py +++ b/openpilot/selfdrive/locationd/helpers.py @@ -1,4 +1,5 @@ import numpy as np +from collections.abc import Sequence from typing import Any from functools import cache @@ -8,29 +9,29 @@ from openpilot.common.transformations.orientation import rot_from_euler, euler_f @cache def fft_next_good_size(n: int) -> int: - """ - smallest composite of 2, 3, 5, 7, 11 that is >= n - inspired by pocketfft - """ - if n <= 6: - return n - best, f2 = 2 * n, 1 - while f2 < best: - f23 = f2 - while f23 < best: - f235 = f23 - while f235 < best: - f2357 = f235 - while f2357 < best: - f235711 = f2357 - while f235711 < best: - best = f235711 if f235711 >= n else best - f235711 *= 11 - f2357 *= 7 - f235 *= 5 - f23 *= 3 - f2 *= 2 - return best + """ + smallest composite of 2, 3, 5, 7, 11 that is >= n + inspired by pocketfft + """ + if n <= 6: + return n + best, f2 = 2 * n, 1 + while f2 < best: + f23 = f2 + while f23 < best: + f235 = f23 + while f235 < best: + f2357 = f235 + while f2357 < best: + f235711 = f2357 + while f235711 < best: + best = f235711 if f235711 >= n else best + f235711 *= 11 + f2357 *= 7 + f235 *= 5 + f23 *= 3 + f2 *= 2 + return best def parabolic_peak_interp(R, max_index): @@ -68,7 +69,8 @@ class NPQueue: class PointBuckets: - def __init__(self, x_bounds: list[tuple[float, float]], min_points: list[float], min_points_total: int, points_per_bucket: int, rowsize: int) -> None: + def __init__(self, x_bounds: list[tuple[float, float]], min_points: Sequence[float], min_points_total: int, points_per_bucket: int, rowsize: int) -> None: + self._rng = np.random.default_rng() self.x_bounds = x_bounds self.buckets = {bounds: NPQueue(maxlen=points_per_bucket, rowsize=rowsize) for bounds in x_bounds} self.buckets_min_points = dict(zip(x_bounds, min_points, strict=True)) @@ -98,9 +100,9 @@ class PointBuckets: points = np.vstack([x.arr for x in self.buckets.values()]) if num_points is None: return points - return points[np.random.choice(np.arange(len(points)), min(len(points), num_points), replace=False)] + return points[self._rng.choice(np.arange(len(points)), min(len(points), num_points), replace=False)] - def load_points(self, points: list[list[float]]) -> None: + def load_points(self, points: Sequence[Sequence[float]]) -> None: for point in points: self.add_point(*point) @@ -128,7 +130,7 @@ class Measurement: self.xyz_std: np.ndarray = xyz_std @classmethod - def from_measurement_xyz(cls, measurement: log.LivePose.XYZMeasurement) -> 'Measurement': + def from_measurement_xyz(cls, measurement: log.DeviceMotion.XYZMeasurement) -> 'Measurement': return cls( xyz=np.array([measurement.x, measurement.y, measurement.z]), xyz_std=np.array([measurement.xStd, measurement.yStd, measurement.zStd]) @@ -143,12 +145,12 @@ class Pose: self.angular_velocity = angular_velocity @classmethod - def from_live_pose(cls, live_pose: log.LivePose) -> 'Pose': + def from_device_motion(cls, device_motion: log.DeviceMotion) -> 'Pose': return Pose( - orientation=Measurement.from_measurement_xyz(live_pose.orientationNED), - velocity=Measurement.from_measurement_xyz(live_pose.velocityDevice), - acceleration=Measurement.from_measurement_xyz(live_pose.accelerationDevice), - angular_velocity=Measurement.from_measurement_xyz(live_pose.angularVelocityDevice) + orientation=Measurement.from_measurement_xyz(device_motion.orientationNED), + velocity=Measurement.from_measurement_xyz(device_motion.velocityDevice), + acceleration=Measurement.from_measurement_xyz(device_motion.accelerationDevice), + angular_velocity=Measurement.from_measurement_xyz(device_motion.angularVelocityDevice) ) @@ -176,8 +178,8 @@ class PoseCalibrator: return Pose(ned_from_calib_euler, velocity_calib, acceleration_calib, angular_velocity_calib) - def feed_live_calib(self, live_calib: log.LiveCalibrationData): - calib_rpy = np.array(live_calib.rpyCalib) + def feed_extrinsics_calibration(self, extrinsics_calibration: log.ExtrinsicsCalibration): + calib_rpy = np.array(extrinsics_calibration.rpyCalib) device_from_calib = rot_from_euler(calib_rpy) self.calib_from_device = device_from_calib.T - self.calib_valid = live_calib.calStatus == log.LiveCalibrationData.Status.calibrated + self.calib_valid = extrinsics_calibration.calStatus == log.ExtrinsicsCalibration.Status.calibrated diff --git a/openpilot/selfdrive/locationd/lagd.py b/openpilot/selfdrive/locationd/lagd.py index da2c53fd11..c80a18db16 100755 --- a/openpilot/selfdrive/locationd/lagd.py +++ b/openpilot/selfdrive/locationd/lagd.py @@ -172,7 +172,7 @@ class BlockAverage: class LateralLagEstimator: - inputs = {"carControl", "carState", "controlsState", "liveCalibration", "livePose"} + inputs = {"carControl", "carState", "controlsState", "extrinsicsCalibration", "deviceMotion"} def __init__(self, CP: car.CarParams, dt: float, block_count: int = BLOCK_NUM, min_valid_block_count: int = BLOCK_NUM_NEEDED, block_size: int = BLOCK_SIZE, @@ -220,39 +220,39 @@ class LateralLagEstimator: self.block_avg = BlockAverage(self.block_count, self.block_size, valid_blocks, initial_lag) def get_msg(self, valid: bool, debug: bool = False) -> capnp._DynamicStructBuilder: - msg = messaging.new_message('liveDelay') + msg = messaging.new_message('lateralDelay') msg.valid = valid - liveDelay = msg.liveDelay + lateralDelay = msg.lateralDelay valid_mean_lag, valid_std, current_mean_lag, current_std = self.block_avg.get() if self.block_avg.valid_blocks >= self.min_valid_block_count and not np.isnan(valid_mean_lag) and not np.isnan(valid_std): if valid_std > MAX_LAG_STD: - liveDelay.status = log.LiveDelayData.Status.invalid + lateralDelay.status = log.LateralDelay.Status.invalid else: - liveDelay.status = log.LiveDelayData.Status.estimated + lateralDelay.status = log.LateralDelay.Status.estimated else: - liveDelay.status = log.LiveDelayData.Status.unestimated + lateralDelay.status = log.LateralDelay.Status.unestimated - if liveDelay.status == log.LiveDelayData.Status.estimated: - liveDelay.lateralDelay = min(MAX_LAG, max(MIN_LAG, valid_mean_lag)) + if lateralDelay.status == log.LateralDelay.Status.estimated: + lateralDelay.lateralDelay = min(MAX_LAG, max(MIN_LAG, valid_mean_lag)) else: - liveDelay.lateralDelay = self.initial_lag + lateralDelay.lateralDelay = self.initial_lag if not np.isnan(current_mean_lag) and not np.isnan(current_std): - liveDelay.lateralDelayEstimate = current_mean_lag - liveDelay.lateralDelayEstimateStd = current_std + lateralDelay.lateralDelayEstimate = current_mean_lag + lateralDelay.lateralDelayEstimateStd = current_std else: - liveDelay.lateralDelayEstimate = self.initial_lag - liveDelay.lateralDelayEstimateStd = 0.0 + lateralDelay.lateralDelayEstimate = self.initial_lag + lateralDelay.lateralDelayEstimateStd = 0.0 - liveDelay.validBlocks = self.block_avg.valid_blocks - liveDelay.calPerc = min(100 * (self.block_avg.valid_blocks * self.block_size + self.block_avg.idx) // + lateralDelay.validBlocks = self.block_avg.valid_blocks + lateralDelay.calPerc = min(100 * (self.block_avg.valid_blocks * self.block_size + self.block_avg.idx) // (self.min_valid_block_count * self.block_size), 100) if debug: - liveDelay.points = self.block_avg.values.flatten().tolist() - liveDelay.version = VERSION + lateralDelay.points = self.block_avg.values.flatten().tolist() + lateralDelay.version = VERSION return msg @@ -265,11 +265,11 @@ class LateralLagEstimator: elif which == "controlsState": self.steering_saturated = getattr(msg.lateralControlState, msg.lateralControlState.which()).saturated self.desired_curvature = msg.desiredCurvature - elif which == "liveCalibration": - self.calibrator.feed_live_calib(msg) - elif which == "livePose": - device_pose = Pose.from_live_pose(msg) - calibrated_pose = self.calibrator.build_calibrated_pose(device_pose) + elif which == "extrinsicsCalibration": + self.calibrator.feed_extrinsics_calibration(msg) + elif which == "deviceMotion": + device_motion = Pose.from_device_motion(msg) + calibrated_pose = self.calibrator.build_calibrated_pose(device_motion) self.yaw_rate = calibrated_pose.angular_velocity.yaw self.yaw_rate_std = calibrated_pose.angular_velocity.yaw_std self.pose_valid = msg.angularVelocityDevice.valid and msg.posenetOK and msg.inputsOK @@ -369,13 +369,13 @@ def retrieve_initial_lag(params: Params, CP: car.CarParams): if last_lag_data is not None: try: with log.Event.from_bytes(last_lag_data) as last_lag_msg, car.CarParams.from_bytes(last_carparams_data) as last_CP: - ld = last_lag_msg.liveDelay + ld = last_lag_msg.lateralDelay if last_CP.carFingerprint != CP.carFingerprint: raise Exception("Car model mismatch") lag, valid_blocks, status, version = ld.lateralDelayEstimate, ld.validBlocks, ld.status, ld.version assert valid_blocks <= BLOCK_NUM, "Invalid number of valid blocks" - assert status != log.LiveDelayData.Status.invalid, "Lag estimate is invalid" + assert status != log.LateralDelay.Status.invalid, "Lag estimate is invalid" assert version == VERSION, f"Lag estimate is from a different version (got {version}, expected {VERSION})" return lag, valid_blocks except Exception as e: @@ -390,13 +390,13 @@ def main(): DEBUG = bool(int(os.getenv("DEBUG", "0"))) - pm = messaging.PubMaster(['liveDelay']) - sm = messaging.SubMaster(['livePose', 'liveCalibration', 'carState', 'controlsState', 'carControl'], poll='livePose') + pm = messaging.PubMaster(['lateralDelay']) + sm = messaging.SubMaster(['deviceMotion', 'extrinsicsCalibration', 'carState', 'controlsState', 'carControl'], poll='deviceMotion') params = Params() CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) - lag_learner = LateralLagEstimator(CP, 1. / SERVICE_LIST['livePose'].frequency) + lag_learner = LateralLagEstimator(CP, 1. / SERVICE_LIST['deviceMotion'].frequency) if (initial_lag_params := retrieve_initial_lag(params, CP)) is not None: lag, valid_blocks = initial_lag_params lag_learner.reset(lag, valid_blocks) @@ -412,12 +412,12 @@ def main(): lag_learner.handle_log(t, which, sm[which]) lag_learner.update_points() - # 4Hz driven by livePose + # 4Hz driven by deviceMotion if sm.frame % 5 == 0: lag_learner.update_estimate() lag_msg = lag_learner.get_msg(sm.all_checks(), DEBUG) lag_msg_dat = lag_msg.to_bytes() - pm.send('liveDelay', lag_msg_dat) + pm.send('lateralDelay', lag_msg_dat) if sm.frame % 1200 == 0: # cache every 60 seconds params.put("LiveDelay", lag_msg_dat) diff --git a/openpilot/selfdrive/locationd/locationd.py b/openpilot/selfdrive/locationd/locationd.py index 8e03995d13..9fbec991d5 100755 --- a/openpilot/selfdrive/locationd/locationd.py +++ b/openpilot/selfdrive/locationd/locationd.py @@ -66,7 +66,7 @@ class LocationEstimator: self.observations = {kind: np.zeros(3, dtype=np.float32) for kind in obs_kinds} self.observation_errors = {kind: np.zeros(3, dtype=np.float32) for kind in obs_kinds} - def reset(self, t: float, x_initial: np.ndarray = PoseKalman.initial_x, P_initial: np.ndarray = PoseKalman.initial_P): + def reset(self, t: float | None, x_initial: np.ndarray = PoseKalman.initial_x, P_initial: np.ndarray = PoseKalman.initial_P): self.kf.init_state(x_initial, covs=P_initial, filter_time=t) def _validate_sensor_source(self, source: log.SensorEventData.SensorSource): @@ -148,7 +148,7 @@ class LocationEstimator: elif which == "carState": self.car_speed = abs(msg.vEgo) - elif which == "liveCalibration": + elif which == "extrinsicsCalibration": # Note that we use this message during calibration if len(msg.rpyCalib) > 0: calib = np.array(msg.rpyCalib) @@ -217,19 +217,19 @@ class LocationEstimator: angular_velocity_device, angular_velocity_device_std = state[States.ANGULAR_VELOCITY], std[States.ANGULAR_VELOCITY] acceleration_device, acceleration_device_std = state[States.ACCELERATION], std[States.ACCELERATION] - msg = messaging.new_message("livePose") + msg = messaging.new_message("deviceMotion") msg.valid = filter_valid - livePose = msg.livePose - init_xyz_measurement(livePose.orientationNED, orientation_ned, orientation_ned_std, filter_valid) - init_xyz_measurement(livePose.velocityDevice, velocity_device, velocity_device_std, filter_valid) - init_xyz_measurement(livePose.angularVelocityDevice, angular_velocity_device, angular_velocity_device_std, filter_valid) - init_xyz_measurement(livePose.accelerationDevice, acceleration_device, acceleration_device_std, filter_valid) + deviceMotion = msg.deviceMotion + init_xyz_measurement(deviceMotion.orientationNED, orientation_ned, orientation_ned_std, filter_valid) + init_xyz_measurement(deviceMotion.velocityDevice, velocity_device, velocity_device_std, filter_valid) + init_xyz_measurement(deviceMotion.angularVelocityDevice, angular_velocity_device, angular_velocity_device_std, filter_valid) + init_xyz_measurement(deviceMotion.accelerationDevice, acceleration_device, acceleration_device_std, filter_valid) if self.debug: - livePose.debugFilterState.value = state.tolist() - livePose.debugFilterState.std = std.tolist() - livePose.debugFilterState.valid = filter_valid - livePose.debugFilterState.observations = [ + deviceMotion.debugFilterState.value = state.tolist() + deviceMotion.debugFilterState.std = std.tolist() + deviceMotion.debugFilterState.valid = filter_valid + deviceMotion.debugFilterState.observations = [ {'kind': k, 'value': self.observations[k].tolist(), 'error': self.observation_errors[k].tolist()} for k in self.observations.keys() ] @@ -238,10 +238,10 @@ class LocationEstimator: new_mean = np.mean(self.posenet_stds[POSENET_STD_HIST_HALF:]) std_spike = (new_mean / old_mean) > 4.0 and new_mean > 7.0 - livePose.inputsOK = inputs_valid - livePose.posenetOK = not std_spike or self.car_speed <= 5.0 - livePose.sensorsOK = sensors_valid - livePose.timestamp = int(np.nan_to_num(self.kf.t) * 1e9) + deviceMotion.inputsOK = inputs_valid + deviceMotion.posenetOK = not std_spike or self.car_speed <= 5.0 + deviceMotion.sensorsOK = sensors_valid + deviceMotion.timestamp = int(np.nan_to_num(self.kf.t) * 1e9) return msg @@ -267,8 +267,8 @@ def main(): DEBUG = bool(int(os.getenv("DEBUG", "0"))) SIMULATION = bool(int(os.getenv("SIMULATION", "0"))) - pm = messaging.PubMaster(['livePose']) - sm = messaging.SubMaster(['carState', 'liveCalibration', 'cameraOdometry'], poll='cameraOdometry') + pm = messaging.PubMaster(['deviceMotion']) + sm = messaging.SubMaster(['carState', 'extrinsicsCalibration', 'cameraOdometry'], poll='cameraOdometry') # separate sensor sockets for efficiency sensor_sockets = [messaging.sub_sock(which, timeout=20) for which in ['accelerometer', 'gyroscope']] sensor_alive, sensor_valid, sensor_recv_time = defaultdict(bool), defaultdict(bool), defaultdict(float) @@ -288,7 +288,7 @@ def main(): initial_pose_data = params.get("LocationFilterInitialState") if initial_pose_data is not None: with log.Event.from_bytes(initial_pose_data) as lp_msg: - filter_state = lp_msg.livePose.debugFilterState + filter_state = lp_msg.deviceMotion.debugFilterState x_initial = np.array(filter_state.value, dtype=np.float64) if len(filter_state.value) != 0 else PoseKalman.initial_x P_initial = np.diag(np.array(filter_state.std, dtype=np.float64)) if len(filter_state.std) != 0 else PoseKalman.initial_P estimator.reset(None, x_initial, P_initial) @@ -333,7 +333,7 @@ def main(): sensors_valid = sensor_all_checks(acc_msgs, gyro_msgs, sensor_valid, sensor_recv_time, sensor_alive, SIMULATION) msg = estimator.get_msg(sensors_valid, inputs_valid, filter_initialized) - pm.send("livePose", msg) + pm.send("deviceMotion", msg) if __name__ == "__main__": diff --git a/openpilot/selfdrive/locationd/paramsd.py b/openpilot/selfdrive/locationd/paramsd.py index a32773bf0c..760176bc5c 100755 --- a/openpilot/selfdrive/locationd/paramsd.py +++ b/openpilot/selfdrive/locationd/paramsd.py @@ -65,10 +65,10 @@ class VehicleParamsLearner: self.avg_angle_offset = self.angle_offset def handle_log(self, t: float, which: str, msg: capnp._DynamicStructReader): - if which == 'livePose': + if which == 'deviceMotion': t = msg.timestamp * 1e-9 - device_pose = Pose.from_live_pose(msg) - calibrated_pose = self.calibrator.build_calibrated_pose(device_pose) + device_motion = Pose.from_device_motion(msg) + calibrated_pose = self.calibrator.build_calibrated_pose(device_motion) yaw_rate, yaw_rate_std = calibrated_pose.angular_velocity.z, calibrated_pose.angular_velocity.z_std yaw_rate_valid = msg.angularVelocityDevice.valid @@ -79,7 +79,7 @@ class VehicleParamsLearner: yaw_rate, yaw_rate_std = 0.0, np.radians(10.0) self.observed_yaw_rate = yaw_rate - localizer_roll, localizer_roll_std = device_pose.orientation.x, device_pose.orientation.x_std + localizer_roll, localizer_roll_std = device_motion.orientation.x, device_motion.orientation.x_std localizer_roll_std = np.radians(1) if np.isnan(localizer_roll_std) else localizer_roll_std roll_valid = (localizer_roll_std < ROLL_STD_MAX) and (ROLL_MIN < localizer_roll < ROLL_MAX) and msg.sensorsOK if roll_valid: @@ -113,15 +113,15 @@ class VehicleParamsLearner: self.kf.predict_and_observe(t, ObservationKind.STIFFNESS, np.array([[stiffness]])) self.kf.predict_and_observe(t, ObservationKind.STEER_RATIO, np.array([[steer_ratio]])) - elif which == 'liveCalibration': - self.calibrator.feed_live_calib(msg) + elif which == 'extrinsicsCalibration': + self.calibrator.feed_extrinsics_calibration(msg) elif which == 'carState': steering_angle = msg.steeringAngleDeg in_linear_region = abs(steering_angle) < 45 self.observed_speed = msg.vEgo - self.active = self.observed_speed > MIN_ACTIVE_SPEED and in_linear_region + self.active = self.observed_speed > MIN_ACTIVE_SPEED and in_linear_region and msg.gearShifter != car.CarState.GearShifter.reverse if self.active: self.kf.predict_and_observe(t, ObservationKind.STEER_ANGLE, np.array([[np.radians(steering_angle)]])) @@ -136,7 +136,7 @@ class VehicleParamsLearner: x = self.kf.x P = np.sqrt(self.kf.P.diagonal()) if not np.all(np.isfinite(x)): - cloudlog.error("NaN in liveParameters estimate. Resetting to default values") + cloudlog.error("NaN in vehicleParameters estimate. Resetting to default values") self.reset(self.kf.t) x = self.kf.x @@ -156,38 +156,38 @@ class VehicleParamsLearner: self.total_offset_valid = check_valid_with_hysteresis(self.total_offset_valid, self.angle_offset, OFFSET_MAX, OFFSET_LOWERED_MAX) self.roll_valid = check_valid_with_hysteresis(self.roll_valid, self.roll, ROLL_MAX, ROLL_LOWERED_MAX) - msg = messaging.new_message('liveParameters') + msg = messaging.new_message('vehicleParameters') msg.valid = valid - liveParameters = msg.liveParameters - liveParameters.posenetValid = True - liveParameters.sensorValid = sensors_valid - liveParameters.steerRatio = float(x[States.STEER_RATIO].item()) - liveParameters.stiffnessFactor = float(x[States.STIFFNESS].item()) - liveParameters.roll = float(self.roll) - liveParameters.angleOffsetAverageDeg = float(self.avg_angle_offset) - liveParameters.angleOffsetDeg = float(self.angle_offset) - liveParameters.steerRatioValid = self.min_sr <= liveParameters.steerRatio <= self.max_sr - liveParameters.stiffnessFactorValid = 0.2 <= liveParameters.stiffnessFactor <= 5.0 - liveParameters.angleOffsetAverageValid = bool(self.avg_offset_valid) - liveParameters.angleOffsetValid = bool(self.total_offset_valid) - liveParameters.valid = all(( - liveParameters.angleOffsetAverageValid, - liveParameters.angleOffsetValid , + vehicleParameters = msg.vehicleParameters + vehicleParameters.posenetValid = True + vehicleParameters.sensorValid = sensors_valid + vehicleParameters.steerRatio = float(x[States.STEER_RATIO].item()) + vehicleParameters.stiffnessFactor = float(x[States.STIFFNESS].item()) + vehicleParameters.roll = float(self.roll) + vehicleParameters.angleOffsetAverageDeg = float(self.avg_angle_offset) + vehicleParameters.angleOffsetDeg = float(self.angle_offset) + vehicleParameters.steerRatioValid = self.min_sr <= vehicleParameters.steerRatio <= self.max_sr + vehicleParameters.stiffnessFactorValid = 0.2 <= vehicleParameters.stiffnessFactor <= 5.0 + vehicleParameters.angleOffsetAverageValid = bool(self.avg_offset_valid) + vehicleParameters.angleOffsetValid = bool(self.total_offset_valid) + vehicleParameters.valid = all(( + vehicleParameters.angleOffsetAverageValid, + vehicleParameters.angleOffsetValid , self.roll_valid, roll_std < ROLL_STD_MAX, - liveParameters.stiffnessFactorValid, - liveParameters.steerRatioValid, + vehicleParameters.stiffnessFactorValid, + vehicleParameters.steerRatioValid, )) - liveParameters.steerRatioStd = float(P[States.STEER_RATIO].item()) - liveParameters.stiffnessFactorStd = float(P[States.STIFFNESS].item()) - liveParameters.angleOffsetAverageStd = float(P[States.ANGLE_OFFSET].item()) - liveParameters.angleOffsetFastStd = float(P[States.ANGLE_OFFSET_FAST].item()) + vehicleParameters.steerRatioStd = float(P[States.STEER_RATIO].item()) + vehicleParameters.stiffnessFactorStd = float(P[States.STIFFNESS].item()) + vehicleParameters.angleOffsetAverageStd = float(P[States.ANGLE_OFFSET].item()) + vehicleParameters.angleOffsetFastStd = float(P[States.ANGLE_OFFSET_FAST].item()) if debug: - liveParameters.debugFilterState = log.LiveParametersData.FilterState.new_message() - liveParameters.debugFilterState.value = x.tolist() - liveParameters.debugFilterState.std = P.tolist() + vehicleParameters.debugFilterState = log.VehicleParameters.FilterState.new_message() + vehicleParameters.debugFilterState.value = x.tolist() + vehicleParameters.debugFilterState.std = P.tolist() return msg @@ -200,25 +200,6 @@ def check_valid_with_hysteresis(current_valid: bool, val: float, threshold: floa return current_valid -# TODO: Remove this function after few releases (added in 0.9.9) -def migrate_cached_vehicle_params_if_needed(params: Params): - last_parameters_data_old = params.get("LiveParameters") - last_parameters_data = params.get("LiveParametersV2") - if last_parameters_data_old is None or last_parameters_data is not None: - return - - try: - last_parameters_msg = messaging.new_message('liveParameters') - last_parameters_msg.liveParameters.valid = True - last_parameters_msg.liveParameters.steerRatio = last_parameters_data_old['steerRatio'] - last_parameters_msg.liveParameters.stiffnessFactor = last_parameters_data_old['stiffnessFactor'] - last_parameters_msg.liveParameters.angleOffsetAverageDeg = last_parameters_data_old['angleOffsetAverageDeg'] - params.put("LiveParametersV2", last_parameters_msg.to_bytes(), block=True) - except Exception as e: - cloudlog.error(f"Failed to perform parameter migration: {e}") - params.remove("LiveParameters") - - def retrieve_initial_vehicle_params(params: Params, CP: car.CarParams, replay: bool, debug: bool): last_parameters_data = params.get("LiveParametersV2") last_carparams_data = params.get("CarParamsPrevRoute") @@ -229,7 +210,7 @@ def retrieve_initial_vehicle_params(params: Params, CP: car.CarParams, replay: b if last_parameters_data is not None and last_carparams_data is not None: try: with log.Event.from_bytes(last_parameters_data) as last_lp_msg, car.CarParams.from_bytes(last_carparams_data) as last_CP: - lp = last_lp_msg.liveParameters + lp = last_lp_msg.vehicleParameters # Check if car model matches if last_CP.carFingerprint != CP.carFingerprint: raise Exception("Car model mismatch") @@ -267,14 +248,12 @@ def main(): DEBUG = bool(int(os.getenv("DEBUG", "0"))) REPLAY = bool(int(os.getenv("REPLAY", "0"))) - pm = messaging.PubMaster(['liveParameters']) - sm = messaging.SubMaster(['livePose', 'liveCalibration', 'carState'], poll='livePose') + pm = messaging.PubMaster(['vehicleParameters']) + sm = messaging.SubMaster(['deviceMotion', 'extrinsicsCalibration', 'carState'], poll='deviceMotion') params = Params() CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) - migrate_cached_vehicle_params_if_needed(params) - steer_ratio, stiffness_factor, angle_offset_deg, pInitial = retrieve_initial_vehicle_params(params, CP, REPLAY, DEBUG) learner = VehicleParamsLearner(CP, steer_ratio, stiffness_factor, np.radians(angle_offset_deg), pInitial) @@ -286,14 +265,14 @@ def main(): t = sm.logMonoTime[which] * 1e-9 learner.handle_log(t, which, sm[which]) - if sm.updated['livePose']: + if sm.updated['deviceMotion']: msg = learner.get_msg(sm.all_checks(), debug=DEBUG) msg_dat = msg.to_bytes() if sm.frame % 1200 == 0: # once a minute params.put("LiveParametersV2", msg_dat) - pm.send('liveParameters', msg_dat) + pm.send('vehicleParameters', msg_dat) if __name__ == "__main__": diff --git a/openpilot/selfdrive/locationd/test/test_calibrationd.py b/openpilot/selfdrive/locationd/test/test_calibrationd.py index f862b369fe..7afd43b35b 100644 --- a/openpilot/selfdrive/locationd/test/test_calibrationd.py +++ b/openpilot/selfdrive/locationd/test/test_calibrationd.py @@ -2,6 +2,7 @@ import random import numpy as np +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.cereal import log from openpilot.common.params import Params @@ -29,19 +30,19 @@ def process_messages(c, cam_odo_calib, cycles, [0.0, 0.0, HEIGHT_INIT.item()], [cam_odo_height_std, cam_odo_height_std, cam_odo_height_std]) -class TestCalibrationd: +class TestCalibrationd(OpenpilotTestCase): def test_read_saved_params(self): - msg = messaging.new_message('liveCalibration') - msg.liveCalibration.validBlocks = random.randint(1, 10) - msg.liveCalibration.rpyCalib = [random.random() for _ in range(3)] - msg.liveCalibration.height = [random.random() for _ in range(1)] + msg = messaging.new_message('extrinsicsCalibration') + msg.extrinsicsCalibration.validBlocks = random.randint(1, 10) + msg.extrinsicsCalibration.rpyCalib = [random.random() for _ in range(3)] + msg.extrinsicsCalibration.height = [random.random() for _ in range(1)] Params().put("CalibrationParams", msg.to_bytes(), block=True) c = Calibrator(param_put=True) - np.testing.assert_allclose(msg.liveCalibration.rpyCalib, c.rpy) - np.testing.assert_allclose(msg.liveCalibration.height, c.height) - assert msg.liveCalibration.validBlocks == c.valid_blocks + np.testing.assert_allclose(msg.extrinsicsCalibration.rpyCalib, c.rpy) + np.testing.assert_allclose(msg.extrinsicsCalibration.height, c.height) + assert msg.extrinsicsCalibration.validBlocks == c.valid_blocks def test_calibration_basics(self): @@ -91,7 +92,7 @@ class TestCalibrationd: np.testing.assert_allclose(c.rpy, [0.0, 0.0, 0.0], atol=1e-3) process_messages(c, [0.0, MAX_ALLOWED_PITCH_SPREAD*0.9, MAX_ALLOWED_YAW_SPREAD*0.9], BLOCK_SIZE + 10) assert c.valid_blocks == INPUTS_NEEDED + 1 - assert c.cal_status == log.LiveCalibrationData.Status.calibrated + assert c.cal_status == log.ExtrinsicsCalibration.Status.calibrated c = Calibrator(param_put=False) process_messages(c, [0.0, 0.0, 0.0], BLOCK_SIZE * INPUTS_NEEDED) @@ -99,7 +100,7 @@ class TestCalibrationd: np.testing.assert_allclose(c.rpy, [0.0, 0.0, 0.0]) process_messages(c, [0.0, MAX_ALLOWED_PITCH_SPREAD*1.1, 0.0], BLOCK_SIZE + 10) assert c.valid_blocks == 1 - assert c.cal_status == log.LiveCalibrationData.Status.recalibrating + assert c.cal_status == log.ExtrinsicsCalibration.Status.recalibrating np.testing.assert_allclose(c.rpy, [0.0, MAX_ALLOWED_PITCH_SPREAD*1.1, 0.0], atol=1e-2) c = Calibrator(param_put=False) @@ -108,5 +109,5 @@ class TestCalibrationd: np.testing.assert_allclose(c.rpy, [0.0, 0.0, 0.0]) process_messages(c, [0.0, 0.0, MAX_ALLOWED_YAW_SPREAD*1.1], BLOCK_SIZE + 10) assert c.valid_blocks == 1 - assert c.cal_status == log.LiveCalibrationData.Status.recalibrating + assert c.cal_status == log.ExtrinsicsCalibration.Status.recalibrating np.testing.assert_allclose(c.rpy, [0.0, 0.0, MAX_ALLOWED_YAW_SPREAD*1.1], atol=1e-2) diff --git a/openpilot/selfdrive/locationd/test/test_lagd.py b/openpilot/selfdrive/locationd/test/test_lagd.py index 128c19332f..0acc3e646f 100644 --- a/openpilot/selfdrive/locationd/test/test_lagd.py +++ b/openpilot/selfdrive/locationd/test/test_lagd.py @@ -1,8 +1,10 @@ import random import numpy as np import time -import pytest +import unittest +from functools import cache +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import messaging, log from opendbc.car.structs import car from openpilot.selfdrive.locationd.lagd import LateralLagEstimator, retrieve_initial_lag, masked_normalized_cross_correlation, \ @@ -18,6 +20,12 @@ DT = 0.05 LAGD_MIN_LAG_FRAMES, LAGD_MAX_LAG_FRAMES = int(round(MIN_LAG / DT)), int(round(MAX_LAG / DT)) +@cache +def get_test_car_params(): + lr = migrate(LogReader(TEST_ROUTE), [migrate_carParams]) + return next(m for m in lr if m.which() == "carParams").carParams + + def process_messages(estimator, lag_frames, n_frames, vego=25.0, rejection_threshold=0.0): for i in range(n_frames): t = i * estimator.dt @@ -35,9 +43,9 @@ def process_messages(estimator, lag_frames, n_frames, vego=25.0, rejection_thres (t, "carControl", car.CarControl(latActive=not rejected)), (t, "carState", car.CarState(vEgo=vego, steeringPressed=False)), (t, "controlsState", log.ControlsState(desiredCurvature=desired_cuvature)), - (t, "livePose", log.LivePose(angularVelocityDevice=log.LivePose.XYZMeasurement(z=actual_yr, valid=True), + (t, "deviceMotion", log.DeviceMotion(angularVelocityDevice=log.DeviceMotion.XYZMeasurement(z=actual_yr, valid=True), posenetOK=True, inputsOK=True)), - (t, "liveCalibration", log.LiveCalibrationData(rpyCalib=[0, 0, 0], calStatus=log.LiveCalibrationData.Status.calibrated)), + (t, "extrinsicsCalibration", log.ExtrinsicsCalibration(rpyCalib=[0, 0, 0], calStatus=log.ExtrinsicsCalibration.Status.calibrated)), ] for t, w, m in msgs: estimator.handle_log(t, w, m) @@ -45,17 +53,16 @@ def process_messages(estimator, lag_frames, n_frames, vego=25.0, rejection_thres estimator.update_estimate() -class TestLagd: +class TestLagd(OpenpilotTestCase): def test_read_saved_params(self): params = Params() - lr = migrate(LogReader(TEST_ROUTE), [migrate_carParams]) - CP = next(m for m in lr if m.which() == "carParams").carParams + CP = get_test_car_params() - msg = messaging.new_message('liveDelay') - msg.liveDelay.lateralDelayEstimate = random.random() - msg.liveDelay.validBlocks = random.randint(1, 10) - msg.liveDelay.version = VERSION + msg = messaging.new_message('lateralDelay') + msg.lateralDelay.lateralDelayEstimate = random.random() + msg.lateralDelay.validBlocks = random.randint(1, 10) + msg.lateralDelay.version = VERSION params.put("LiveDelay", msg.to_bytes(), block=True) params.put("CarParamsPrevRoute", CP.as_builder().to_bytes(), block=True) @@ -63,24 +70,24 @@ class TestLagd: assert saved_lag_params is not None lag, valid_blocks = saved_lag_params - assert lag == msg.liveDelay.lateralDelayEstimate - assert valid_blocks == msg.liveDelay.validBlocks + assert lag == msg.lateralDelay.lateralDelayEstimate + assert valid_blocks == msg.lateralDelay.validBlocks def test_read_invalid_saved_params(self, subtests): params = Params() - lr = migrate(LogReader(TEST_ROUTE), [migrate_carParams]) - CP = next(m for m in lr if m.which() == "carParams").carParams + CP = get_test_car_params() for msg_dict in [{'version': 0}, {'status': 'invalid'}, {'validBlocks': 100}]: - with subtests.test(msg=f"liveDelay={msg_dict}"): - msg = messaging.new_message('liveDelay') - msg.liveDelay = msg_dict + with subtests.test(msg=f"lateralDelay={msg_dict}"): + msg = messaging.new_message('lateralDelay') + msg.lateralDelay = msg_dict params.put("LiveDelay", msg.to_bytes(), block=True) params.put("CarParamsPrevRoute", CP.as_builder().to_bytes(), block=True) assert retrieve_initial_lag(params, CP) is None def test_ncc(self): + rng = np.random.default_rng() lag_frames = random.randint(1, 19) desired_sig = np.sin(np.arange(0.0, 10.0, 0.1)) @@ -91,15 +98,15 @@ class TestLagd: assert np.argmax(corr) == lag_frames # add some noise - desired_sig += np.random.normal(0, 0.05, len(desired_sig)) - actual_sig += np.random.normal(0, 0.05, len(actual_sig)) + desired_sig += rng.normal(0, 0.05, len(desired_sig)) + actual_sig += rng.normal(0, 0.05, len(actual_sig)) corr = masked_normalized_cross_correlation(desired_sig, actual_sig, mask, 200)[len(desired_sig) - 1:len(desired_sig) + 20] assert np.argmax(corr) in range(lag_frames - MAX_ERR_FRAMES, lag_frames + MAX_ERR_FRAMES + 1) # mask out 40% of the values, and make them noise - mask = np.random.choice([True, False], size=len(desired_sig), p=[0.6, 0.4]) - desired_sig[~mask] = np.random.normal(0, 1, size=np.sum(~mask)) - actual_sig[~mask] = np.random.normal(0, 1, size=np.sum(~mask)) + mask = rng.choice([True, False], size=len(desired_sig), p=[0.6, 0.4]) + desired_sig[~mask] = rng.normal(0, 1, size=np.sum(~mask)) + actual_sig[~mask] = rng.normal(0, 1, size=np.sum(~mask)) corr = masked_normalized_cross_correlation(desired_sig, actual_sig, mask, 200)[len(desired_sig) - 1:len(desired_sig) + 20] assert np.argmax(corr) in range(lag_frames - MAX_ERR_FRAMES, lag_frames + MAX_ERR_FRAMES + 1) @@ -107,11 +114,11 @@ class TestLagd: mocked_CP = car.CarParams(steerActuatorDelay=0.5) estimator = LateralLagEstimator(mocked_CP, DT) msg = estimator.get_msg(True) - assert msg.liveDelay.status == 'unestimated' - assert np.allclose(msg.liveDelay.lateralDelay, estimator.initial_lag) - assert np.allclose(msg.liveDelay.lateralDelayEstimate, estimator.initial_lag) - assert msg.liveDelay.validBlocks == 0 - assert msg.liveDelay.calPerc == 0 + assert msg.lateralDelay.status == 'unestimated' + assert np.allclose(msg.lateralDelay.lateralDelay, estimator.initial_lag) + assert np.allclose(msg.lateralDelay.lateralDelayEstimate, estimator.initial_lag) + assert msg.lateralDelay.validBlocks == 0 + assert msg.lateralDelay.calPerc == 0 def test_estimator_basics(self, subtests): for lag_frames in range(LAGD_MIN_LAG_FRAMES, LAGD_MAX_LAG_FRAMES - 1): @@ -120,23 +127,23 @@ class TestLagd: estimator = LateralLagEstimator(mocked_CP, DT, min_recovery_buffer_sec=0.0, min_yr=0.0) process_messages(estimator, lag_frames, int(MIN_OKAY_WINDOW_SEC / DT) + BLOCK_NUM_NEEDED * BLOCK_SIZE) msg = estimator.get_msg(True) - assert msg.liveDelay.status == 'estimated' - assert np.allclose(msg.liveDelay.lateralDelay, lag_frames * DT, atol=0.01) - assert np.allclose(msg.liveDelay.lateralDelayEstimate, lag_frames * DT, atol=0.01) - assert np.allclose(msg.liveDelay.lateralDelayEstimateStd, 0.0, atol=0.01) - assert msg.liveDelay.validBlocks == BLOCK_NUM_NEEDED - assert msg.liveDelay.calPerc == 100 + assert msg.lateralDelay.status == 'estimated' + assert np.allclose(msg.lateralDelay.lateralDelay, lag_frames * DT, atol=0.01) + assert np.allclose(msg.lateralDelay.lateralDelayEstimate, lag_frames * DT, atol=0.01) + assert np.allclose(msg.lateralDelay.lateralDelayEstimateStd, 0.0, atol=0.01) + assert msg.lateralDelay.validBlocks == BLOCK_NUM_NEEDED + assert msg.lateralDelay.calPerc == 100 def test_estimator_masking(self): mocked_CP, lag_frames = car.CarParams(steerActuatorDelay=0.5), random.randint(LAGD_MIN_LAG_FRAMES, LAGD_MAX_LAG_FRAMES - 1) estimator = LateralLagEstimator(mocked_CP, DT, min_recovery_buffer_sec=0.0, min_yr=0.0, min_valid_block_count=1) process_messages(estimator, lag_frames, (int(MIN_OKAY_WINDOW_SEC / DT) + BLOCK_SIZE) * 2, rejection_threshold=0.4) msg = estimator.get_msg(True) - assert np.allclose(msg.liveDelay.lateralDelayEstimate, lag_frames * DT, atol=0.01) - assert np.allclose(msg.liveDelay.lateralDelayEstimateStd, 0.0, atol=0.01) - assert msg.liveDelay.calPerc == 100 + assert np.allclose(msg.lateralDelay.lateralDelayEstimate, lag_frames * DT, atol=0.01) + assert np.allclose(msg.lateralDelay.lateralDelayEstimateStd, 0.0, atol=0.01) + assert msg.lateralDelay.calPerc == 100 - @pytest.mark.skipif(PC, reason="only on device") + @unittest.skipIf(PC, "only on device") def test_estimator_performance(self): mocked_CP = car.CarParams(steerActuatorDelay=0.5) estimator = LateralLagEstimator(mocked_CP, DT) diff --git a/openpilot/selfdrive/locationd/test/test_locationd_scenarios.py b/openpilot/selfdrive/locationd/test/test_locationd_scenarios.py index 69f2ca2821..01fc3f1177 100644 --- a/openpilot/selfdrive/locationd/test/test_locationd_scenarios.py +++ b/openpilot/selfdrive/locationd/test/test_locationd_scenarios.py @@ -1,7 +1,11 @@ +import fcntl import numpy as np +import os +import tempfile from collections import defaultdict from enum import Enum +from openpilot.common.test import OpenpilotTestCase from openpilot.tools.lib.logreader import LogReader from openpilot.selfdrive.locationd.lagd import masked_symmetric_moving_average from openpilot.selfdrive.test.process_replay.migration import migrate_all @@ -37,11 +41,11 @@ def get_select_fields_data(logs): def sig_smooth(signal): return masked_symmetric_moving_average(signal, np.ones_like(signal), 5, 1.0) def get_nested_keys(msg, keys): - val = None + val = msg for key in keys: - val = getattr(msg if val is None else val, key) if isinstance(key, str) else val[key] + val = getattr(val, key) if isinstance(key, str) else val[key] return val - lp = [x.livePose for x in logs if x.which() == 'livePose'] + lp = [x.deviceMotion for x in logs if x.which() == 'deviceMotion'] data = defaultdict(list) for msg in lp: for key, fields in SELECT_COMPARE_FIELDS.items(): @@ -96,7 +100,7 @@ def run_scenarios(scenario, logs): return get_select_fields_data(logs), get_select_fields_data(replayed_logs) -class TestLocationdScenarios: +class TestLocationdScenarios(OpenpilotTestCase): """ Test locationd with different scenarios. In all these scenarios, we expect the following: - locationd kalman filter should never go unstable (we care mostly about yaw_rate, roll, gpsOK, inputsOK, sensorsOK) @@ -105,7 +109,20 @@ class TestLocationdScenarios: @classmethod def setup_class(cls): - cls.logs = migrate_all(LogReader(TEST_ROUTE)) + # xdist can initialize this class in several workers at once. URLFile's + # cache writes are atomic, but cache misses are not locked, so every worker + # otherwise downloads the same route concurrently. + lock_path = os.path.join(tempfile.gettempdir(), "openpilot-locationd-scenarios.lock") + ready_path = f"{lock_path}.ready" + logs = None + with open(lock_path, "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + if not os.path.exists(ready_path): + logs = list(LogReader(TEST_ROUTE)) + open(ready_path, "w").close() + if logs is None: + logs = list(LogReader(TEST_ROUTE)) + cls.logs = migrate_all(logs) def test_base(self): """ diff --git a/openpilot/selfdrive/locationd/test/test_paramsd.py b/openpilot/selfdrive/locationd/test/test_paramsd.py index 28c3f4acf7..515c7a814a 100644 --- a/openpilot/selfdrive/locationd/test/test_paramsd.py +++ b/openpilot/selfdrive/locationd/test/test_paramsd.py @@ -1,8 +1,9 @@ import random import numpy as np +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import messaging -from openpilot.selfdrive.locationd.paramsd import retrieve_initial_vehicle_params, migrate_cached_vehicle_params_if_needed +from openpilot.selfdrive.locationd.paramsd import retrieve_initial_vehicle_params from openpilot.selfdrive.locationd.models.car_kf import CarKalman from openpilot.selfdrive.locationd.test.test_locationd_scenarios import TEST_ROUTE from openpilot.selfdrive.test.process_replay.migration import migrate, migrate_carParams @@ -10,58 +11,29 @@ from openpilot.common.params import Params from openpilot.tools.lib.logreader import LogReader -def get_random_live_parameters(CP): - msg = messaging.new_message("liveParameters") - msg.liveParameters.steerRatio = (random.random() + 0.5) * CP.steerRatio - msg.liveParameters.stiffnessFactor = random.random() - msg.liveParameters.angleOffsetAverageDeg = random.random() - msg.liveParameters.debugFilterState.std = [random.random() for _ in range(CarKalman.P_initial.shape[0])] +def get_random_vehicle_parameters(CP): + msg = messaging.new_message("vehicleParameters") + msg.vehicleParameters.steerRatio = (random.random() + 0.5) * CP.steerRatio + msg.vehicleParameters.stiffnessFactor = random.random() + msg.vehicleParameters.angleOffsetAverageDeg = random.random() + msg.vehicleParameters.debugFilterState.std = [random.random() for _ in range(CarKalman.P_initial.shape[0])] return msg -class TestParamsd: +class TestParamsd(OpenpilotTestCase): def test_read_saved_params(self): params = Params() lr = migrate(LogReader(TEST_ROUTE), [migrate_carParams]) CP = next(m for m in lr if m.which() == "carParams").carParams - msg = get_random_live_parameters(CP) + msg = get_random_vehicle_parameters(CP) params.put("LiveParametersV2", msg.to_bytes(), block=True) params.put("CarParamsPrevRoute", CP.as_builder().to_bytes(), block=True) - migrate_cached_vehicle_params_if_needed(params) # this is not tested here but should not mess anything up or throw an error sr, sf, offset, p_init = retrieve_initial_vehicle_params(params, CP, replay=True, debug=True) - np.testing.assert_allclose(sr, msg.liveParameters.steerRatio) - np.testing.assert_allclose(sf, msg.liveParameters.stiffnessFactor) - np.testing.assert_allclose(offset, msg.liveParameters.angleOffsetAverageDeg) + np.testing.assert_allclose(sr, msg.vehicleParameters.steerRatio) + np.testing.assert_allclose(sf, msg.vehicleParameters.stiffnessFactor) + np.testing.assert_allclose(offset, msg.vehicleParameters.angleOffsetAverageDeg) np.testing.assert_equal(p_init.shape, CarKalman.P_initial.shape) - np.testing.assert_allclose(np.diagonal(p_init), msg.liveParameters.debugFilterState.std) - - # TODO Remove this test after the support for old format is removed - def test_read_saved_params_old_format(self): - params = Params() - - lr = migrate(LogReader(TEST_ROUTE), [migrate_carParams]) - CP = next(m for m in lr if m.which() == "carParams").carParams - - msg = get_random_live_parameters(CP) - params.put("LiveParameters", msg.liveParameters.to_dict(), block=True) - params.put("CarParamsPrevRoute", CP.as_builder().to_bytes(), block=True) - params.remove("LiveParametersV2") - - migrate_cached_vehicle_params_if_needed(params) - sr, sf, offset, _ = retrieve_initial_vehicle_params(params, CP, replay=True, debug=True) - np.testing.assert_allclose(sr, msg.liveParameters.steerRatio) - np.testing.assert_allclose(sf, msg.liveParameters.stiffnessFactor) - np.testing.assert_allclose(offset, msg.liveParameters.angleOffsetAverageDeg) - assert params.get("LiveParametersV2") is not None - - def test_read_saved_params_corrupted_old_format(self): - params = Params() - params.put("LiveParameters", {}, block=True) - params.remove("LiveParametersV2") - - migrate_cached_vehicle_params_if_needed(params) - assert params.get("LiveParameters") is None - assert params.get("LiveParametersV2") is None + np.testing.assert_allclose(np.diagonal(p_init), msg.vehicleParameters.debugFilterState.std) diff --git a/openpilot/selfdrive/locationd/test/test_torqued.py b/openpilot/selfdrive/locationd/test/test_torqued.py index ac8e40fc30..3c5cb29bfc 100644 --- a/openpilot/selfdrive/locationd/test/test_torqued.py +++ b/openpilot/selfdrive/locationd/test/test_torqued.py @@ -1,25 +1,27 @@ from opendbc.car.structs import car +from openpilot.common.test import OpenpilotTestCase from openpilot.selfdrive.locationd.torqued import TorqueEstimator -def test_cal_percent(): - est = TorqueEstimator(car.CarParams()) - msg = est.get_msg() - assert msg.liveTorqueParameters.calPerc == 0 +class TestTorqued(OpenpilotTestCase): + def test_cal_percent(self): + est = TorqueEstimator(car.CarParams()) + msg = est.get_msg() + assert msg.lateralTorqueParameters.calPerc == 0 - for (low, high), min_pts in zip(est.filtered_points.buckets.keys(), - est.filtered_points.buckets_min_points.values(), strict=True): - for _ in range(int(min_pts)): - est.filtered_points.add_point((low + high) / 2.0, 0.0) + for (low, high), min_pts in zip(est.filtered_points.buckets.keys(), + est.filtered_points.buckets_min_points.values(), strict=True): + for _ in range(int(min_pts)): + est.filtered_points.add_point((low + high) / 2.0, 0.0) - # enough bucket points, but not enough total points - msg = est.get_msg() - assert msg.liveTorqueParameters.calPerc == (len(est.filtered_points) / est.min_points_total * 100 + 100) / 2 + # enough bucket points, but not enough total points + msg = est.get_msg() + assert msg.lateralTorqueParameters.calPerc == (len(est.filtered_points) / est.min_points_total * 100 + 100) / 2 - # add enough points to bucket with most capacity - key = list(est.filtered_points.buckets)[0] - for _ in range(est.min_points_total - len(est.filtered_points)): - est.filtered_points.add_point((key[0] + key[1]) / 2.0, 0.0) + # add enough points to bucket with most capacity + key = list(est.filtered_points.buckets)[0] + for _ in range(est.min_points_total - len(est.filtered_points)): + est.filtered_points.add_point((key[0] + key[1]) / 2.0, 0.0) - msg = est.get_msg() - assert msg.liveTorqueParameters.calPerc == 100 + msg = est.get_msg() + assert msg.lateralTorqueParameters.calPerc == 100 diff --git a/openpilot/selfdrive/locationd/torqued.py b/openpilot/selfdrive/locationd/torqued.py index 329d767610..1b5653510c 100755 --- a/openpilot/selfdrive/locationd/torqued.py +++ b/openpilot/selfdrive/locationd/torqued.py @@ -62,14 +62,14 @@ class TorqueEstimator(ParameterEstimator, TorqueEstimatorExt): self.lag = 0.0 self.track_all_points = track_all_points # for offline analysis, without max lateral accel or max steer torque filters if decimated: - self.min_bucket_points = MIN_BUCKET_POINTS / 10 + self.min_bucket_points: list[float] = (MIN_BUCKET_POINTS / 10).tolist() self.min_points_total = MIN_POINTS_TOTAL_QLOG self.fit_points = FIT_POINTS_TOTAL_QLOG self.factor_sanity = FACTOR_SANITY_QLOG self.friction_sanity = FRICTION_SANITY_QLOG else: - self.min_bucket_points = MIN_BUCKET_POINTS + self.min_bucket_points = MIN_BUCKET_POINTS.tolist() self.min_points_total = MIN_POINTS_TOTAL self.fit_points = FIT_POINTS_TOTAL self.factor_sanity = FACTOR_SANITY @@ -110,19 +110,20 @@ class TorqueEstimator(ParameterEstimator, TorqueEstimatorExt): if params_cache is not None and torque_cache is not None: try: with log.Event.from_bytes(torque_cache) as log_evt: - cache_ltp = log_evt.liveTorqueParameters + cache_ltp = log_evt.lateralTorqueParameters with car.CarParams.from_bytes(params_cache) as msg: cache_CP = msg if self.get_restore_key(cache_CP, cache_ltp.version) == self.get_restore_key(CP, VERSION): - if cache_ltp.liveValid: + if cache_ltp.valid: initial_params = { 'latAccelFactor': cache_ltp.latAccelFactorFiltered, 'latAccelOffset': cache_ltp.latAccelOffsetFiltered, 'frictionCoefficient': cache_ltp.frictionCoefficientFiltered } - initial_params['points'] = cache_ltp.points + cached_points: list[list[float]] = [list(point) for point in cache_ltp.points] + initial_params['points'] = cached_points self.decay = cache_ltp.decay - self.filtered_points.load_points(initial_params['points']) + self.filtered_points.load_points(cached_points) cloudlog.info("restored torque params from cache") except Exception: cloudlog.exception("failed to restore cached torque params") @@ -161,7 +162,7 @@ class TorqueEstimator(ParameterEstimator, TorqueEstimatorExt): _, spread = np.matmul(points[:, [0, 2]], slope2rot(slope)).T friction_coeff = np.std(spread) * FRICTION_FACTOR except np.linalg.LinAlgError as e: - cloudlog.exception(f"Error computing live torque params: {e}") + cloudlog.exception(f"Error computing lateral torque parameters: {e}") slope = offset = friction_coeff = np.nan return slope, offset, friction_coeff @@ -183,21 +184,21 @@ class TorqueEstimator(ParameterEstimator, TorqueEstimatorExt): # TODO: check if high aEgo affects resulting lateral accel self.raw_points["vego"].append(msg.vEgo) self.raw_points["steer_override"].append(msg.steeringPressed) - elif which == "liveCalibration": - self.calibrator.feed_live_calib(msg) - elif which == "liveDelay": + elif which == "extrinsicsCalibration": + self.calibrator.feed_extrinsics_calibration(msg) + elif which == "lateralDelay": self.lag = get_lat_delay(self.params, msg.lateralDelay) # calculate lateral accel from past steering torque - elif which == "livePose": + elif which == "deviceMotion": is_valid = msg.angularVelocityDevice.valid and msg.orientationNED.valid and msg.inputsOK and msg.sensorsOK and msg.posenetOK if len(self.raw_points['steer_torque']) == self.hist_len and is_valid: t = msg.timestamp * 1e-9 - device_pose = Pose.from_live_pose(msg) - calibrated_pose = self.calibrator.build_calibrated_pose(device_pose) + device_motion = Pose.from_device_motion(msg) + calibrated_pose = self.calibrator.build_calibrated_pose(device_motion) angular_velocity_calibrated = calibrated_pose.angular_velocity yaw_rate = angular_velocity_calibrated.yaw - roll = device_pose.orientation.roll + roll = device_motion.orientation.roll # check lat active up to now (without lag compensation) lat_active = np.interp(np.arange(t - MIN_ENGAGE_BUFFER, t + self.lag, DT_MDL), self.raw_points['carControl_t'], self.raw_points['lat_active']).astype(bool) @@ -214,40 +215,40 @@ class TorqueEstimator(ParameterEstimator, TorqueEstimatorExt): self.all_torque_points.append([steer, lateral_acc]) def get_msg(self, valid=True, with_points=False): - msg = messaging.new_message('liveTorqueParameters') + msg = messaging.new_message('lateralTorqueParameters') msg.valid = valid - liveTorqueParameters = msg.liveTorqueParameters - liveTorqueParameters.version = VERSION - liveTorqueParameters.useParams = self.use_params + lateralTorqueParameters = msg.lateralTorqueParameters + lateralTorqueParameters.version = VERSION + lateralTorqueParameters.useParams = self.use_params # Calculate raw estimates when possible, only update filters when enough points are gathered if self.filtered_points.is_calculable(): latAccelFactor, latAccelOffset, frictionCoeff = self.estimate_params() - liveTorqueParameters.latAccelFactorRaw = float(latAccelFactor) - liveTorqueParameters.latAccelOffsetRaw = float(latAccelOffset) - liveTorqueParameters.frictionCoefficientRaw = float(frictionCoeff) + lateralTorqueParameters.latAccelFactorRaw = float(latAccelFactor) + lateralTorqueParameters.latAccelOffsetRaw = float(latAccelOffset) + lateralTorqueParameters.frictionCoefficientRaw = float(frictionCoeff) if self.filtered_points.is_valid(): if any(val is None or np.isnan(val) for val in [latAccelFactor, latAccelOffset, frictionCoeff]): - cloudlog.exception("Live torque parameters are invalid.") - liveTorqueParameters.liveValid = False + cloudlog.exception("Lateral torque parameters are invalid.") + lateralTorqueParameters.valid = False self.reset() else: - liveTorqueParameters.liveValid = True + lateralTorqueParameters.valid = True latAccelFactor = np.clip(latAccelFactor, self.min_lataccel_factor, self.max_lataccel_factor) frictionCoeff = np.clip(frictionCoeff, self.min_friction, self.max_friction) self.update_params({'latAccelFactor': latAccelFactor, 'latAccelOffset': latAccelOffset, 'frictionCoefficient': frictionCoeff}) if with_points: - liveTorqueParameters.points = self.filtered_points.get_points()[:, [0, 2]].tolist() + lateralTorqueParameters.points = self.filtered_points.get_points()[:, [0, 2]].tolist() - liveTorqueParameters.latAccelFactorFiltered = float(self.filtered_params['latAccelFactor'].x) - liveTorqueParameters.latAccelOffsetFiltered = float(self.filtered_params['latAccelOffset'].x) - liveTorqueParameters.frictionCoefficientFiltered = float(self.filtered_params['frictionCoefficient'].x) - liveTorqueParameters.totalBucketPoints = len(self.filtered_points) - liveTorqueParameters.calPerc = self.filtered_points.get_valid_percent() - liveTorqueParameters.decay = self.decay - liveTorqueParameters.maxResets = self.resets + lateralTorqueParameters.latAccelFactorFiltered = float(self.filtered_params['latAccelFactor'].x) + lateralTorqueParameters.latAccelOffsetFiltered = float(self.filtered_params['latAccelOffset'].x) + lateralTorqueParameters.frictionCoefficientFiltered = float(self.filtered_params['frictionCoefficient'].x) + lateralTorqueParameters.totalBucketPoints = len(self.filtered_points) + lateralTorqueParameters.calPerc = self.filtered_points.get_valid_percent() + lateralTorqueParameters.decay = self.decay + lateralTorqueParameters.maxResets = self.resets return msg @@ -256,8 +257,8 @@ def main(demo=False): DEBUG = bool(int(os.getenv("DEBUG", "0"))) - pm = messaging.PubMaster(['liveTorqueParameters']) - sm = messaging.SubMaster(['carControl', 'carOutput', 'carState', 'liveCalibration', 'livePose', 'liveDelay'], poll='livePose') + pm = messaging.PubMaster(['lateralTorqueParameters']) + sm = messaging.SubMaster(['carControl', 'carOutput', 'carState', 'extrinsicsCalibration', 'deviceMotion', 'lateralDelay'], poll='deviceMotion') params = Params() estimator = TorqueEstimator(messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams)) @@ -272,9 +273,9 @@ def main(demo=False): TorqueEstimatorExt.update_use_params(estimator) - # 4Hz driven by livePose + # 4Hz driven by deviceMotion if sm.frame % 5 == 0: - pm.send('liveTorqueParameters', estimator.get_msg(valid=sm.all_checks(), with_points=DEBUG)) + pm.send('lateralTorqueParameters', estimator.get_msg(valid=sm.all_checks(), with_points=DEBUG)) # Cache points every 60 seconds while onroad if sm.frame % 240 == 0: diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 5f6281556f..8769ef69a2 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -1,13 +1,13 @@ import glob import json import os -import sys, subprocess +import time from SCons.Script import Action, Value from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_existing_chunks 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 = [ @@ -26,18 +26,7 @@ tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + " def estimate_pickle_max_size(onnx_size): return 1.2 * onnx_size + 10 * 1024 * 1024 # 20% + 10MB is plenty -# get fastest TG config -# probe in subprocess so usbgpu locks gets released on process exit -def probe_devices(): - return set(subprocess.run( - [sys.executable, '-c', 'from tinygrad import Device\nprint("\\n".join(Device.get_available_devices()))'], - capture_output=True, text=True, check=True).stdout.strip().splitlines()) - -available = probe_devices() -if 'CUDA' in available: - tg_backend = 'CUDA' - tg_flags = f'DEV={tg_backend}' -elif 'QCOM' in available: +if arch == 'comma_arm64': tg_backend = 'QCOM' tg_flags = f'DEV={tg_backend} IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' else: @@ -47,18 +36,18 @@ else: tg_devices = { # which device to put jit inputs to at runtime 'openpilot.selfdrive.modeld.modeld': { 'default': {'WARP_DEV': tg_backend, 'QUEUE_DEV': tg_backend}, - 'usbgpu': {'WARP_DEV': tg_backend, 'QUEUE_DEV': 'AMD'} + 'chestnut': {'WARP_DEV': tg_backend, 'QUEUE_DEV': 'AMD'} }, 'openpilot.selfdrive.modeld.dmonitoringmodeld': { 'default': {'DEV': tg_backend} }, } -USBGPU = usbgpu_present() # or release # TODO always build big model on release -if USBGPU: - usbgpu_tg_flags = f'DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV={tg_backend} FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0' +CHESTNUT = chestnut_present() +if CHESTNUT: + chestnut_tg_flags = f'DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV={tg_backend} FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2' # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it - usbgpu_lock = File("models/.usb_gpu.lock").abspath + chestnut_lock = File("models/.chestnut.lock").abspath def write_tg_devices(target, source, env): with open(str(target[0]), "w") as f: @@ -84,28 +73,45 @@ compile_modeld_script = [ model_w, model_h = MEDMODEL_INPUT_SIZE frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ -for usbgpu in [False, True] if USBGPU else [False]: - target_pkl_path = File(modeld_pkl_path(usbgpu)).abspath - # BIG_INTO_SMALL=1 builds the default target from the big model, e.g. to test it without a USB GPU - file_prefix, cmd_flags = ('big_', usbgpu_tg_flags) if usbgpu else ('big_' if os.getenv('BIG_INTO_SMALL') else '', tg_flags) - driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath) - camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in CAMERA_CONFIGS) - cmd = (f'{cmd_flags} {mac_brew_string} 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_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets): - chunk_file(pkl, chunks) - node = lenv.Command( - chunk_targets, - tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(chunk_targets), chunker_file], - [cmd, Action(do_chunk, " [CHUNK] $TARGET")], - ) - 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 + # BIG_INTO_SMALL=1 builds the default target from the big model, e.g. to test it without a chestnut + file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('big_' if os.getenv('BIG_INTO_SMALL') else '', tg_flags) + driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath) + camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in CAMERA_CONFIGS) + # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. + 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(chunk_targets), chunker_file], + actions, + ) + if chestnut: + lenv.SideEffect(chestnut_lock, node) # get model metadata fn = File(f"models/dmonitoring_model").abspath @@ -137,4 +143,5 @@ def tg_compile(flags, model_name): Action(do_chunk, " [CHUNK] $TARGET")], ) -tg_compile(tg_flags, 'dmonitoring_model') +if not os.getenv('SKIP_TINYGRAD_COMPILE'): + tg_compile(tg_flags, 'dmonitoring_model') diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py index 769fb69eb3..2d27a41496 100755 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ b/openpilot/selfdrive/modeld/compile_modeld.py @@ -3,7 +3,6 @@ import argparse import atexit import math import os -import pickle import tempfile import time import shutil @@ -30,22 +29,6 @@ def _patch_tinygrad_fetch_fw(): helpers.fetch_fw = fetch_fw _patch_tinygrad_fetch_fw() -def _patch_tinygrad_buffer_reduce(): - from tinygrad.device import Buffer - def __reduce_ex__(self, protocol): - buf = None - if self._base is not None: - return self.__class__, (self.device, self.size, self.dtype, None, None, None, 0, self.base, self.offset, self.is_allocated()) - if self.device == "NPY": - return self.__class__, (self.device, self.size, self.dtype, self._buf, self.options, None, self.uop_refcount) - if self.is_allocated(): - buf = bytearray(self.nbytes) - self.copyout(memoryview(buf)) - if protocol >= 5: - buf = pickle.PickleBuffer(buf) - return self.__class__, (self.device, self.size, self.dtype, None, self.options, buf, self.uop_refcount) - Buffer.__reduce_ex__ = __reduce_ex__ -_patch_tinygrad_buffer_reduce() from tinygrad.tensor import Tensor from tinygrad.helpers import Context @@ -242,7 +225,7 @@ 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) - np.random.seed(seed) + rng = np.random.default_rng(seed) Tensor.manual_seed(seed) testing = test_val is not None or test_buffers is not None @@ -250,7 +233,7 @@ def compile_jit(jit, make_random_inputs, input_keys, make_queues): for i in range(n_runs): for v in npy.values(): - v[:] = np.random.randn(*v.shape).astype(v.dtype) + v[:] = rng.standard_normal(v.shape).astype(v.dtype) Device.default.synchronize() random_inputs = make_random_inputs() st = time.perf_counter() diff --git a/openpilot/selfdrive/modeld/dmonitoringmodeld.py b/openpilot/selfdrive/modeld/dmonitoringmodeld.py index 554407a223..4010725b89 100755 --- a/openpilot/selfdrive/modeld/dmonitoringmodeld.py +++ b/openpilot/selfdrive/modeld/dmonitoringmodeld.py @@ -8,7 +8,8 @@ import numpy as np from openpilot.cereal import messaging from openpilot.cereal.messaging import PubMaster, SubMaster -from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcClient, VisionBuf from openpilot.common.swaglog import cloudlog from openpilot.common.realtime import config_realtime_process from openpilot.common.transformations.model import dmonitoringmodel_intrinsics @@ -28,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'] @@ -109,8 +110,8 @@ def get_driverstate_packet(model_output, frame_id: int, location_ts: int, exec_t def main(): config_realtime_process(7, 5) - cloudlog.warning("connecting to driver stream") - vipc_client = VisionIpcClient("camerad", VisionStreamType.VISION_STREAM_DRIVER, True) + cloudlog.warning("connecting to cabin stream") + vipc_client = VisionIpcClient("camerad", VisionStreamType.VISION_STREAM_CABIN, True) while not vipc_client.connect(False): time.sleep(0.1) assert vipc_client.is_connected() @@ -119,7 +120,7 @@ def main(): model = ModelState(vipc_client.width, vipc_client.height) cloudlog.warning("models loaded, dmonitoringmodeld starting") - sm = SubMaster(["liveCalibration"]) + sm = SubMaster(["extrinsicsCalibration"]) pm = PubMaster(["driverStateV2"]) calib = np.zeros(model.numpy_inputs['calib'].size, dtype=np.float32) @@ -135,8 +136,8 @@ def main(): model_transform = np.linalg.inv(np.dot(dmonitoringmodel_intrinsics, np.linalg.inv(cam.intrinsics))).astype(np.float32) sm.update(0) - if sm.updated["liveCalibration"]: - calib[:] = np.array(sm["liveCalibration"].rpyCalib) + if sm.updated["extrinsicsCalibration"]: + calib[:] = np.array(sm["extrinsicsCalibration"].rpyCalib) t1 = time.perf_counter() model_output, gpu_execution_time = model.run(buf, calib, model_transform) diff --git a/openpilot/selfdrive/modeld/fill_model_msg.py b/openpilot/selfdrive/modeld/fill_model_msg.py index c4c973bb78..4d0069c878 100644 --- a/openpilot/selfdrive/modeld/fill_model_msg.py +++ b/openpilot/selfdrive/modeld/fill_model_msg.py @@ -175,8 +175,8 @@ def fill_model_msg(msg: capnp._DynamicStructBuilder, net_output_data: dict[str, modelV2.rawPredictions = net_output_data['raw_pred'].tobytes() def fill_pose_msg(msg: capnp._DynamicStructBuilder, net_output_data: dict[str, np.ndarray], - vipc_frame_id: int, vipc_dropped_frames: int, timestamp_eof: int, live_calib_seen: bool) -> None: - msg.valid = live_calib_seen & (vipc_dropped_frames < 1) + vipc_frame_id: int, vipc_dropped_frames: int, timestamp_eof: int, extrinsics_calibration_seen: bool) -> None: + msg.valid = extrinsics_calibration_seen & (vipc_dropped_frames < 1) cameraOdometry = msg.cameraOdometry cameraOdometry.frameId = vipc_frame_id diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 608bc6aa64..84236f3fd0 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -6,18 +6,19 @@ import struct 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 + MODELS_DIR = Path(__file__).resolve().parent / 'models' TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json' -USBGPU_VID = 0xADD1 -USBGPU_PID = 0x0001 -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): @@ -38,22 +39,22 @@ def dump_oob(obj, f): def load_oob(f): opcodes = f.read(struct.unpack(' bool: - for d in Path("/sys/bus/usb/devices").glob("*"): +def chestnut_present() -> bool: + for d in USB_DEVICES_PATH.glob("*"): try: - if int((d / "idVendor").read_text(), 16) == USBGPU_VID and \ - int((d / "idProduct").read_text(), 16) == USBGPU_PID: + 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": return True except Exception: pass return False + +def chestnut_compiled() -> bool: + return Path(get_manifest_path(modeld_pkl_path(chestnut=True))).is_file() diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index bd4e693fa6..f3f639d6d1 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -1,14 +1,22 @@ #!/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 +os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom from tinygrad.tensor import Tensor +from tinygrad.device import Device +import struct +import threading import time import numpy as np import openpilot.cereal.messaging as messaging from openpilot.cereal import log from opendbc.car.structs import car from openpilot.cereal.messaging import PubMaster, SubMaster -from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf +from openpilot.cereal.services import SERVICE_LIST +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcClient, VisionBuf from opendbc.car.car_helpers import get_demo_car_params from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params @@ -18,16 +26,17 @@ from openpilot.common.transformations.camera import DEVICE_CAMERAS from openpilot.system.camerad.cameras.nv12_info import get_nv12_info 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, smooth_value, get_curvature_from_plan +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.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState -from openpilot.common.file_chunker import open_file_chunked, get_manifest_path +from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import usbgpu_present, modeld_pkl_path, get_tg_input_devices, load_oob +from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, modeld_pkl_path, get_tg_input_devices, load_oob 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') @@ -35,16 +44,17 @@ SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') LAT_SMOOTH_SECONDS = 0.0 LONG_SMOOTH_SECONDS = 0.3 MIN_LAT_CONTROL_SPEED = 0.3 +BIG_MODEL_TIMEOUT = 60 def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log.ModelDataV2.Action, lat_action_t: float, long_action_t: float, v_ego: float) -> log.ModelDataV2.Action: if 'action' not in model_output: plan = model_output['plan'][0] - desired_accel, should_stop = get_accel_from_plan(plan[:,Plan.VELOCITY][:,0], - plan[:,Plan.ACCELERATION][:,0], - ModelConstants.T_IDXS, - action_t=long_action_t) + desired_accel = get_accel_from_plan(plan[:,Plan.VELOCITY][:,0], + plan[:,Plan.ACCELERATION][:,0], + ModelConstants.T_IDXS, + action_t=long_action_t) desired_curvature = get_curvature_from_plan(plan[:,Plan.T_FROM_CURRENT_EULER][:,2], plan[:,Plan.ORIENTATION_RATE][:,2], ModelConstants.T_IDXS, @@ -53,7 +63,7 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log. else: desired_accel = model_output['action'][0,1] desired_curvature = model_output['action'][0,0] / (max(1.0, v_ego))**2 - should_stop = (v_ego < 0.3 and desired_accel < 0.1) + stop = should_stop(v_ego, desired_accel) desired_accel = smooth_value(desired_accel, prev_action.desiredAcceleration, LONG_SMOOTH_SECONDS) if v_ego > MIN_LAT_CONTROL_SPEED: desired_curvature = smooth_value(desired_curvature, prev_action.desiredCurvature, LAT_SMOOTH_SECONDS) @@ -62,7 +72,64 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log. return log.ModelDataV2.Action(desiredCurvature=float(desired_curvature), desiredAcceleration=float(desired_accel), - shouldStop=bool(should_stop)) + shouldStop=bool(stop)) + + +class ChestnutState: + # only modeld can access chestnut + def __init__(self, pm: PubMaster, big: bool): + self.pm = pm + self.big = big + self.valid = True + self.sends = 0 + self.metrics = {} + + @cached_property + def power_limit(self) -> int: + smu = Device["AMD"].iface.dev_impl.smu + return smu._send_msg(smu.smu_mod.PPSMC_MSG_GetPptLimit, 0, read_back_arg=True, timeout=100) + + def send(self) -> None: + msg = messaging.new_message('chestnutState') + state = msg.chestnutState + self.sends += 1 + if self.big and "AMD" in Device._opened_devices and self.sends % 100 == 1: + try: + 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_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, + 'powerLimitW': self.power_limit, + 'gpuUsagePercent': metrics.AverageGfxActivity, + 'gpuClockMhz': metrics.AverageGfxclkFrequencyPostDs, + 'fanSpeedRpm': metrics.AvgFanRpm} + self.valid = True + except Exception: + if self.valid: + cloudlog.exception("chestnut state read failed") + self.valid = False + self.metrics.clear() + if self.big: + for k, v in self.metrics.items(): + setattr(state, k, v) + + asm_valid = False + 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] | None: + inputs: dict[str, np.ndarray], after_enqueue: Callable[[], None] | None = None) -> dict[str, np.ndarray]: for key in bufs.keys(): ptr = np.frombuffer(bufs[key].data, dtype=np.uint8).ctypes.data yuv_size = self.frame_buf_params[key][3] @@ -129,7 +196,11 @@ class ModelState(ModelStateBase): outs, = self.run_policy( **{k: self.input_queues[k] for k in POLICY_INPUTS if k in self.input_queues}, warped=warped ) + if after_enqueue is not None: + after_enqueue() model_output = outs.numpy()[0] + if self.chestnut and not np.all(np.isfinite(model_output)): + raise RuntimeError("model output not finite") 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']] @@ -137,16 +208,26 @@ class ModelState(ModelStateBase): outputs_dict['raw_pred'] = model_output.copy() 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} + 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.prev_desire[:] = 0 + self.full_frames.clear() + self._blob_cache.clear() + def main(demo=False): cloudlog.warning("modeld init") - _present = usbgpu_present() - _compiled = os.path.isfile(get_manifest_path(modeld_pkl_path(usbgpu=True))) - USBGPU = _present and _compiled + CHESTNUT = chestnut_present() and chestnut_compiled() + if CHESTNUT: + os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' params = Params() - params.put_bool("UsbGpuPresent", _present) - params.put_bool("UsbGpuCompiled", _compiled) + params.put_bool("ChestnutLoading", CHESTNUT) + params.remove("ChestnutActive") config_realtime_process(7, 54) @@ -154,12 +235,12 @@ def main(demo=False): while True: available_streams = VisionIpcClient.available_streams("camerad", block=False) if available_streams: - use_extra_client = VisionStreamType.VISION_STREAM_WIDE_ROAD in available_streams and VisionStreamType.VISION_STREAM_ROAD in available_streams - main_wide_camera = VisionStreamType.VISION_STREAM_ROAD not in available_streams + use_extra_client = VisionStreamType.VISION_STREAM_WIDE_ROAD in available_streams and VisionStreamType.VISION_STREAM_NARROW_ROAD in available_streams + main_wide_camera = VisionStreamType.VISION_STREAM_NARROW_ROAD not in available_streams break time.sleep(.1) - vipc_client_main_stream = VisionStreamType.VISION_STREAM_WIDE_ROAD if main_wide_camera else VisionStreamType.VISION_STREAM_ROAD + vipc_client_main_stream = VisionStreamType.VISION_STREAM_WIDE_ROAD if main_wide_camera else VisionStreamType.VISION_STREAM_NARROW_ROAD vipc_client_main = VisionIpcClient("camerad", vipc_client_main_stream, True) vipc_client_extra = VisionIpcClient("camerad", VisionStreamType.VISION_STREAM_WIDE_ROAD, False) cloudlog.warning(f"vision stream set up, main_wide_camera: {main_wide_camera}, use_extra_client: {use_extra_client}") @@ -175,15 +256,38 @@ def main(demo=False): st = time.monotonic() cloudlog.warning("loading model") - model = ModelState(vipc_client_main.width, vipc_client_main.height, USBGPU) + model = None + if CHESTNUT: + big_model = None + def load_big(): + nonlocal big_model + try: + m = ModelState(vipc_client_main.width, vipc_client_main.height, True) + m.warmup() + big_model = m + except Exception: + cloudlog.exception("big model load failed") + loader = threading.Thread(target=load_big, daemon=True) + loader.start() + loader.join(BIG_MODEL_TIMEOUT) + model = big_model + params.put_bool("ChestnutActive", model is not None) + + small_model = ModelState(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("ChestnutLoading", False) + assert model is not None cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") # messaging - pm = PubMaster(["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"]) - sm = SubMaster(["deviceState", "carState", "roadCameraState", "liveCalibration", "driverMonitoringState", "carControl", "liveDelay"]) + 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.chestnut) if CHESTNUT else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_RUN_FREQ) @@ -193,7 +297,7 @@ def main(demo=False): model_transform_main = np.zeros((3, 3), dtype=np.float32) model_transform_extra = np.zeros((3, 3), dtype=np.float32) - live_calib_seen = False + extrinsics_calibration_seen = False buf_main, buf_extra = None, None meta_main = FrameMeta() meta_extra = FrameMeta() @@ -210,6 +314,7 @@ def main(demo=False): prev_action = log.ModelDataV2.Action() DH = DesireHelper() + RELC = RoadEdgeLaneChangeController() while True: # Keep receiving frames until we are at least 1 frame ahead of previous extra frame @@ -247,18 +352,19 @@ def main(demo=False): sm.update(0) desire = DH.desire is_rhd = sm["driverMonitoringState"].isRHD - frame_id = sm["roadCameraState"].frameId + frame_id = sm["narrowRoadCameraState"].frameId v_ego = max(sm["carState"].vEgo, 0.) - if sm.frame % 60 == 0: - model.lat_delay = get_lat_delay(params, sm["liveDelay"].lateralDelay) - lat_delay = model.lat_delay + LAT_SMOOTH_SECONDS - if sm.updated["liveCalibration"] and sm.seen['roadCameraState'] and sm.seen['deviceState']: - device_from_calib_euler = np.array(sm["liveCalibration"].rpyCalib, dtype=np.float32) - dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] - model_transform_main = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics if main_wide_camera else dc.fcam.intrinsics, False).astype(np.float32) + model.lat_delay = get_lat_delay(params, sm["lateralDelay"].lateralDelay) + lat_delay = sm["lateralDelay"].lateralDelay + LAT_SMOOTH_SECONDS + if sm.updated["extrinsicsCalibration"] and sm.seen['narrowRoadCameraState'] and sm.seen['deviceState']: + device_from_calib_euler = np.array(sm["extrinsicsCalibration"].rpyCalib, dtype=np.float32) + dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['narrowRoadCameraState'].sensor))] + main_intrinsics = dc.wide_road.intrinsics if main_wide_camera else dc.narrow_road.intrinsics + model_transform_main = get_warp_matrix(device_from_calib_euler, main_intrinsics, False).astype(np.float32) has_wide_camera = use_extra_client or main_wide_camera - model_transform_extra = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics if has_wide_camera else dc.fcam.intrinsics, True).astype(np.float32) - live_calib_seen = True + extra_intrinsics = dc.wide_road.intrinsics if has_wide_camera else dc.narrow_road.intrinsics + model_transform_extra = get_warp_matrix(device_from_calib_euler, extra_intrinsics, True).astype(np.float32) + extrinsics_calibration_seen = True traffic_convention = np.zeros(2) traffic_convention[int(is_rhd)] = 1 @@ -290,7 +396,22 @@ def main(demo=False): } mt1 = time.perf_counter() - model_output = model.run(bufs, transforms, inputs) + try: + send_chestnut = (chestnut_state is not None and + run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0) + model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None) + except Exception: + if not params.get_bool("ChestnutActive"): + raise + # fallback to small model + cloudlog.exception("big model failed, fall 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 @@ -298,13 +419,13 @@ def main(demo=False): modelv2_send = messaging.new_message('modelV2') drivingdata_send = messaging.new_message('drivingModelData') posenet_send = messaging.new_message('cameraOdometry') - mdv2sp_send = messaging.new_message('modelDataV2SP') action = get_action_from_model(model_output, prev_action, lat_action_t, long_action_t, v_ego) prev_action = action 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, live_calib_seen) + frame_drop_ratio, meta_main.timestamp_eof, model_execution_time, extrinsics_calibration_seen) + modelv2_send.modelV2.big = model.chestnut desire_state = modelv2_send.modelV2.meta.desireState l_lane_change_prob = desire_state[log.Desire.laneChangeLeft] @@ -313,17 +434,19 @@ def main(demo=False): 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) mdv2sp_send.modelDataV2SP.laneTurnDirection = DH.lane_turn_direction fill_driving_model_data(drivingdata_send, modelv2_send) - fill_pose_msg(posenet_send, model_output, meta_main.frame_id, vipc_dropped_frames, meta_main.timestamp_eof, live_calib_seen) + fill_pose_msg(posenet_send, model_output, meta_main.frame_id, vipc_dropped_frames, meta_main.timestamp_eof, extrinsics_calibration_seen) pm.send('modelV2', modelv2_send) pm.send('drivingModelData', drivingdata_send) pm.send('cameraOdometry', posenet_send) pm.send('modelDataV2SP', mdv2sp_send) last_vipc_frame_id = meta_main.frame_id - if __name__ == "__main__": try: import argparse diff --git a/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx b/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx index bd3f2c85a6..1e592e2192 100644 --- a/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx +++ b/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3e7b31dfbc0a5234f1baf196513b77fc6af12204b8a8ffe8ee0417e48352f316 -size 7494962 +oid sha256:dd299afabe7a3e0d04cbe2bd97fdb0c93bba8ad6d3cc3663a0e0ededaf243ac2 +size 7497335 diff --git a/openpilot/selfdrive/modeld/parse_model_outputs.py b/openpilot/selfdrive/modeld/parse_model_outputs.py index 26c138b8ec..839c20f7cc 100644 --- a/openpilot/selfdrive/modeld/parse_model_outputs.py +++ b/openpilot/selfdrive/modeld/parse_model_outputs.py @@ -41,7 +41,7 @@ class Parser: raw = outs[name] outs[name] = sigmoid(raw) - def parse_mdn(self, name, outs, in_N=0, out_N=1, out_shape=None): + def parse_mdn(self, name, outs, in_N=0, out_N=1, out_shape=()): if self.check_missing(outs, name): return raw = outs[name] diff --git a/openpilot/selfdrive/monitoring/dmonitoringd.py b/openpilot/selfdrive/monitoring/dmonitoringd.py index 085a13fcb1..caff23baa0 100755 --- a/openpilot/selfdrive/monitoring/dmonitoringd.py +++ b/openpilot/selfdrive/monitoring/dmonitoringd.py @@ -10,7 +10,7 @@ def dmonitoringd_thread(): params = Params() pm = messaging.PubMaster(['driverMonitoringState']) - sm = messaging.SubMaster(['driverStateV2', 'liveCalibration', 'carState', 'selfdriveState', 'modelV2', + sm = messaging.SubMaster(['driverStateV2', 'extrinsicsCalibration', 'carState', 'selfdriveState', 'modelV2', 'carControl'], poll='driverStateV2') DM = DriverMonitoring(rhd_saved=params.get_bool("IsRhdDetected"), always_on=params.get_bool("AlwaysOnDM")) diff --git a/openpilot/selfdrive/monitoring/policy.py b/openpilot/selfdrive/monitoring/policy.py index bfca76ff30..bd698214ac 100644 --- a/openpilot/selfdrive/monitoring/policy.py +++ b/openpilot/selfdrive/monitoring/policy.py @@ -41,7 +41,7 @@ class DRIVER_MONITOR_SETTINGS: # lockout specs self._MAX_ALERT_3 = 2 self._MAX_NO_RESPONSE = 1 - self._LOCKOUT_TIME = int(1800 / DT_DMON) + self._LOCKOUT_TIMES = [int(60 * n_min / DT_DMON) for n_min in [1, 5, 15, 30]] self._TIMEOUT_RECOVERY_FACTOR_MAX = 5. self._TIMEOUT_RECOVERY_FACTOR_MIN = 1.25 @@ -107,17 +107,17 @@ class DriverBlink: self.right = 0. # model output refers to center of undistorted+leveled image -ref_undistorted_cam = DEVICE_CAMERAS[("tici", "ar0231")].dcam -dcam_undistorted_FL = 598.0 -dcam_undistorted_W, dcam_undistorted_H = (ref_undistorted_cam.width, ref_undistorted_cam.height) +ref_undistorted_cam = DEVICE_CAMERAS[("tici", "ar0231")].cabin +cabin_undistorted_FL = 598.0 +cabin_undistorted_W, cabin_undistorted_H = (ref_undistorted_cam.width, ref_undistorted_cam.height) def face_orientation_from_model(orient_model, pos_model, rpy_calib): pitch_model = orient_model[0] yaw_model = orient_model[1] - face_pixel_position = ((pos_model[0]+0.5)*dcam_undistorted_W, (pos_model[1]+0.5)*dcam_undistorted_H) - yaw_focal_angle = atan2(face_pixel_position[0] - dcam_undistorted_W//2, dcam_undistorted_FL) - pitch_focal_angle = atan2(face_pixel_position[1] - dcam_undistorted_H//2, dcam_undistorted_FL) + face_pixel_position = ((pos_model[0]+0.5)*cabin_undistorted_W, (pos_model[1]+0.5)*cabin_undistorted_H) + yaw_focal_angle = atan2(face_pixel_position[0] - cabin_undistorted_W//2, cabin_undistorted_FL) + pitch_focal_angle = atan2(face_pixel_position[1] - cabin_undistorted_H//2, cabin_undistorted_FL) pitch = pitch_model + pitch_focal_angle yaw = -yaw_model + yaw_focal_angle @@ -152,7 +152,10 @@ class DriverMonitoring: self.cnt_since_alert_3 = 0 self.no_response_timeout = int(self.settings._NO_RESPONSE_TIMEOUT / DT_DMON) self.no_response_cnt = 0 - self.lockout_time = 0 + self.lockout_active = Params().get_bool("DriverTooDistracted") + self.lockout_count = Params().get("DriverLockoutCount") or 0 + self.lockout_duration = self.settings._LOCKOUT_TIMES[min(max(self.lockout_count - 1, 0), len(self.settings._LOCKOUT_TIMES) - 1)] + self.lockout_time_elapsed = 0 self.step_change = 0. self.active_policy = MonitoringPolicy.vision self.driver_interacting = False @@ -163,7 +166,6 @@ class DriverMonitoring: self.threshold_alert_2 = 0. self.dcam_uncertain_cnt = 0 self.dcam_reset_cnt = 0 - self.too_distracted = Params().get_bool("DriverTooDistracted") self._reset_awareness() self._set_policy(MonitoringPolicy.vision) @@ -310,16 +312,20 @@ class DriverMonitoring: self.driver_interacting = driver_engaged if self.alert_3_cnt >= self.settings._MAX_ALERT_3 or self.no_response_cnt >= self.settings._MAX_NO_RESPONSE: - self.too_distracted = True + if not self.lockout_active: + self.lockout_count += 1 + self.lockout_duration = self.settings._LOCKOUT_TIMES[min(self.lockout_count - 1, len(self.settings._LOCKOUT_TIMES) - 1)] + Params().put("DriverLockoutCount", self.lockout_count) + self.lockout_active = True - if self.too_distracted: - self.lockout_time += 1 - if self.lockout_time > self.settings._LOCKOUT_TIME: - self.too_distracted = False + if self.lockout_active: + self.lockout_time_elapsed += 1 + if self.lockout_time_elapsed > self.lockout_duration: + self.lockout_active = False self.alert_3_cnt = 0 self.cnt_since_alert_3 = 0 self.no_response_cnt = 0 - self.lockout_time = 0 + self.lockout_time_elapsed = 0 always_on_valid = self.always_on and not wrong_gear if (self.driver_interacting and self.awareness > 0 and self.active_policy == MonitoringPolicy.wheeltouch) or \ @@ -379,8 +385,10 @@ class DriverMonitoring: dat = messaging.new_message('driverMonitoringState', valid=valid) dm = dat.driverMonitoringState - dm.lockout = self.too_distracted - dm.lockoutRecoveryPercent = to_percent(self.lockout_time / self.settings._LOCKOUT_TIME) + dm.lockout = self.lockout_active + dm.lockoutCount = self.lockout_count + if self.lockout_active: + dm.lockoutMinutesRemaining = max(1, round((self.lockout_duration - self.lockout_time_elapsed) * DT_DMON / 60.)) dm.alert3Count = self.alert_3_cnt dm.noResponseCount = self.no_response_cnt dm.noResponseForceDecel = self.alert_level == AlertLevel.three and self.cnt_since_alert_3 >= self.no_response_timeout @@ -433,7 +441,7 @@ class DriverMonitoring: driver_engaged = sm['carState'].steeringPressed or (sm['selfdriveState'].enabled and sm['carState'].gasPressed) brake_disengage_prob = sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs[0] # brake disengage prob in next 2s steering_angle_deg = sm['carState'].steeringAngleDeg - rpyCalib = sm['liveCalibration'].rpyCalib + rpyCalib = sm['extrinsicsCalibration'].rpyCalib self._set_pose_strictness( brake_disengage_prob=brake_disengage_prob, diff --git a/openpilot/selfdrive/monitoring/test_monitoring.py b/openpilot/selfdrive/monitoring/test_monitoring.py index e42e72d645..d9889860f5 100644 --- a/openpilot/selfdrive/monitoring/test_monitoring.py +++ b/openpilot/selfdrive/monitoring/test_monitoring.py @@ -1,5 +1,6 @@ -import pytest +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import log from opendbc.car.structs import car from openpilot.common.realtime import DT_DMON @@ -49,7 +50,7 @@ always_distracted = [msg_DISTRACTED] * int(TEST_TIMESPAN / DT_DMON) always_true = [True] * int(TEST_TIMESPAN / DT_DMON) always_false = [False] * int(TEST_TIMESPAN / DT_DMON) -class TestMonitoring: +class TestMonitoring(OpenpilotTestCase): def _run_seq(self, msgs, interaction, engaged, lowspeed): DM = DriverMonitoring() alert_lvls = [] @@ -86,21 +87,17 @@ class TestMonitoring: # engaged, distracted past red and beyond the no-response window -> unavailability response + lockout def test_distracted_lockout(self): alert_lvls, d_status = self._run_seq(always_distracted, always_false, always_true, always_false) - s = d_status.settings assert alert_lvls[int(DISTRACTED_SECONDS_TO_RED / DT_DMON)] == 3 - assert d_status.alert_3_cnt == 1 - assert d_status.no_response_cnt == s._MAX_NO_RESPONSE - assert d_status.too_distracted - assert d_status.lockout_time > 0 + assert d_status.lockout_active + assert d_status.lockout_time_elapsed > 0 + assert d_status.lockout_count >= 1 # no face -> wheeltouch red, sustained past the no-response timeout -> unavailability response + lockout def test_invisible_lockout(self): _, d_status = self._run_seq(always_no_face, always_false, always_true, always_false) - s = d_status.settings assert d_status.active_policy == log.DriverMonitoringState.MonitoringPolicy.wheeltouch - assert d_status.alert_3_cnt == 1 - assert d_status.no_response_cnt == s._MAX_NO_RESPONSE - assert d_status.too_distracted + assert d_status.lockout_active + assert d_status.lockout_count >= 1 # engaged, no face detected the whole time, no action def test_fully_invisible_driver(self): @@ -246,36 +243,38 @@ def _build_sm(selfdrive_enabled, lat_active, steering_pressed, gas_pressed): cc.latActive = lat_active mv2 = log.ModelDataV2.new_message() mv2.meta.disengagePredictions.brakeDisengageProbs = [0.0] - lc = log.LiveCalibrationData.new_message() + lc = log.ExtrinsicsCalibration.new_message() lc.rpyCalib = [0.0, 0.0, 0.0] return { 'carState': cs, 'selfdriveState': ss, 'carControl': cc, - 'modelV2': mv2, 'liveCalibration': lc, 'driverStateV2': make_msg(False), + 'modelV2': mv2, 'extrinsicsCalibration': lc, 'driverStateV2': make_msg(False), } -@pytest.mark.parametrize("selfdrive_enabled, lat_active, steering, gas, expected_op_engaged, expected_driver_engaged", [ - (False, False, False, False, False, False), # disabled - (True, False, False, False, True, False), # OP enabled - (False, True, False, False, True, False), # MADS lat-only - (True, True, False, False, True, False), # both active - (False, True, False, True, True, False), # MADS lat-only + gas - (True, True, False, True, True, True), # full op + gas: override - (False, True, True, False, True, True), # MADS lat-only + wheel touch: override -]) -def test_run_step_engagement(selfdrive_enabled, lat_active, steering, gas, - expected_op_engaged, expected_driver_engaged): - sm = _build_sm(selfdrive_enabled, lat_active, steering, gas) - dm = DriverMonitoring() - captured = {} - orig = dm._update_events +class TestRunStepEngagement(OpenpilotTestCase): + @parameterized.expand([ + (False, False, False, False, False, False), # disabled + (True, False, False, False, True, False), # OP enabled + (False, True, False, False, True, False), # MADS lat-only + (True, True, False, False, True, False), # both active + (False, True, False, True, True, False), # MADS lat-only + gas + (True, True, False, True, True, True), # full op + gas: override + (False, True, True, False, True, True), # MADS lat-only + wheel touch: override + ], names=["selfdrive_enabled", "lat_active", "steering", "gas", + "expected_op_engaged", "expected_driver_engaged"]) + def test_run_step_engagement(self, selfdrive_enabled, lat_active, steering, gas, + expected_op_engaged, expected_driver_engaged): + sm = _build_sm(selfdrive_enabled, lat_active, steering, gas) + dm = DriverMonitoring() + captured = {} + orig = dm._update_events - def spy(driver_engaged, op_engaged, lowspeed, wrong_gear): - captured['driver_engaged'] = driver_engaged - captured['op_engaged'] = op_engaged - return orig(driver_engaged, op_engaged, lowspeed, wrong_gear) + def spy(driver_engaged, op_engaged, lowspeed, wrong_gear): + captured['driver_engaged'] = driver_engaged + captured['op_engaged'] = op_engaged + return orig(driver_engaged, op_engaged, lowspeed, wrong_gear) - dm._update_events = spy - dm.run_step(sm, demo=False) - assert captured['op_engaged'] == expected_op_engaged - assert captured['driver_engaged'] == expected_driver_engaged + object.__setattr__(dm, '_update_events', spy) + dm.run_step(sm, demo=False) + assert captured['op_engaged'] == expected_op_engaged + assert captured['driver_engaged'] == expected_driver_engaged diff --git a/openpilot/selfdrive/pandad/pandad.cc b/openpilot/selfdrive/pandad/pandad.cc index 250ad5a481..ac87804bc8 100644 --- a/openpilot/selfdrive/pandad/pandad.cc +++ b/openpilot/selfdrive/pandad/pandad.cc @@ -143,8 +143,8 @@ void fill_panda_state(cereal::PandaState::Builder &ps, cereal::PandaState::Panda ps.setSbu1Voltage(health.sbu1_voltage_mV / 1000.0f); ps.setSbu2Voltage(health.sbu2_voltage_mV / 1000.0f); ps.setSoundOutputLevel(health.sound_output_level_pkt); - ps.setControlsAllowedLateral(health.controls_allowed_lateral_pkt); - ps.setControlsAllowedLongitudinal(health.controls_allowed_longitudinal_pkt); + ps.setControlsAllowedLateral(health.controls_allowed_sp_pkt & 1); + ps.setControlsAllowedLongitudinal((health.controls_allowed_sp_pkt >> 1) & 1); } void fill_panda_can_state(cereal::PandaState::PandaCanState::Builder &cs, const can_health_t &can_health) { @@ -292,9 +292,9 @@ void process_panda_state(Panda *panda, PubMaster *pm, bool engaged, bool engaged void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control, bool is_onroad) { static Params params; - static SubMaster sm({"deviceState", "driverCameraState"}); + static SubMaster sm({"deviceState", "cabinCameraState"}); - static uint64_t last_driver_camera_t = 0; + static uint64_t last_cabin_camera_t = 0; static uint16_t prev_fan_speed = 999; static int ir_pwr = 0; static int prev_ir_pwr = 999; @@ -318,20 +318,20 @@ void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control, } } - if (sm.updated("driverCameraState")) { - auto event = sm["driverCameraState"]; - int cur_integ_lines = event.getDriverCameraState().getIntegLines(); + if (sm.updated("cabinCameraState")) { + auto event = sm["cabinCameraState"]; + int cur_integ_lines = event.getCabinCameraState().getIntegLines(); // reset the filter when camerad restarts - if (event.getDriverCameraState().getFrameId() < prev_frame_id) { + if (event.getCabinCameraState().getFrameId() < prev_frame_id) { integ_lines_filter.reset(0); integ_lines_filter_driver_view.reset(0); driver_view = params.getBool("IsDriverViewEnabled"); } - prev_frame_id = event.getDriverCameraState().getFrameId(); + prev_frame_id = event.getCabinCameraState().getFrameId(); cur_integ_lines = (driver_view ? integ_lines_filter_driver_view : integ_lines_filter).update(cur_integ_lines); - last_driver_camera_t = event.getLogMonoTime(); + last_cabin_camera_t = event.getLogMonoTime(); if (cur_integ_lines <= CUTOFF_IL) { ir_pwr = 0; @@ -343,7 +343,7 @@ void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control, } // Disable IR on input timeout - if (nanos_since_boot() - last_driver_camera_t > 1e9) { + if (nanos_since_boot() - last_cabin_camera_t > 1e9) { ir_pwr = 0; } diff --git a/openpilot/selfdrive/pandad/spi.cc b/openpilot/selfdrive/pandad/spi.cc index f54c26e506..369032a533 100644 --- a/openpilot/selfdrive/pandad/spi.cc +++ b/openpilot/selfdrive/pandad/spi.cc @@ -29,6 +29,12 @@ enum SpiError { const unsigned int SPI_ACK_TIMEOUT = 500; // milliseconds const std::string SPI_DEVICE = "/dev/spidev0.0"; +// TODO: fix SPI turnaround synchronization at the protocol level. +static uint64_t spi_last_bus_activity_ns = 0; // protected by hw_lock + +static void wait_for_spi_turnaround(uint64_t start_ns) { + while ((nanos_since_boot() - start_ns) < 400000) {} +} class LockEx { public: @@ -319,6 +325,8 @@ int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx assert(tx_len < SPI_BUF_SIZE); assert(max_rx_len < SPI_BUF_SIZE); + wait_for_spi_turnaround(spi_last_bus_activity_ns); + xfer_count++; header = { .sync = SPI_SYNC, @@ -347,6 +355,7 @@ int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx if (ret < 0) { goto fail; } + wait_for_spi_turnaround(nanos_since_boot()); // Send data if (tx_data != NULL) { @@ -389,6 +398,7 @@ int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx memcpy(rx_data, rx_buf + 3, rx_data_len); } + spi_last_bus_activity_ns = nanos_since_boot(); return rx_data_len; fail: @@ -403,6 +413,7 @@ fail: } } + spi_last_bus_activity_ns = nanos_since_boot(); if (ret >= 0) ret = -1; return ret; } diff --git a/openpilot/selfdrive/pandad/tests/test_pandad.py b/openpilot/selfdrive/pandad/tests/test_pandad.py old mode 100644 new mode 100755 index e7a7107dcd..891508f2d7 --- a/openpilot/selfdrive/pandad/tests/test_pandad.py +++ b/openpilot/selfdrive/pandad/tests/test_pandad.py @@ -1,20 +1,30 @@ -import os -import pytest -import time +#!/usr/bin/env python3 +import os +import time +import unittest + +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.cereal import log from openpilot.common.gpio import gpio_set, gpio_init from panda import Panda, PandaDFU from openpilot.system.manager.process_config import managed_processes from openpilot.common.hardware import HARDWARE -from openpilot.common.hardware.tici.pins import GPIO +from openpilot.common.hardware.comma.pins import GPIO HERE = os.path.dirname(os.path.realpath(__file__)) -@pytest.mark.tici -class TestPandad: +class TestPandad(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True + + def setUp(self): + super().setUp() + managed_processes['pandad'].stop() + if not Panda.list(): + self._run_test() + def teardown_method(self): managed_processes['pandad'].stop() @@ -60,7 +70,7 @@ class TestPandad: def test_in_reset(self): gpio_init(GPIO.STM_RST_N, True) - gpio_set(GPIO.STM_RST_N, 1) + gpio_set(GPIO.STM_RST_N, True) assert not Panda.list() self._run_test() @@ -79,3 +89,7 @@ class TestPandad: assert not PandaDFU.list() self._run_test() + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/selfdrive/pandad/tests/test_pandad_canprotocol.cc b/openpilot/selfdrive/pandad/tests/test_pandad_canprotocol.cc index 83339a4c1c..50bbc51f49 100644 --- a/openpilot/selfdrive/pandad/tests/test_pandad_canprotocol.cc +++ b/openpilot/selfdrive/pandad/tests/test_pandad_canprotocol.cc @@ -1,11 +1,7 @@ -#define CATCH_CONFIG_MAIN -#define CATCH_CONFIG_ENABLE_BENCHMARKING - #include -#include "catch2/catch.hpp" +#include "common/tests/native_test.h" #include "openpilot/cereal/messaging/messaging.h" -#include "common/util.h" #include "selfdrive/pandad/panda.h" struct PandaTest : public Panda { @@ -26,12 +22,9 @@ PandaTest::PandaTest(int can_list_size_, cereal::PandaState::PandaType hw_type_) int data_limit = ((hw_type == cereal::PandaState::PandaType::RED_PANDA) ? std::size(dlc_to_len) : 8); // prepare test data for (int i = 0; i < data_limit; ++i) { - std::random_device rd; - std::independent_bits_engine rbe(rd()); - int data_len = dlc_to_len[i]; std::string bytes(data_len, '\0'); - std::generate(bytes.begin(), bytes.end(), std::ref(rbe)); + for (int j = 0; j < data_len; ++j) bytes[j] = static_cast((i * 31 + j) & 0xff); test_data[data_len] = bytes; } @@ -39,16 +32,15 @@ PandaTest::PandaTest(int can_list_size_, cereal::PandaState::PandaType hw_type_) auto can_list = msg.initEvent().initSendcan(can_list_size); for (uint8_t i = 0; i < can_list_size; ++i) { auto can = can_list[i]; - uint32_t id = util::random_int(0, std::size(dlc_to_len) - 1); + uint32_t id = i % data_limit; const std::string &dat = test_data[dlc_to_len[id]]; can.setAddress(i); - can.setSrc(util::random_int(0, 2)); + can.setSrc(i % 3); can.setDat(kj::ArrayPtr((uint8_t *)dat.data(), dat.size())); total_pakets_size += sizeof(can_header) + dat.size(); } can_data_list = can_list.asReader(); - INFO("test " << can_list_size << " packets, total size " << total_pakets_size); } void PandaTest::test_can_send() { @@ -56,30 +48,29 @@ void PandaTest::test_can_send() { this->pack_can_buffer(can_data_list, [&](uint8_t *chunk, size_t size) { unpacked_data.insert(unpacked_data.end(), chunk, &chunk[size]); }); - REQUIRE(unpacked_data.size() == total_pakets_size); + CHECK(unpacked_data.size() == total_pakets_size); int cnt = 0; - INFO("test can message integrity"); for (int pos = 0, pckt_len = 0; pos < unpacked_data.size(); pos += pckt_len) { can_header header; memcpy(&header, &unpacked_data[pos], sizeof(can_header)); const uint8_t data_len = dlc_to_len[header.data_len_code]; pckt_len = sizeof(can_header) + data_len; - REQUIRE(header.addr == cnt); - REQUIRE(test_data.find(data_len) != test_data.end()); + CHECK(header.addr == cnt); + CHECK(test_data.find(data_len) != test_data.end()); const std::string &dat = test_data[data_len]; - REQUIRE(memcmp(dat.data(), &unpacked_data[pos + sizeof(can_header)], dat.size()) == 0); + CHECK(memcmp(dat.data(), &unpacked_data[pos + sizeof(can_header)], dat.size()) == 0); ++cnt; } - REQUIRE(cnt == can_list_size); + CHECK(cnt == can_list_size); } void PandaTest::test_can_recv(uint32_t rx_chunk_size) { std::vector frames; this->pack_can_buffer(can_data_list, [&](uint8_t *data, uint32_t size) { if (rx_chunk_size == 0) { - REQUIRE(this->unpack_can_buffer(data, size, frames)); + CHECK(this->unpack_can_buffer(data, size, frames)); } else { this->receive_buffer_size = 0; uint32_t pos = 0; @@ -90,46 +81,35 @@ void PandaTest::test_can_recv(uint32_t rx_chunk_size) { this->receive_buffer_size += chunk_size; pos += chunk_size; - REQUIRE(this->unpack_can_buffer(this->receive_buffer, this->receive_buffer_size, frames)); + CHECK(this->unpack_can_buffer(this->receive_buffer, this->receive_buffer_size, frames)); } } }); - REQUIRE(frames.size() == can_list_size); + CHECK(frames.size() == can_list_size); for (int i = 0; i < frames.size(); ++i) { - REQUIRE(frames[i].address == i); - REQUIRE(test_data.find(frames[i].dat.size()) != test_data.end()); + CHECK(frames[i].address == i); + CHECK(test_data.find(frames[i].dat.size()) != test_data.end()); const std::string &dat = test_data[frames[i].dat.size()]; - REQUIRE(memcmp(dat.data(), frames[i].dat.data(), dat.size()) == 0); + CHECK(memcmp(dat.data(), frames[i].dat.data(), dat.size()) == 0); } } -TEST_CASE("send/recv CAN 2.0 packets") { - auto can_list_size = GENERATE(1, 3, 5, 10, 30, 60, 100, 200); - PandaTest test(can_list_size, cereal::PandaState::PandaType::DOS); +void test_can_protocol() { + for (auto hw_type : {cereal::PandaState::PandaType::DOS, cereal::PandaState::PandaType::RED_PANDA}) { + for (int can_list_size : {1, 3, 5, 10, 30, 60, 100, 200}) { + PandaTest send_test(can_list_size, hw_type); + send_test.test_can_send(); - SECTION("can_send") { - test.test_can_send(); - } - SECTION("can_receive") { - test.test_can_recv(); - } - SECTION("chunked_can_receive") { - test.test_can_recv(0x40); + PandaTest receive_test(can_list_size, hw_type); + receive_test.test_can_recv(); + + PandaTest chunked_receive_test(can_list_size, hw_type); + chunked_receive_test.test_can_recv(0x40); + } } } -TEST_CASE("send/recv CAN FD packets") { - auto can_list_size = GENERATE(1, 3, 5, 10, 30, 60, 100, 200); - PandaTest test(can_list_size, cereal::PandaState::PandaType::RED_PANDA); - - SECTION("can_send") { - test.test_can_send(); - } - SECTION("can_receive") { - test.test_can_recv(); - } - SECTION("chunked_can_receive") { - test.test_can_recv(0x40); - } +int main() { + return run_native_test(test_can_protocol); } diff --git a/openpilot/selfdrive/pandad/tests/test_pandad_loopback.py b/openpilot/selfdrive/pandad/tests/test_pandad_loopback.py old mode 100644 new mode 100755 index 0d0aa80046..ad35018f5f --- a/openpilot/selfdrive/pandad/tests/test_pandad_loopback.py +++ b/openpilot/selfdrive/pandad/tests/test_pandad_loopback.py @@ -1,11 +1,14 @@ +#!/usr/bin/env python3 + import os import copy import random import time -import pytest +import unittest from collections import defaultdict from pprint import pprint +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.cereal import log from opendbc.car.structs import car @@ -69,8 +72,8 @@ def send_random_can_messages(sendcan, count): return sent_msgs -@pytest.mark.tici -class TestBoarddLoopback: +class TestBoarddLoopback(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True @classmethod def setup_class(cls): os.environ['STARTED'] = '1' @@ -114,3 +117,7 @@ class TestBoarddLoopback: pprint(sm['pandaStates']) # may drop messages due to RX buffer overflow for bus in sent_loopback.keys(): assert not len(sent_loopback[bus]), f"loop {i}: bus {bus} missing {len(sent_loopback[bus])} out of {sent_total[bus]} messages" + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/selfdrive/pandad/tests/test_pandad_spi.py b/openpilot/selfdrive/pandad/tests/test_pandad_spi.py old mode 100644 new mode 100755 index 02f5accfd6..8ca39445a2 --- a/openpilot/selfdrive/pandad/tests/test_pandad_spi.py +++ b/openpilot/selfdrive/pandad/tests/test_pandad_spi.py @@ -1,9 +1,12 @@ +#!/usr/bin/env python3 + import os import time +import unittest import numpy as np -import pytest import random +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.cereal.services import SERVICE_LIST from openpilot.common.timeout import Timeout @@ -12,8 +15,8 @@ from openpilot.selfdrive.pandad.tests.test_pandad_loopback import setup_pandad, JUNGLE_SPAM = "JUNGLE_SPAM" in os.environ -@pytest.mark.tici -class TestBoarddSpi: +class TestBoarddSpi(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True @classmethod def setup_class(cls): os.environ['STARTED'] = '1' @@ -38,10 +41,10 @@ class TestBoarddSpi: total_recv_count = 0 total_sent_count = 0 - sent_msgs = {bus: list() for bus in range(3)} + sent_msgs = {bus: [] for bus in range(3)} st = time.monotonic() - ts = {s: list() for s in socks.keys()} + ts = {s: [] for s in socks.keys()} for _ in range(int(os.getenv("TEST_TIME", "20"))): # send some CAN messages if not JUNGLE_SPAM: @@ -99,9 +102,12 @@ class TestBoarddSpi: edt = 1e3 / SERVICE_LIST[service].frequency assert edt*0.9 < np.mean(dts) < edt*1.1 assert np.max(dts) < edt*8 - assert np.min(dts) < edt assert len(dts) >= ((et-0.5)*SERVICE_LIST[service].frequency*0.8) with subtests.test(msg="CAN traffic"): print(f"Sent {total_sent_count} CAN messages, got {total_recv_count} back. {total_recv_count/(total_sent_count+1e-4):.2%} received") assert total_recv_count > 20 + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/selfdrive/selfdrived/alerts_offroad.json b/openpilot/selfdrive/selfdrived/alerts_offroad.json index c90497e8c1..91a7ec8ab3 100644 --- a/openpilot/selfdrive/selfdrived/alerts_offroad.json +++ b/openpilot/selfdrive/selfdrived/alerts_offroad.json @@ -17,8 +17,8 @@ "severity": 1, "_comment": "Set extra field to the failed reason." }, - "Offroad_NeosUpdate": { - "text": "An update to your device's operating system is downloading in the background. You will be prompted to update when it's ready to install.", + "Offroad_ChestnutBranch": { + "text": "Chestnut detected! Switch to the %1 branch to use chestnut-class models.", "severity": 0 }, "Offroad_UnregisteredHardware": { @@ -26,7 +26,7 @@ "severity": 1 }, "Offroad_CarUnrecognized": { - "text": "sunnypilot was unable to identify your car. Your car is either unsupported or its ECUs are not recognized. Please submit a pull request to add the firmware versions to the proper vehicle. Need help? Join discord.comma.ai.", + "text": "sunnypilot was unable to identify your car. Your car is either unsupported or its ECUs are not recognized. Please select your vehicle manually at https://www.sunnylink.ai/. Need help? Visit https://community.sunnypilot.ai/", "severity": 0 }, "Offroad_Recalibration": { @@ -42,7 +42,7 @@ "severity": 0 }, "Offroad_ExcessiveActuation": { - "text": "Excessive %1 actuation detected on your last drive. Please contact support at https://comma.ai/support and share your device's Dongle ID for troubleshooting.", + "text": "Excessive %1 actuation detected on your last drive. Please visit https://community.sunnypilot.ai/ and share your device's Dongle ID for troubleshooting.", "severity": 1, "_comment": "Set extra field to lateral or longitudinal." }, diff --git a/openpilot/selfdrive/selfdrived/events.py b/openpilot/selfdrive/selfdrived/events.py index d2252e4bd4..8a8271b123 100755 --- a/openpilot/selfdrive/selfdrived/events.py +++ b/openpilot/selfdrive/selfdrived/events.py @@ -7,11 +7,8 @@ from opendbc.car.structs import car import openpilot.cereal.messaging as messaging from openpilot.common.constants import CV from openpilot.common.git import get_short_branch -from openpilot.common.realtime import DT_CTRL, DT_DMON +from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.locationd.calibrationd import MIN_SPEED_FILTER -from openpilot.selfdrive.monitoring.policy import DRIVER_MONITOR_SETTINGS -from openpilot.system.micd import SAMPLE_RATE, SAMPLE_BUFFER -from openpilot.selfdrive.ui.feedback.feedbackd import FEEDBACK_MAX_DURATION from openpilot.common.hardware import HARDWARE from openpilot.sunnypilot.selfdrive.selfdrived.events_base import EventsBase, Priority, ET, Alert, \ @@ -25,7 +22,6 @@ VisualAlert = car.CarControl.HUDControl.VisualAlert AudibleAlert = log.SelfdriveState.AudibleAlert EventName = log.OnroadEvent.EventName -DMON_LOCKOUT_TIME = DRIVER_MONITOR_SETTINGS()._LOCKOUT_TIME # get event name from enum EVENT_NAME = {v: k for k, v in EventName.schema.enumerants.items()} @@ -91,9 +87,9 @@ def below_steer_speed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.S def calibration_incomplete_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: - first_word = 'Recalibrating' if sm['liveCalibration'].calStatus == log.LiveCalibrationData.Status.recalibrating else 'Calibrating' + first_word = 'Recalibrating' if sm['extrinsicsCalibration'].calStatus == log.ExtrinsicsCalibration.Status.recalibrating else 'Calibrating' return Alert( - f"{first_word}: {sm['liveCalibration'].calPerc:.0f}%", + f"{first_word}: {sm['extrinsicsCalibration'].calPerc:.0f}%", f"Drive Above {get_display_speed(MIN_SPEED_FILTER, metric)}", AlertStatus.normal, AlertSize.mid, Priority.LOWEST, VisualAlert.none, AudibleAlert.none, .2) @@ -101,19 +97,11 @@ def calibration_incomplete_alert(CP: car.CarParams, CS: car.CarState, sm: messag def too_distracted_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: if sm['driverMonitoringState'].lockout: - mins_left = max(1, round((100 - sm['driverMonitoringState'].lockoutRecoveryPercent) / 100 * DMON_LOCKOUT_TIME * DT_DMON / 60.)) + mins_left = sm['driverMonitoringState'].lockoutMinutesRemaining return NoEntryAlert("Too Distracted", f"{mins_left} minute{'s' if mins_left != 1 else ''} Left", priority=Priority.HIGH) return NoEntryAlert("Pay Attention to Engage", priority=Priority.HIGH) -def audio_feedback_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: - duration = FEEDBACK_MAX_DURATION - ((sm['audioFeedback'].blockNum + 1) * SAMPLE_BUFFER / SAMPLE_RATE) - return NormalPermanentAlert( - "Recording Audio Feedback", - f"{round(duration)} second{'s' if round(duration) != 1 else ''} remaining. Press again to save early.", - priority=Priority.LOW) - - # *** debug alerts *** def out_of_space_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: @@ -141,13 +129,13 @@ def comm_issue_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaste def camera_malfunction_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: - all_cams = ('roadCameraState', 'driverCameraState', 'wideRoadCameraState') + all_cams = ('narrowRoadCameraState', 'cabinCameraState', 'wideRoadCameraState') bad_cams = [s.replace('State', '') for s in all_cams if s in sm.data.keys() and not sm.all_checks([s, ])] return NormalPermanentAlert("Camera Malfunction", ', '.join(bad_cams)) def calibration_invalid_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: - rpy = sm['liveCalibration'].rpyCalib + rpy = sm['extrinsicsCalibration'].rpyCalib yaw = math.degrees(rpy[2] if len(rpy) == 3 else math.nan) pitch = math.degrees(rpy[1] if len(rpy) == 3 else math.nan) angles = f"Remount Device (Pitch: {pitch:.1f}°, Yaw: {yaw:.1f}°)" @@ -155,16 +143,16 @@ def calibration_invalid_alert(CP: car.CarParams, CS: car.CarState, sm: messaging def paramsd_invalid_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: - if not sm['liveParameters'].angleOffsetValid: - angle_offset_deg = sm['liveParameters'].angleOffsetDeg + if not sm['vehicleParameters'].angleOffsetValid: + angle_offset_deg = sm['vehicleParameters'].angleOffsetDeg title = "Steering misalignment detected" text = f"Angle offset too high (Offset: {angle_offset_deg:.1f}°)" - elif not sm['liveParameters'].steerRatioValid: - steer_ratio = sm['liveParameters'].steerRatio + elif not sm['vehicleParameters'].steerRatioValid: + steer_ratio = sm['vehicleParameters'].steerRatio title = "Steer ratio mismatch" text = f"Steering rack geometry may be off (Ratio: {steer_ratio:.1f})" - elif not sm['liveParameters'].stiffnessFactorValid: - stiffness_factor = sm['liveParameters'].stiffnessFactor + elif not sm['vehicleParameters'].stiffnessFactorValid: + stiffness_factor = sm['vehicleParameters'].stiffnessFactor title = "Abnormal tire stiffness" text = f"Check tires, pressure, or alignment (Factor: {stiffness_factor:.1f})" else: @@ -183,11 +171,6 @@ def low_memory_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaste return NormalPermanentAlert("Low Memory", f"{sm['deviceState'].memoryUsagePercent}% used") -def high_cpu_usage_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: - x = max(sm['deviceState'].cpuUsagePercent, default=0.) - return NormalPermanentAlert("High CPU Usage", f"{x}% used") - - def modeld_lagging_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: return NormalPermanentAlert("Driving Model Lagging", f"{sm['modelV2'].frameDropPerc:.1f}% frames dropped") @@ -229,6 +212,7 @@ def invalid_lkas_setting_alert(CP: car.CarParams, CS: car.CarState, sm: messagin EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { # ********** events with no alerts ********** + EventName.noGps: {}, EventName.stockFcw: {}, EventName.actuatorsApiUnavailable: {}, @@ -245,6 +229,15 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { "Ensure road ahead is clear"), }, + EventName.bigModelLoading: { + ET.NO_ENTRY: NoEntryAlert("Big Model Loading"), + }, + + EventName.bigModelFailed: { + ET.SOFT_DISABLE: soft_disable_alert("Big Model Failed"), + ET.PERMANENT: NormalPermanentAlert("Big Model Failed ", "Restart the car to retry,\nsmall model is still available", duration=20.), + }, + EventName.lateralManeuver: { ET.WARNING: longitudinal_maneuver_alert, ET.PERMANENT: NormalPermanentAlert("Lateral Maneuver Mode"), @@ -458,7 +451,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { # Thrown when the fan is driven at >50% but is not rotating EventName.fanMalfunction: { - ET.PERMANENT: NormalPermanentAlert("Fan Malfunction", "Likely Hardware Issue"), + ET.PERMANENT: NormalPermanentAlert("Fan Malfunction", "Contact comma.ai/support"), }, # Camera is not outputting frames @@ -582,6 +575,10 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { ET.NO_ENTRY: NoEntryAlert("Press Set to Engage"), }, + EventName.carNotReady: { + ET.NO_ENTRY: NoEntryAlert("Car Not Ready"), + }, + EventName.wrongCruiseMode: { ET.USER_DISABLE: EngagementAlert(AudibleAlert.disengage), ET.NO_ENTRY: NoEntryAlert("Adaptive Cruise Disabled"), @@ -609,16 +606,13 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { EventName.sensorDataInvalid: { ET.PERMANENT: Alert( "Sensor Data Invalid", - "Possible Hardware Issue", + "Contact comma.ai/support", AlertStatus.normal, AlertSize.mid, Priority.LOWER, VisualAlert.none, AudibleAlert.none, .2, creation_delay=1.), ET.NO_ENTRY: NoEntryAlert("Sensor Data Invalid"), ET.SOFT_DISABLE: soft_disable_alert("Sensor Data Invalid"), }, - EventName.noGps: { - }, - EventName.tooDistracted: { ET.NO_ENTRY: too_distracted_alert, }, @@ -677,11 +671,6 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { ET.NO_ENTRY: NoEntryAlert("Electronic Stability Control Disabled"), }, - EventName.lowBattery: { - ET.SOFT_DISABLE: soft_disable_alert("Low Battery"), - ET.NO_ENTRY: NoEntryAlert("Low Battery"), - }, - # Different openpilot services communicate between each other at a certain # interval. If communication does not follow the regular schedule this alert # is thrown. This can mean a service crashed, did not broadcast a message for @@ -735,13 +724,6 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { ET.NO_ENTRY: posenet_invalid_alert, }, - # When the localizer detects an acceleration of more than 40 m/s^2 (~4G) we - # alert the driver the device might have fallen from the windshield. - EventName.deviceFalling: { - ET.SOFT_DISABLE: soft_disable_alert("Device Fell Off Mount"), - ET.NO_ENTRY: NoEntryAlert("Device Fell Off Mount"), - }, - EventName.lowMemory: { ET.SOFT_DISABLE: soft_disable_alert("Low Memory: Reboot Your Device"), ET.PERMANENT: low_memory_alert, @@ -764,14 +746,6 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { ET.NO_ENTRY: NoEntryAlert("Controls Mismatch"), }, - # Sometimes the USB stack on the device can get into a bad state - # causing the connection to the panda to be lost - EventName.usbError: { - ET.SOFT_DISABLE: soft_disable_alert("USB Error: Reboot Your Device"), - ET.PERMANENT: NormalPermanentAlert("USB Error: Reboot Your Device"), - ET.NO_ENTRY: NoEntryAlert("USB Error: Reboot Your Device"), - }, - # This alert can be thrown for the following reasons: # - No CAN data received at all # - CAN data is received, but some message are not received at the right frequency @@ -824,7 +798,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { # and this alert is thrown. EventName.relayMalfunction: { ET.IMMEDIATE_DISABLE: ImmediateDisableAlert("Harness Relay Malfunction"), - ET.PERMANENT: NormalPermanentAlert("Harness Relay Malfunction", "Check Hardware"), + ET.PERMANENT: NormalPermanentAlert("Harness Relay Malfunction", "Contact comma.ai/support"), ET.NO_ENTRY: NoEntryAlert("Harness Relay Malfunction"), }, @@ -859,10 +833,6 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { EventName.userBookmark: { ET.PERMANENT: NormalPermanentAlert("Bookmark Saved", duration=1.5), }, - - EventName.audioFeedback: { - ET.PERMANENT: audio_feedback_alert, - }, } diff --git a/openpilot/selfdrive/selfdrived/helpers.py b/openpilot/selfdrive/selfdrived/helpers.py index 3b3c44ecaf..e7dca0f9d5 100644 --- a/openpilot/selfdrive/selfdrived/helpers.py +++ b/openpilot/selfdrive/selfdrived/helpers.py @@ -31,7 +31,7 @@ class ExcessiveActuationCheck: # lateral yaw_rate = calibrated_pose.angular_velocity.yaw - roll = sm['liveParameters'].roll + roll = sm['vehicleParameters'].roll roll_compensated_lateral_accel = (CS.vEgo * yaw_rate) - (math.sin(roll) * ACCELERATION_DUE_TO_GRAVITY) # Prevent false positives after overriding @@ -41,9 +41,9 @@ class ExcessiveActuationCheck: if abs(roll_compensated_lateral_accel) > ISO_LATERAL_ACCEL * 2: excessive_lat_actuation = True - # livePose acceleration can be noisy due to bad mounting or aliased livePose measurements - livepose_valid = abs(CS.aEgo - accel_calibrated) < 2 - self._excessive_counter = self._excessive_counter + 1 if livepose_valid and (excessive_long_actuation or excessive_lat_actuation) else 0 + # deviceMotion acceleration can be noisy due to bad mounting or aliased deviceMotion measurements + device_motion_valid = abs(CS.aEgo - accel_calibrated) < 2 + self._excessive_counter = self._excessive_counter + 1 if device_motion_valid and (excessive_long_actuation or excessive_lat_actuation) else 0 excessive_type = None if self._excessive_counter > MIN_EXCESSIVE_ACTUATION_COUNT: diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 2edc0bce18..180b58c916 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -7,7 +7,8 @@ import openpilot.cereal.messaging as messaging from openpilot.cereal import log, custom from opendbc.car.structs import car -from msgq.visionipc import VisionIpcClient, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcClient from openpilot.common.params import Params @@ -15,7 +16,7 @@ from openpilot.common.realtime import config_realtime_process, Priority, Ratekee from openpilot.common.swaglog import cloudlog from openpilot.common.gps import get_gps_location_service -from openpilot.selfdrive.car.car_specific import CarSpecificEvents +from openpilot.selfdrive.car.car_events import CarEvents from openpilot.selfdrive.locationd.helpers import PoseCalibrator, Pose from openpilot.selfdrive.selfdrived.events import Events, ET from openpilot.selfdrive.selfdrived.helpers import ExcessiveActuationCheck @@ -30,6 +31,7 @@ from openpilot.sunnypilot import get_sanitize_int_param from openpilot.sunnypilot.selfdrive.car.car_specific import CarSpecificEventsSP from openpilot.sunnypilot.selfdrive.car.cruise_helpers import CruiseHelper from openpilot.sunnypilot.selfdrive.car.intelligent_cruise_button_management.controller import IntelligentCruiseButtonManagement +from openpilot.sunnypilot.selfdrive.selfdrived.button_state_tracker import ButtonStateTracker from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP REPLAY = "REPLAY" in os.environ @@ -74,12 +76,16 @@ class SelfdriveD(CruiseHelper): else: self.CP_SP = CP_SP - self.car_events = CarSpecificEvents(self.CP) + self.car_events = CarEvents(self.CP) self.pose_calibrator = PoseCalibrator() self.calibrated_pose: Pose | None = None self.excessive_actuation_check = ExcessiveActuationCheck() self.excessive_actuation = self.params.get("Offroad_ExcessiveActuation") is not None + self.big_model_loading = False + self.big_model_active = False + self.big_model_failed = False + self.big_model_ready_t = 0. # Setup sockets self.pm = messaging.PubMaster(['selfdriveState', 'onroadEvents'] + ['selfdriveStateSP', 'onroadEventsSP']) @@ -87,21 +93,21 @@ class SelfdriveD(CruiseHelper): self.gps_location_service = get_gps_location_service(self.params) self.gps_packets = [self.gps_location_service] self.sensor_packets = ["accelerometer", "gyroscope"] - self.camera_packets = ["roadCameraState", "driverCameraState", "wideRoadCameraState"] + self.camera_packets = ["narrowRoadCameraState", "cabinCameraState", "wideRoadCameraState"] # TODO: de-couple selfdrived with card/conflate on carState without introducing controls mismatches self.car_state_sock = messaging.sub_sock('carState', timeout=20) - ignore = self.sensor_packets + self.gps_packets + ['alertDebug', 'lateralManeuverPlan'] + ['modelDataV2SP'] + ignore = self.sensor_packets + self.gps_packets + ['alertDebug', 'lateralManeuverPlan'] + ['modelDataV2SP', 'longitudinalPlanSP'] if SIMULATION: - ignore += ['driverCameraState', 'managerState'] + ignore += ['cabinCameraState', 'managerState'] if REPLAY: # no vipc in replay will make them ignored anyways - ignore += ['roadCameraState', 'wideRoadCameraState'] - self.sm = messaging.SubMaster(['deviceState', 'pandaStates', 'peripheralState', 'modelV2', 'liveCalibration', - 'carOutput', 'driverMonitoringState', 'longitudinalPlan', 'livePose', 'liveDelay', - 'managerState', 'liveParameters', 'radarState', 'liveTorqueParameters', - 'controlsState', 'carControl', 'driverAssistance', 'alertDebug', 'userBookmark', 'audioFeedback', + ignore += ['narrowRoadCameraState', 'wideRoadCameraState'] + self.sm = messaging.SubMaster(['deviceState', 'pandaStates', 'peripheralState', 'modelV2', 'extrinsicsCalibration', + 'carOutput', 'driverMonitoringState', 'longitudinalPlan', 'deviceMotion', 'lateralDelay', + 'managerState', 'vehicleParameters', 'radarState', 'lateralTorqueParameters', + 'controlsState', 'carControl', 'driverAssistance', 'alertDebug', 'userBookmark', 'lateralManeuverPlan', 'modelDataV2SP', 'longitudinalPlanSP'] + \ self.camera_packets + self.sensor_packets + self.gps_packets, ignore_alive=ignore, ignore_avg_freq=ignore, @@ -177,6 +183,7 @@ class SelfdriveD(CruiseHelper): self.car_events_sp = CarSpecificEventsSP(self.CP, self.CP_SP) CruiseHelper.__init__(self, self.CP) + self.button_state_tracker = ButtonStateTracker() def update_events(self, CS): """Compute onroadEvents from carState""" @@ -188,6 +195,27 @@ class SelfdriveD(CruiseHelper): self.events.add(EventName.joystickDebug) self.startup_event = None + loading = self.params.get_bool("ChestnutLoading") + if self.big_model_loading and not loading: + self.big_model_ready_t = time.monotonic() + self.big_model_loading = loading + if self.big_model_loading: + self.events.add(EventName.bigModelLoading) + + big_active = self.params.get("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 chestnut_present) + if big_failed and not self.big_model_failed: + self.events.add(EventName.bigModelFailed) + self.big_model_failed = big_failed + + # soft disable if the big model fails + if big_active: + self.big_model_active = True + if not self.enabled and not model_unavailable: + self.big_model_active = False + if self.sm.recv_frame['lateralManeuverPlan'] > 0: self.events.add(EventName.lateralManeuver) self.startup_event = None @@ -205,13 +233,10 @@ class SelfdriveD(CruiseHelper): self.events.add(EventName.selfdriveInitializing) return - # Check for user bookmark press (bookmark button or end of LKAS button feedback) + # Check for user bookmark press if self.sm.updated['userBookmark']: self.events.add(EventName.userBookmark) - if self.sm.updated['audioFeedback']: - self.events.add(EventName.audioFeedback) - # Don't add any more events while in dashcam mode if self.CP.passive: return @@ -285,11 +310,11 @@ class SelfdriveD(CruiseHelper): self.last_functional_fan_frame = self.sm.frame # Handle calibration status - cal_status = self.sm['liveCalibration'].calStatus - if cal_status != log.LiveCalibrationData.Status.calibrated: - if cal_status == log.LiveCalibrationData.Status.uncalibrated: + cal_status = self.sm['extrinsicsCalibration'].calStatus + if cal_status != log.ExtrinsicsCalibration.Status.calibrated: + if cal_status == log.ExtrinsicsCalibration.Status.uncalibrated: self.events.add(EventName.calibrationIncomplete) - elif cal_status == log.LiveCalibrationData.Status.recalibrating: + elif cal_status == log.ExtrinsicsCalibration.Status.recalibrating: if not self.recalibrating_seen: set_offroad_alert("Offroad_Recalibration", True) self.recalibrating_seen = True @@ -306,13 +331,13 @@ class SelfdriveD(CruiseHelper): # NOTE: To fork maintainers. # Disabling or nerfing safety features will get you and your users banned from our servers. # We recommend that you do not change these numbers from the defaults. - if self.sm.updated['liveCalibration']: - self.pose_calibrator.feed_live_calib(self.sm['liveCalibration']) - if self.sm.updated['livePose']: - device_pose = Pose.from_live_pose(self.sm['livePose']) - self.calibrated_pose = self.pose_calibrator.build_calibrated_pose(device_pose) + if self.sm.updated['extrinsicsCalibration']: + self.pose_calibrator.feed_extrinsics_calibration(self.sm['extrinsicsCalibration']) + if self.sm.updated['deviceMotion']: + device_motion = Pose.from_device_motion(self.sm['deviceMotion']) + self.calibrated_pose = self.pose_calibrator.build_calibrated_pose(device_motion) - if self.calibrated_pose is not None: + if self.calibrated_pose is not None and not self.CP.notCar: excessive_actuation = self.excessive_actuation_check.update(self.sm, CS, self.calibrated_pose) if not self.excessive_actuation and excessive_actuation is not None: set_offroad_alert("Offroad_ExcessiveActuation", True, extra_text=str(excessive_actuation)) @@ -325,9 +350,16 @@ class SelfdriveD(CruiseHelper): # Handle lane change if self.sm['modelV2'].meta.laneChangeState == LaneChangeState.preLaneChange: direction = self.sm['modelV2'].meta.laneChangeDirection + mdv2sp = self.sm['modelDataV2SP'] + if (CS.leftBlindspot and direction == LaneChangeDirection.left) or \ (CS.rightBlindspot and direction == LaneChangeDirection.right): self.events.add(EventName.laneChangeBlocked) + + elif (mdv2sp.leftLaneChangeEdgeBlock and direction == LaneChangeDirection.left) or \ + (mdv2sp.rightLaneChangeEdgeBlock and direction == LaneChangeDirection.right): + self.events_sp.add(custom.OnroadEventSP.EventName.laneChangeRoadEdge) + else: if direction == LaneChangeDirection.left: self.events.add(EventName.preLaneChangeLeft) @@ -365,6 +397,9 @@ class SelfdriveD(CruiseHelper): # All events here should at least have NO_ENTRY and SOFT_DISABLE. num_events = len(self.events) + if self.big_model_active and big_failed: + self.events.add(EventName.bigModelFailed) + not_running = {p.name for p in self.sm['managerState'].processes if not p.running and p.shouldBeRunning} if self.sm.recv_frame['managerState'] and len(not_running): if not_running != self.not_running_prev: @@ -387,8 +422,6 @@ class SelfdriveD(CruiseHelper): self.events.add(EventName.radarTempUnavailable) elif any(self.sm['radarState'].radarErrors.to_dict().values()): self.events.add(EventName.radarFault) - if not self.sm.valid['pandaStates']: - self.events.add(EventName.usbError) if CS.canTimeout: self.events.add(EventName.canBusMissing) elif not CS.canValid: @@ -397,7 +430,9 @@ class SelfdriveD(CruiseHelper): # generic catch-all. ideally, a more specific event should be added above instead has_disable_events = self.events.contains(ET.NO_ENTRY) and (self.events.contains(ET.SOFT_DISABLE) or self.events.contains(ET.IMMEDIATE_DISABLE)) no_system_errors = (not has_disable_events) or (len(self.events) == num_events) - if not self.sm.all_checks() and no_system_errors: + warmup_sec = 5. + big_model_settling = self.big_model_loading or time.monotonic() < self.big_model_ready_t + warmup_sec + if not self.sm.all_checks() and no_system_errors and not big_model_settling: # the load holds modelV2 and friends back on purpose if not self.sm.all_alive(): self.events.add(EventName.commIssue) elif not self.sm.all_freq_ok(): @@ -416,12 +451,13 @@ class SelfdriveD(CruiseHelper): else: self.logged_comm_issue = None - if not self.CP.notCar: - if not self.sm['livePose'].posenetOK: + if not self.CP.notCar and not big_model_settling: # localization has nothing to work with during the load + if not self.sm['deviceMotion'].posenetOK: self.events.add(EventName.posenetInvalid) - if not self.sm['livePose'].inputsOK: + if not self.sm['deviceMotion'].inputsOK: self.events.add(EventName.locationdTemporaryError) - if not self.sm['liveParameters'].valid and cal_status == log.LiveCalibrationData.Status.calibrated and not TESTING_CLOSET and (not SIMULATION or REPLAY): + if (not self.sm['vehicleParameters'].valid and cal_status == log.ExtrinsicsCalibration.Status.calibrated and + not TESTING_CLOSET and (not SIMULATION or REPLAY)): self.events.add(EventName.paramsdTemporaryError) # conservative HW alert. if the data or frequency are off, locationd will throw an error @@ -460,7 +496,7 @@ class SelfdriveD(CruiseHelper): # GPS checks gps_ok = self.sm.recv_frame[self.gps_location_service] > 0 and (self.sm.frame - self.sm.recv_frame[self.gps_location_service]) * DT_CTRL < 2.0 - if not gps_ok and self.sm['livePose'].inputsOK and (self.distance_traveled > 1500): + if not gps_ok and self.sm['deviceMotion'].inputsOK and (self.distance_traveled > 1500): self.events.add(EventName.noGps) if gps_ok: self.distance_traveled = 0 @@ -499,9 +535,9 @@ class SelfdriveD(CruiseHelper): timed_out = self.sm.frame * DT_CTRL > 6. if all_valid or timed_out or (SIMULATION and not REPLAY): available_streams = VisionIpcClient.available_streams("camerad", block=False) - if VisionStreamType.VISION_STREAM_ROAD not in available_streams: - self.sm.ignore_alive.append('roadCameraState') - self.sm.ignore_valid.append('roadCameraState') + if VisionStreamType.VISION_STREAM_NARROW_ROAD not in available_streams: + self.sm.ignore_alive.append('narrowRoadCameraState') + self.sm.ignore_valid.append('narrowRoadCameraState') if VisionStreamType.VISION_STREAM_WIDE_ROAD not in available_streams: self.sm.ignore_alive.append('wideRoadCameraState') self.sm.ignore_valid.append('wideRoadCameraState') @@ -597,6 +633,8 @@ class SelfdriveD(CruiseHelper): icbm.sendButton = self.icbm.cruise_button icbm.vTarget = self.icbm.v_target + self.button_state_tracker.publish(ss_sp) + self.pm.send('selfdriveStateSP', ss_sp_msg) # onroadEventsSP - logged every second or on change @@ -616,6 +654,7 @@ class SelfdriveD(CruiseHelper): self.mads.update(CS) self.update_alerts(CS) + self.button_state_tracker.update(CS) self.publish_selfdriveState(CS) self.CS_prev = CS diff --git a/openpilot/selfdrive/selfdrived/tests/test_alertmanager.py b/openpilot/selfdrive/selfdrived/tests/test_alertmanager.py index 8f7c79878c..67cd8f087f 100644 --- a/openpilot/selfdrive/selfdrived/tests/test_alertmanager.py +++ b/openpilot/selfdrive/selfdrived/tests/test_alertmanager.py @@ -1,12 +1,13 @@ import random +from openpilot.common.test import OpenpilotTestCase from openpilot.selfdrive.selfdrived.events import Alert, EVENTS from openpilot.selfdrive.selfdrived.alertmanager import AlertManager from openpilot.sunnypilot.selfdrive.selfdrived.events_base import EmptyAlert -class TestAlertManager: +class TestAlertManager(OpenpilotTestCase): def test_duration(self): """ diff --git a/openpilot/selfdrive/selfdrived/tests/test_alerts.py b/openpilot/selfdrive/selfdrived/tests/test_alerts.py index 0210c138da..9737aa9de7 100644 --- a/openpilot/selfdrive/selfdrived/tests/test_alerts.py +++ b/openpilot/selfdrive/selfdrived/tests/test_alerts.py @@ -2,8 +2,8 @@ import copy import json import os import random -from PIL import Image, ImageDraw, ImageFont +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import log from opendbc.car.structs import car from openpilot.cereal.messaging import SubMaster @@ -24,7 +24,7 @@ for event_types in EVENTS.values(): ALERTS.append(alert) -class TestAlerts: +class TestAlerts(OpenpilotTestCase): @classmethod def setup_class(cls): @@ -46,41 +46,6 @@ class TestAlerts: fail_msg = f"{name} @{e} not in EVENTS" assert e in EVENTS.keys(), fail_msg - # ensure alert text doesn't exceed allowed width - def test_alert_text_length(self): - font_path = os.path.join(BASEDIR, "openpilot/selfdrive/assets/fonts") - regular_font_path = os.path.join(font_path, "Inter-SemiBold.ttf") - bold_font_path = os.path.join(font_path, "Inter-Bold.ttf") - semibold_font_path = os.path.join(font_path, "Inter-SemiBold.ttf") - - max_text_width = 2160 - 300 # full screen width is usable, minus sidebar - draw = ImageDraw.Draw(Image.new('RGB', (0, 0))) - - fonts = { - AlertSize.small: [ImageFont.truetype(semibold_font_path, 74)], - AlertSize.mid: [ImageFont.truetype(bold_font_path, 88), - ImageFont.truetype(regular_font_path, 66)], - } - - for alert in ALERTS: - if not isinstance(alert, Alert): - alert = alert(self.CP, self.CS, self.sm, False, 100, log.LongitudinalPersonality.standard) - - # for full size alerts, both text fields wrap the text, - # so it's unlikely that they would go past the max width - if alert.alert_size in (AlertSize.none, AlertSize.full): - continue - - for i, txt in enumerate([alert.alert_text_1, alert.alert_text_2]): - if i >= len(fonts[alert.alert_size]): - break - - font = fonts[alert.alert_size][i] - left, _, right, _ = draw.textbbox((0, 0), txt, font) - width = right - left - msg = f"type: {alert.alert_type} msg: {txt}" - assert width <= max_text_width, msg - def test_alert_sanity_check(self): for event_types in EVENTS.values(): for event_type, a in event_types.items(): diff --git a/openpilot/selfdrive/selfdrived/tests/test_state_machine.py b/openpilot/selfdrive/selfdrived/tests/test_state_machine.py index 8139e53d2e..22f664718e 100644 --- a/openpilot/selfdrive/selfdrived/tests/test_state_machine.py +++ b/openpilot/selfdrive/selfdrived/tests/test_state_machine.py @@ -1,3 +1,4 @@ +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import log from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.selfdrived.state import StateMachine, SOFT_DISABLE_TIME @@ -13,15 +14,15 @@ ALL_STATES = tuple(State.schema.enumerants.values()) ENABLE_EVENT_TYPES = (ET.ENABLE, ET.PRE_ENABLE, ET.OVERRIDE_LATERAL, ET.OVERRIDE_LONGITUDINAL) -def make_event(event_types): - event = {} +def make_event(event_types: list[str | None]): + EVENTS[0] = {} for ev in event_types: - event[ev] = NormalPermanentAlert("alert") - EVENTS[0] = event + if ev is not None: + EVENTS[0][ev] = NormalPermanentAlert("alert") return 0 -class TestStateMachine: +class TestStateMachine(OpenpilotTestCase): def setup_method(self): self.events = Events() self.state_machine = StateMachine() diff --git a/openpilot/selfdrive/test/cpp_harness.py b/openpilot/selfdrive/test/cpp_harness.py deleted file mode 100755 index f9f425102b..0000000000 --- a/openpilot/selfdrive/test/cpp_harness.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env python3 -import subprocess -import sys - -from openpilot.common.prefix import OpenpilotPrefix - -with OpenpilotPrefix(): - ret = subprocess.call(sys.argv[1:]) - -sys.exit(ret) diff --git a/openpilot/selfdrive/test/fuzzy_generation.py b/openpilot/selfdrive/test/fuzzy_generation.py deleted file mode 100644 index c97221ae9e..0000000000 --- a/openpilot/selfdrive/test/fuzzy_generation.py +++ /dev/null @@ -1,81 +0,0 @@ -import capnp -import hypothesis.strategies as st -from typing import Any -from collections.abc import Callable -from functools import cache - -from openpilot.cereal import log - -DrawType = Callable[[st.SearchStrategy], Any] - - -class FuzzyGenerator: - def __init__(self, draw: DrawType, real_floats: bool): - self.draw = draw - self.native_type_map = FuzzyGenerator._get_native_type_map(real_floats) - - def generate_native_type(self, field: str) -> st.SearchStrategy[bool | int | float | str | bytes]: - value_func = self.native_type_map.get(field) - if value_func is not None: - return value_func - else: - raise NotImplementedError(f'Invalid type: {field}') - - def generate_field(self, field: capnp.lib.capnp._StructSchemaField) -> st.SearchStrategy: - def rec(field_type: capnp.lib.capnp._DynamicStructReader) -> st.SearchStrategy: - type_which = field_type.which() - if type_which == 'struct': - return self.generate_struct(field.schema.elementType if base_type == 'list' else field.schema) - elif type_which == 'list': - return st.lists(rec(field_type.list.elementType)) - elif type_which == 'enum': - schema = field.schema.elementType if base_type == 'list' else field.schema - return st.sampled_from(list(schema.enumerants.keys())) - else: - return self.generate_native_type(type_which) - - try: - if hasattr(field.proto, 'slot'): - slot_type = field.proto.slot.type - base_type = slot_type.which() - return rec(slot_type) - else: - return self.generate_struct(field.schema) - except capnp.lib.capnp.KjException: - return self.generate_struct(field.schema) - - def generate_struct(self, schema: capnp.lib.capnp._StructSchema, event: str | None = None) -> st.SearchStrategy[dict[str, Any]]: - single_fill: tuple[str, ...] = (event,) if event else (self.draw(st.sampled_from(schema.union_fields)),) if schema.union_fields else () - fields_to_generate = [f for f in schema.non_union_fields + single_fill if not f.endswith('DEPRECATED') and f != 'deprecated'] - return st.fixed_dictionaries({field: self.generate_field(schema.fields[field]) for field in fields_to_generate}) - - @staticmethod - @cache - def _get_native_type_map(real_floats: bool) -> dict[str, st.SearchStrategy]: - return { - 'bool': st.booleans(), - 'int8': st.integers(min_value=-2**7, max_value=2**7-1), - 'int16': st.integers(min_value=-2**15, max_value=2**15-1), - 'int32': st.integers(min_value=-2**31, max_value=2**31-1), - 'int64': st.integers(min_value=-2**63, max_value=2**63-1), - 'uint8': st.integers(min_value=0, max_value=2**8-1), - 'uint16': st.integers(min_value=0, max_value=2**16-1), - 'uint32': st.integers(min_value=0, max_value=2**32-1), - 'uint64': st.integers(min_value=0, max_value=2**64-1), - 'float32': st.floats(width=32, allow_nan=not real_floats, allow_infinity=not real_floats), - 'float64': st.floats(width=64, allow_nan=not real_floats, allow_infinity=not real_floats), - 'text': st.text(max_size=1000), - 'data': st.binary(max_size=1000), - 'anyPointer': st.text(), # Note: No need to define a separate function for anyPointer - } - - @classmethod - def get_random_msg(cls, draw: DrawType, struct: capnp.lib.capnp._StructModule, real_floats: bool = False) -> dict[str, Any]: - fg = cls(draw, real_floats=real_floats) - data: dict[str, Any] = draw(fg.generate_struct(struct.schema)) - return data - - @classmethod - def get_random_event_msg(cls, draw: DrawType, events: list[str], real_floats: bool = False) -> list[dict[str, Any]]: - fg = cls(draw, real_floats=real_floats) - return [draw(fg.generate_struct(log.Event.schema, e)) for e in sorted(events)] diff --git a/openpilot/selfdrive/test/helpers.py b/openpilot/selfdrive/test/helpers.py index 9d954216b8..71cdcee5e7 100644 --- a/openpilot/selfdrive/test/helpers.py +++ b/openpilot/selfdrive/test/helpers.py @@ -3,7 +3,6 @@ import http.server import os import threading import time -import pytest from functools import wraps @@ -23,16 +22,16 @@ def set_params_enabled(): params.put_bool("OpenpilotEnabledToggle", True, block=True) # valid calib - msg = messaging.new_message('liveCalibration') - msg.liveCalibration.validBlocks = 20 - msg.liveCalibration.rpyCalib = [0.0, 0.0, 0.0] + msg = messaging.new_message('extrinsicsCalibration') + msg.extrinsicsCalibration.validBlocks = 20 + msg.extrinsicsCalibration.rpyCalib = [0.0, 0.0, 0.0] params.put("CalibrationParams", msg.to_bytes(), block=True) def release_only(f): @wraps(f) def wrap(self, *args, **kwargs): if "RELEASE" not in os.environ: - pytest.skip("This test is only for release branches") + self.skipTest("This test is only for release branches") f(self, *args, **kwargs) return wrap diff --git a/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py b/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py index ba0379f2d7..99a7d8d690 100644 --- a/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py +++ b/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py @@ -43,6 +43,7 @@ class Maneuver: valid = True logs = [] + not_starting_t = 0.0 while plant.current_time < self.duration: speed_lead = np.interp(plant.current_time, self.breakpoints, self.speed_lead_values) prob_lead = np.interp(plant.current_time, self.breakpoints, self.prob_lead_values) @@ -68,8 +69,13 @@ class Maneuver: valid = False if self.ensure_start and log['v_rel'] > 0 and log['acceleration'] < 1e-3: - print('LongitudinalPlanner not starting!') - valid = False + if not_starting_t == 0.0: + not_starting_t = plant.current_time + elif plant.current_time - not_starting_t > 0.5: + print('LongitudinalPlanner not starting!') + valid = False + else: + not_starting_t = 0.0 if self.ensure_slowdown and log['speed'] > 5.5: print('LongitudinalPlanner not slowing down!') diff --git a/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py b/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py index abbd6be1d6..b4e8d76d6d 100755 --- a/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py +++ b/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py @@ -66,7 +66,7 @@ class Plant: control = messaging.new_message('controlsState') ss = messaging.new_message('selfdriveState') car_state = messaging.new_message('carState') - lp = messaging.new_message('liveParameters') + lp = messaging.new_message('vehicleParameters') car_control = messaging.new_message('carControl') model = messaging.new_message('modelV2') car_state_sp = messaging.new_message('carStateSP') @@ -112,7 +112,7 @@ class Plant: position = log.XYZTData.new_message() position.x = [float(x) for x in (self.speed + 0.5) * np.array(ModelConstants.T_IDXS)] model.modelV2.position = position - model.modelV2.action.desiredAcceleration = float(self.acceleration + 0.1) + model.modelV2.action.desiredAcceleration = float(self.acceleration + 0.5) velocity = log.XYZTData.new_message() velocity.x = [float(x) for x in (self.speed + 0.5) * np.ones_like(ModelConstants.T_IDXS)] velocity.x[0] = float(self.speed) # always start at current speed @@ -137,7 +137,7 @@ class Plant: 'carControl': car_control.carControl, 'controlsState': control.controlsState, 'selfdriveState': ss.selfdriveState, - 'liveParameters': lp.liveParameters, + 'vehicleParameters': lp.vehicleParameters, 'modelV2': model.modelV2, 'carStateSP': car_state_sp.carStateSP, 'liveMapDataSP': live_map_data_sp.liveMapDataSP, diff --git a/openpilot/selfdrive/test/longitudinal_maneuvers/test_longitudinal.py b/openpilot/selfdrive/test/longitudinal_maneuvers/test_longitudinal.py index b8f97c5041..c8694b6ac2 100644 --- a/openpilot/selfdrive/test/longitudinal_maneuvers/test_longitudinal.py +++ b/openpilot/selfdrive/test/longitudinal_maneuvers/test_longitudinal.py @@ -1,4 +1,5 @@ import itertools +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized_class from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import STOP_DISTANCE @@ -150,7 +151,9 @@ def create_maneuvers(kwargs): enabled=False, **kwargs, ), - Maneuver( + ] + if not kwargs['e2e']: + maneuvers.append(Maneuver( "slow to 5m/s with allow_throttle = False and pitch = +0.1", duration=30., initial_speed=20., @@ -161,7 +164,7 @@ def create_maneuvers(kwargs): breakpoints=[0.0, 2., 2.01], ensure_slowdown=True, **kwargs, - )] + )) if not kwargs['force_decel']: # controls relies on planner commanding to move for stock-ACC resume spamming maneuvers.append(Maneuver( @@ -179,7 +182,7 @@ def create_maneuvers(kwargs): @parameterized_class(("e2e", "force_decel"), itertools.product([True, False], repeat=2)) -class TestLongitudinalControl: +class TestLongitudinalControl(OpenpilotTestCase): e2e: bool force_decel: bool diff --git a/openpilot/selfdrive/test/mem_usage.py b/openpilot/selfdrive/test/mem_usage.py index 1446d0b4dc..02b7934466 100644 --- a/openpilot/selfdrive/test/mem_usage.py +++ b/openpilot/selfdrive/test/mem_usage.py @@ -7,7 +7,7 @@ from openpilot.common.utils import tabulate DEMO_ROUTE = "5beb9b58bd12b691/0000010a--a51155e496" MB = 1024 * 1024 -TABULATE_OPTS = dict(tablefmt="simple_grid", stralign="center", numalign="center") +TABULATE_OPTS = {"tablefmt": "simple_grid", "stralign": "center", "numalign": "center"} def _get_procs(): diff --git a/openpilot/selfdrive/test/process_replay/README.md b/openpilot/selfdrive/test/process_replay/README.md index 28f3b7cd2a..36d669dda6 100644 --- a/openpilot/selfdrive/test/process_replay/README.md +++ b/openpilot/selfdrive/test/process_replay/README.md @@ -85,7 +85,7 @@ Supported processes: * modeld * dmonitoringmodeld -Certain processes may require an initial state, which is usually supplied within `Params` and persisting from segment to segment (e.g CalibrationParams, LiveParameters). The `custom_params` is dictionary used to prepopulate `Params` with arbitrary values. The `get_custom_params_from_lr` helper is provided to fetch meaningful values from log files. +Certain processes may require an initial state, which is usually supplied within `Params` and persists from segment to segment (for example `CalibrationParams` or the learner cache keys like `LiveParametersV2`). The `custom_params` is a dictionary used to prepopulate `Params` with arbitrary values. The `get_custom_params_from_lr` helper is provided to fetch meaningful values from log files. ```py from openpilot.selfdrive.test.process_replay import get_custom_params_from_lr @@ -104,9 +104,9 @@ Replaying processes that use VisionIPC (e.g. modeld, dmonitoringmodeld) require from openpilot.tools.lib.framereader import FrameReader frs = { - 'roadCameraState': FrameReader(...), + 'narrowRoadCameraState': FrameReader(...), 'wideRoadCameraState': FrameReader(...), - 'driverCameraState': FrameReader(...), + 'cabinCameraState': FrameReader(...), } output_logs = replay_process_with_name(['modeld', 'dmonitoringmodeld'], lr, frs=frs) diff --git a/openpilot/selfdrive/test/process_replay/migration.py b/openpilot/selfdrive/test/process_replay/migration.py index e357a0ee22..646728e673 100644 --- a/openpilot/selfdrive/test/process_replay/migration.py +++ b/openpilot/selfdrive/test/process_replay/migration.py @@ -15,7 +15,7 @@ from opendbc.car.gm.values import GMSafetyFlags from openpilot.selfdrive.modeld.constants import ModelConstants from openpilot.selfdrive.modeld.fill_model_msg import fill_xyz_poly, fill_lane_line_meta from openpilot.selfdrive.test.process_replay.vision_meta import meta_from_encode_index -from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, get_accel_from_plan +from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, get_accel_from_plan, should_stop from openpilot.system.manager.process_config import managed_processes from openpilot.tools.lib.logreader import LogIterable @@ -40,7 +40,8 @@ def migrate_all(lr: LogIterable, manager_states: bool = False, panda_states: boo migrate_carOutput, migrate_controlsState, migrate_carState, - migrate_livePose, + migrate_liveLocationKalman, + migrate_deviceMotion, migrate_liveTracks, migrate_driverAssistance, migrate_drivingModelData, @@ -129,8 +130,10 @@ def migrate_longitudinalPlan(msgs): if msg.which() != 'longitudinalPlan': continue new_msg = msg.as_builder() - a_target, should_stop = get_accel_from_plan(msg.longitudinalPlan.speeds, msg.longitudinalPlan.accels, ModelConstants.T_IDXS[:CONTROL_N]) - new_msg.longitudinalPlan.aTarget, new_msg.longitudinalPlan.shouldStop = float(a_target), bool(should_stop) + a_target = get_accel_from_plan(msg.longitudinalPlan.speeds, msg.longitudinalPlan.accels, ModelConstants.T_IDXS[:CONTROL_N]) + v_now = msg.longitudinalPlan.speeds[0] if len(msg.longitudinalPlan.speeds) == CONTROL_N else 0.0 + stop = should_stop(v_now, a_target) + new_msg.longitudinalPlan.aTarget, new_msg.longitudinalPlan.shouldStop = float(a_target), bool(stop) ops.append((index, as_reader(new_msg))) return ops, [], [] @@ -161,11 +164,11 @@ def migrate_drivingModelData(msgs): return [], add_ops, [] -@migration(inputs=["liveTracksDEPRECATED"], product="liveTracks") +@migration(inputs=["liveTracksDEPRECATED"], product="radarTracks") def migrate_liveTracks(msgs): ops = [] for index, msg in msgs: - new_msg = messaging.new_message('liveTracks') + new_msg = messaging.new_message('radarTracks') new_msg.valid = msg.valid new_msg.logMonoTime = msg.logMonoTime @@ -179,42 +182,42 @@ def migrate_liveTracks(msgs): pt.vRel = track.vRel pts.append(pt) - new_msg.liveTracks.points = pts + new_msg.radarTracks.points = pts ops.append((index, as_reader(new_msg))) return ops, [], [] -@migration(inputs=["liveLocationKalmanDEPRECATED"], product="livePose") +@migration(inputs=["liveLocationKalmanDEPRECATED"], product="deviceMotion") def migrate_liveLocationKalman(msgs): nans = [float('nan')] * 3 ops = [] for index, msg in msgs: - m = messaging.new_message('livePose') + m = messaging.new_message('deviceMotion') m.valid = msg.valid m.logMonoTime = msg.logMonoTime - m.livePose.timestamp = msg.logMonoTime + m.deviceMotion.timestamp = msg.logMonoTime for field in ["orientationNED", "velocityDevice", "accelerationDevice", "angularVelocityDevice"]: - lp_field, llk_field = getattr(m.livePose, field), getattr(msg.liveLocationKalmanDEPRECATED, field) + lp_field, llk_field = getattr(m.deviceMotion, field), getattr(msg.liveLocationKalmanDEPRECATED, field) lp_field.x, lp_field.y, lp_field.z = llk_field.value or nans lp_field.xStd, lp_field.yStd, lp_field.zStd = llk_field.std or nans lp_field.valid = llk_field.valid for flag in ["inputsOK", "posenetOK", "sensorsOK"]: - setattr(m.livePose, flag, getattr(msg.liveLocationKalmanDEPRECATED, flag)) + setattr(m.deviceMotion, flag, getattr(msg.liveLocationKalmanDEPRECATED, flag)) ops.append((index, as_reader(m))) return ops, [], [] -@migration(inputs=["livePose"]) -def migrate_livePose(msgs): +@migration(inputs=["deviceMotion"]) +def migrate_deviceMotion(msgs): ops = [] - needs_migration = all(msg.livePose.timestamp == 0 for _, msg in msgs if msg.which() == 'livePose') + needs_migration = all(msg.deviceMotion.timestamp == 0 for _, msg in msgs if msg.which() == 'deviceMotion') if not needs_migration: return [], [], [] for index, msg in msgs: - if msg.which() == "livePose": + if msg.which() == "deviceMotion": new_msg = msg.as_builder() - new_msg.livePose.timestamp = msg.logMonoTime + new_msg.deviceMotion.timestamp = msg.logMonoTime ops.append((index, as_reader(new_msg))) return ops, [], [] @@ -297,7 +300,7 @@ def migrate_carOutput(msgs): co = messaging.new_message('carOutput') co.valid = msg.valid co.logMonoTime = msg.logMonoTime - co.carOutput.actuatorsOutput = msg.carControl.actuatorsOutputDEPRECATED + co.carOutput.actuatorsOutput = msg.carControl.deprecated.actuatorsOutput add_ops.append(as_reader(co)) return [], add_ops, [] @@ -313,7 +316,7 @@ def migrate_pandaStates(msgs): "CHEVROLET_BOLT_EUV": GMSafetyFlags.EV | GMSafetyFlags.HW_CAM, } # TODO: get new Ford route - safety_param_migration |= dict.fromkeys((set(FORD) - FORD.with_flags(FordFlags.CANFD)), FordSafetyFlags.LONG_CONTROL) + safety_param_migration |= dict.fromkeys({p for p in FORD if not (p.config.flags & FordFlags.CANFD)}, FordSafetyFlags.LONG_CONTROL) # Migrate safety param base on carParams CP = next((m.carParams for _, m in msgs if m.which() == 'carParams'), None) @@ -323,10 +326,10 @@ def migrate_pandaStates(msgs): safety_param = safety_param_migration[fingerprint].value elif len(CP.safetyConfigs): safety_param = CP.safetyConfigs[0].safetyParam - if CP.safetyConfigs[0].safetyParamDEPRECATED != 0: - safety_param = CP.safetyConfigs[0].safetyParamDEPRECATED + if CP.safetyConfigs[0].deprecated.safetyParam != 0: + safety_param = CP.safetyConfigs[0].deprecated.safetyParam else: - safety_param = CP.safetyParamDEPRECATED + safety_param = CP.deprecated.safetyParam ops = [] for index, msg in msgs: @@ -361,7 +364,7 @@ def migrate_peripheralState(msgs): return [], add_ops, [] -@migration(inputs=["roadEncodeIdx", "wideRoadEncodeIdx", "driverEncodeIdx", "roadCameraState", "wideRoadCameraState", "driverCameraState"]) +@migration(inputs=["narrowRoadEncodeIdx", "wideRoadEncodeIdx", "cabinEncodeIdx", "narrowRoadCameraState", "wideRoadCameraState", "cabinCameraState"]) def migrate_cameraStates(msgs): add_ops, del_ops = [], [] frame_to_encode_id = defaultdict(dict) @@ -369,7 +372,7 @@ def migrate_cameraStates(msgs): min_frame_id = defaultdict(lambda: float('inf')) for _, msg in msgs: - if msg.which() not in ["roadEncodeIdx", "wideRoadEncodeIdx", "driverEncodeIdx"]: + if msg.which() not in ["narrowRoadEncodeIdx", "wideRoadEncodeIdx", "cabinEncodeIdx"]: continue encode_index = getattr(msg, msg.which()) @@ -379,7 +382,7 @@ def migrate_cameraStates(msgs): frame_to_encode_id[meta.camera_state][encode_index.frameId] = encode_index.segmentId for index, msg in msgs: - if msg.which() not in ["roadCameraState", "wideRoadCameraState", "driverCameraState"]: + if msg.which() not in ["narrowRoadCameraState", "wideRoadCameraState", "cabinCameraState"]: continue camera_state = getattr(msg, msg.which()) @@ -392,7 +395,7 @@ def migrate_cameraStates(msgs): del_ops.append(index) continue - # fallback mechanism for logs without encodeIdx (e.g. logs from before 2022 with dcamera recording disabled) + # fallback mechanism for logs without encodeIdx (e.g. logs from before 2022 with driver recording disabled) # try to fake encode_id by subtracting lowest frameId encode_id = camera_state.frameId - min_frame_id[msg.which()] print(f"Faking encodeId to {encode_id} for camera feed {msg.which()} with frameId: {camera_state.frameId}") diff --git a/openpilot/selfdrive/test/process_replay/model_replay.py b/openpilot/selfdrive/test/process_replay/model_replay.py index 3e5616e702..ba610c6a2b 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -146,16 +146,17 @@ def trim_logs(logs, start_frame, end_frame, frs_types, include_all_types): def model_replay(lr, frs): # modeld is using frame pairs - modeld_logs = trim_logs(lr, START_FRAME, END_FRAME, {"roadCameraState", "wideRoadCameraState"}, - {"roadEncodeIdx", "wideRoadEncodeIdx", "carParams", "carState", "carControl", "can"}) - dmodeld_logs = trim_logs(lr, START_FRAME, END_FRAME, {"driverCameraState"}, {"driverEncodeIdx", "carParams", "can"}) + camera_states = {"narrowRoadCameraState", "wideRoadCameraState"} + modeld_logs = trim_logs(lr, START_FRAME, END_FRAME, camera_states, + {"narrowRoadEncodeIdx", "wideRoadEncodeIdx", "carParams", "carState", "carControl", "can"}) + dmodeld_logs = trim_logs(lr, START_FRAME, END_FRAME, {"cabinCameraState"}, {"cabinEncodeIdx", "carParams", "can"}) if not SEND_EXTRA_INPUTS: - modeld_logs = [msg for msg in modeld_logs if msg.which() != 'liveCalibration'] - dmodeld_logs = [msg for msg in dmodeld_logs if msg.which() != 'liveCalibration'] + modeld_logs = [msg for msg in modeld_logs if msg.which() != 'extrinsicsCalibration'] + dmodeld_logs = [msg for msg in dmodeld_logs if msg.which() != 'extrinsicsCalibration'] # initial setup - for s in ('liveCalibration', 'deviceState'): + for s in ('extrinsicsCalibration', 'deviceState'): msg = next(msg for msg in lr if msg.which() == s).as_builder() msg.logMonoTime = lr[0].logMonoTime modeld_logs.insert(1, msg.as_reader()) @@ -209,8 +210,8 @@ def get_frames(): print(f"Failed to load frames from cache {cache_name}: {e}") frs = { - 'roadCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "fcamera.hevc"), pix_fmt='nv12', cache_size=END_FRAME - START_FRAME), - 'driverCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "dcamera.hevc"), pix_fmt='nv12', cache_size=END_FRAME - START_FRAME), + 'narrowRoadCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "fcamera.hevc"), pix_fmt='nv12', cache_size=END_FRAME - START_FRAME), + 'cabinCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "dcamera.hevc"), pix_fmt='nv12', cache_size=END_FRAME - START_FRAME), 'wideRoadCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "ecamera.hevc"), pix_fmt='nv12', cache_size=END_FRAME - START_FRAME), } for fr in frs.values(): diff --git a/openpilot/selfdrive/test/process_replay/process_replay.py b/openpilot/selfdrive/test/process_replay/process_replay.py index 0be5d77251..8e5d224ea8 100755 --- a/openpilot/selfdrive/test/process_replay/process_replay.py +++ b/openpilot/selfdrive/test/process_replay/process_replay.py @@ -216,8 +216,7 @@ class ProcessContainer: def _start_process(self): if self.capture is not None: - self.process.launcher = LauncherWithCapture(self.capture, self.process.launcher) - self.process.prepare() + self.process.launcher = LauncherWithCapture(self.capture, self.process.launcher) # ty: ignore[invalid-assignment] # intentional wrapper self.process.start() def start( @@ -401,7 +400,7 @@ class ModeldCameraSyncRcvCallback: def __call__(self, msg, cfg, frame): self.is_dual_camera = len(cfg.vision_pubs) == 2 - if msg.which() == "roadCameraState": + if msg.which() == "narrowRoadCameraState": self.road_present = True elif msg.which() == "wideRoadCameraState": self.wide_road_present = True @@ -437,11 +436,11 @@ CONFIGS = [ ProcessConfig( proc_name="selfdrived", pubs=[ - "carState", "deviceState", "pandaStates", "peripheralState", "liveCalibration", "driverMonitoringState", - "longitudinalPlan", "livePose", "liveDelay", "liveParameters", "radarState", "modelV2", - "driverCameraState", "roadCameraState", "wideRoadCameraState", "managerState", "liveTorqueParameters", + "carState", "deviceState", "pandaStates", "peripheralState", "extrinsicsCalibration", "driverMonitoringState", + "longitudinalPlan", "deviceMotion", "lateralDelay", "vehicleParameters", "radarState", "modelV2", + "cabinCameraState", "narrowRoadCameraState", "wideRoadCameraState", "managerState", "lateralTorqueParameters", "accelerometer", "gyroscope", "carOutput", "gpsLocationExternal", "gpsLocation", "controlsState", - "carControl", "driverAssistance", "alertDebug", "audioFeedback", + "carControl", "driverAssistance", "alertDebug", ], subs=["selfdriveState", "onroadEvents"], ignore=["logMonoTime"], @@ -453,8 +452,8 @@ CONFIGS = [ ), ProcessConfig( proc_name="controlsd", - pubs=["liveParameters", "liveTorqueParameters", "modelV2", "selfdriveState", - "liveCalibration", "livePose", "longitudinalPlan", "carState", "carOutput", + pubs=["vehicleParameters", "lateralTorqueParameters", "modelV2", "selfdriveState", + "extrinsicsCalibration", "deviceMotion", "longitudinalPlan", "carState", "carOutput", "driverMonitoringState", "onroadEvents", "driverAssistance"], subs=["carControl", "controlsState"], ignore=["logMonoTime", ], @@ -465,7 +464,7 @@ CONFIGS = [ ProcessConfig( proc_name="card", pubs=["pandaStates", "carControl", "onroadEvents", "can"], - subs=["sendcan", "carState", "carParams", "carOutput", "liveTracks"], + subs=["sendcan", "carState", "carParams", "carOutput", "radarTracks"], ignore=["logMonoTime", "carState.cumLagMs"], init_callback=card_fingerprint_callback, should_recv_callback=card_rcv_callback, @@ -476,7 +475,7 @@ CONFIGS = [ ), ProcessConfig( proc_name="radard", - pubs=["liveTracks", "carState", "modelV2"], + pubs=["radarTracks", "carState", "modelV2"], subs=["radarState"], ignore=["logMonoTime"], init_callback=get_car_params_callback, @@ -484,7 +483,7 @@ CONFIGS = [ ), ProcessConfig( proc_name="plannerd", - pubs=["modelV2", "carControl", "carState", "controlsState", "liveParameters", "radarState", "selfdriveState"], + pubs=["modelV2", "carControl", "carState", "controlsState", "vehicleParameters", "radarState", "selfdriveState"], subs=["longitudinalPlan", "driverAssistance"], ignore=["logMonoTime", "longitudinalPlan.processingDelay", "longitudinalPlan.solverExecutionTime"], init_callback=get_car_params_callback, @@ -494,14 +493,14 @@ CONFIGS = [ ProcessConfig( proc_name="calibrationd", pubs=["carState", "cameraOdometry"], - subs=["liveCalibration"], + subs=["extrinsicsCalibration"], ignore=["logMonoTime"], init_callback=get_car_params_callback, should_recv_callback=MessageBasedRcvCallback("cameraOdometry", True), ), ProcessConfig( proc_name="dmonitoringd", - pubs=["driverStateV2", "liveCalibration", "carState", "modelV2", "selfdriveState"], + pubs=["driverStateV2", "extrinsicsCalibration", "carState", "modelV2", "selfdriveState", "carControl"], subs=["driverMonitoringState"], ignore=["logMonoTime"], should_recv_callback=MessageBasedRcvCallback("driverStateV2"), @@ -510,9 +509,9 @@ CONFIGS = [ ProcessConfig( proc_name="locationd", pubs=[ - "cameraOdometry", "accelerometer", "gyroscope", "liveCalibration", "carState" + "cameraOdometry", "accelerometer", "gyroscope", "extrinsicsCalibration", "carState" ], - subs=["liveLocationKalman", "livePose"], + subs=["deviceMotion"], ignore=["logMonoTime"], should_recv_callback=MessageBasedRcvCallback("cameraOdometry"), tolerance=NUMPY_TOLERANCE, @@ -520,21 +519,21 @@ CONFIGS = [ ), ProcessConfig( proc_name="paramsd", - pubs=["livePose", "liveCalibration", "carState"], - subs=["liveParameters"], + pubs=["deviceMotion", "extrinsicsCalibration", "carState"], + subs=["vehicleParameters"], ignore=["logMonoTime"], init_callback=get_car_params_callback, - should_recv_callback=MessageBasedRcvCallback("livePose"), + should_recv_callback=MessageBasedRcvCallback("deviceMotion"), tolerance=NUMPY_TOLERANCE, processing_time=0.004, ), ProcessConfig( proc_name="lagd", - pubs=["livePose", "liveCalibration", "carState", "carControl", "controlsState"], - subs=["liveDelay"], + pubs=["deviceMotion", "extrinsicsCalibration", "carState", "carControl", "controlsState"], + subs=["lateralDelay"], ignore=["logMonoTime"], init_callback=get_car_params_callback, - should_recv_callback=MessageBasedRcvCallback("livePose"), + should_recv_callback=MessageBasedRcvCallback("deviceMotion"), tolerance=NUMPY_TOLERANCE, ), ProcessConfig( @@ -545,37 +544,38 @@ CONFIGS = [ ), ProcessConfig( proc_name="torqued", - pubs=["livePose", "liveCalibration", "liveDelay", "carState", "carControl", "carOutput"], - subs=["liveTorqueParameters"], + pubs=["deviceMotion", "extrinsicsCalibration", "lateralDelay", "carState", "carControl", "carOutput"], + subs=["lateralTorqueParameters"], ignore=["logMonoTime"], init_callback=get_car_params_callback, - should_recv_callback=MessageBasedRcvCallback("livePose", True), + should_recv_callback=MessageBasedRcvCallback("deviceMotion", True), tolerance=NUMPY_TOLERANCE, ), ProcessConfig( proc_name="modeld", - pubs=["deviceState", "roadCameraState", "wideRoadCameraState", "liveCalibration", "liveDelay", "driverMonitoringState", "carState", "carControl"], + pubs=["deviceState", "narrowRoadCameraState", "wideRoadCameraState", "extrinsicsCalibration", "lateralDelay", + "driverMonitoringState", "carState", "carControl"], subs=["modelV2", "drivingModelData", "cameraOdometry"], ignore=["logMonoTime", "modelV2.frameDropPerc", "modelV2.modelExecutionTime", "drivingModelData.frameDropPerc", "drivingModelData.modelExecutionTime"], should_recv_callback=ModeldCameraSyncRcvCallback(), tolerance=NUMPY_TOLERANCE, processing_time=0.020, - main_pub=vipc_get_endpoint_name("camerad", meta_from_camera_state("roadCameraState").stream), - vision_pubs=["roadCameraState", "wideRoadCameraState"], + main_pub=vipc_get_endpoint_name("camerad", meta_from_camera_state("narrowRoadCameraState").stream), + vision_pubs=["narrowRoadCameraState", "wideRoadCameraState"], ignore_alive_pubs=["wideRoadCameraState"], init_callback=get_car_params_callback, ), ProcessConfig( proc_name="dmonitoringmodeld", - pubs=["liveCalibration", "driverCameraState"], + pubs=["extrinsicsCalibration", "cabinCameraState"], subs=["driverStateV2"], ignore=["logMonoTime", "driverStateV2.modelExecutionTime", "driverStateV2.gpuExecutionTime"], - should_recv_callback=MessageBasedRcvCallback("driverCameraState"), + should_recv_callback=MessageBasedRcvCallback("cabinCameraState"), tolerance=NUMPY_TOLERANCE, processing_time=0.020, - main_pub=vipc_get_endpoint_name("camerad", meta_from_camera_state("driverCameraState").stream), - vision_pubs=["driverCameraState"], - ignore_alive_pubs=["driverCameraState"], + main_pub=vipc_get_endpoint_name("camerad", meta_from_camera_state("cabinCameraState").stream), + vision_pubs=["cabinCameraState"], + ignore_alive_pubs=["cabinCameraState"], ), ] @@ -591,30 +591,30 @@ def get_custom_params_from_lr(lr: LogIterable, initial_state: str = "first") -> """ Use this to get custom params dict based on provided logs. Useful when replaying following processes: calibrationd, paramsd, torqued - The params may be based on first or last message of given type (carParams, liveCalibration, liveParameters, liveTorqueParameters) in the logs. + The params may be based on first or last message of given type (carParams, extrinsicsCalibration, vehicleParameters, lateralTorqueParameters) in the logs. """ car_params = [m for m in lr if m.which() == "carParams"] - live_calibration = [m for m in lr if m.which() == "liveCalibration"] - live_parameters = [m for m in lr if m.which() == "liveParameters"] - live_torque_parameters = [m for m in lr if m.which() == "liveTorqueParameters"] + extrinsics_calibration = [m for m in lr if m.which() == "extrinsicsCalibration"] + vehicle_parameters = [m for m in lr if m.which() == "vehicleParameters"] + torque_parameters = [m for m in lr if m.which() == "lateralTorqueParameters"] assert initial_state in ["first", "last"] msg_index = 0 if initial_state == "first" else -1 - assert len(car_params) > 0, "carParams required for initial state of liveParameters and CarParamsPrevRoute" + assert len(car_params) > 0, "carParams required for initial state of vehicleParameters and CarParamsPrevRoute" CP = car_params[msg_index].carParams custom_params = { "CarParamsPrevRoute": CP.as_builder().to_bytes() } - if len(live_calibration) > 0: - custom_params["CalibrationParams"] = live_calibration[msg_index].as_builder().to_bytes() - if len(live_parameters) > 0: - custom_params["LiveParametersV2"] = live_parameters[msg_index].as_builder().to_bytes() - if len(live_torque_parameters) > 0: - custom_params["LiveTorqueParameters"] = live_torque_parameters[msg_index].as_builder().to_bytes() + if len(extrinsics_calibration) > 0: + custom_params["CalibrationParams"] = extrinsics_calibration[msg_index].as_builder().to_bytes() + if len(vehicle_parameters) > 0: + custom_params["LiveParametersV2"] = vehicle_parameters[msg_index].as_builder().to_bytes() + if len(torque_parameters) > 0: + custom_params["LiveTorqueParameters"] = torque_parameters[msg_index].as_builder().to_bytes() return custom_params @@ -635,10 +635,10 @@ def replay_process( fingerprint: str | None = None, return_all_logs: bool = False, custom_params: dict[str, Any] | None = None, captured_output_store: dict[str, dict[str, str]] | None = None, disable_progress: bool = False ) -> list[capnp._DynamicStructReader]: - if isinstance(cfg, Iterable): - cfgs = list(cfg) - else: + if isinstance(cfg, ProcessConfig): cfgs = [cfg] + else: + cfgs = list(cfg) all_msgs = migrate_all(lr, manager_states=True, diff --git a/openpilot/selfdrive/test/process_replay/regen.py b/openpilot/selfdrive/test/process_replay/regen.py deleted file mode 100755 index c501a4b250..0000000000 --- a/openpilot/selfdrive/test/process_replay/regen.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -import os -import argparse -import time -import capnp - -from typing import Any -from collections.abc import Iterable - -from openpilot.selfdrive.test.process_replay.process_replay import CONFIGS, FAKEDATA, ProcessConfig, replay_process, get_process_config, \ - check_openpilot_enabled, check_most_messages_valid, get_custom_params_from_lr -from openpilot.selfdrive.test.update_ci_routes import upload_route -from openpilot.tools.lib.framereader import FrameReader -from openpilot.tools.lib.logreader import LogReader, LogIterable, save_log -from openpilot.tools.lib.openpilotci import get_url - - -def regen_segment( - lr: LogIterable, frs: dict[str, Any] | None = None, - processes: Iterable[ProcessConfig] = CONFIGS, disable_tqdm: bool = False -) -> list[capnp._DynamicStructReader]: - all_msgs = sorted(lr, key=lambda m: m.logMonoTime) - custom_params = get_custom_params_from_lr(all_msgs) - - print("Replayed processes:", [p.proc_name for p in processes]) - print("\n\n", "*"*30, "\n\n", sep="") - - output_logs = replay_process(processes, all_msgs, frs, return_all_logs=True, custom_params=custom_params, disable_progress=disable_tqdm) - - return output_logs - - -def setup_data_readers( - route: str, sidx: int, needs_driver_cam: bool = True, needs_road_cam: bool = True, dummy_driver_cam: bool = False -) -> tuple[LogReader, dict[str, Any]]: - lr = LogReader(f"{route}/{sidx}/r") - frs = {} - if needs_road_cam: - frs['roadCameraState'] = FrameReader(get_url(route, str(sidx), "fcamera.hevc")) - if next((True for m in lr if m.which() == "wideRoadCameraState"), False): - frs['wideRoadCameraState'] = FrameReader(get_url(route, str(sidx), "ecamera.hevc")) - if needs_driver_cam: - if dummy_driver_cam: - frs['driverCameraState'] = FrameReader(get_url(route, str(sidx), "fcamera.hevc")) # Use fcam as dummy - else: - device_type = next(str(msg.initData.deviceType) for msg in lr if msg.which() == "initData") - assert device_type != "neo", "Driver camera not supported on neo segments. Use dummy dcamera." - frs['driverCameraState'] = FrameReader(get_url(route, str(sidx), "dcamera.hevc")) - - return lr, frs - - -def regen_and_save( - route: str, sidx: int, processes: str | Iterable[str] = "all", outdir: str = FAKEDATA, - upload: bool = False, disable_tqdm: bool = False, dummy_driver_cam: bool = False -) -> str: - if not isinstance(processes, str) and not hasattr(processes, "__iter__"): - raise ValueError("whitelist_proc must be a string or iterable") - - if processes != "all": - if isinstance(processes, str): - raise ValueError(f"Invalid value for processes: {processes}") - - replayed_processes = [] - for d in processes: - cfg = get_process_config(d) - replayed_processes.append(cfg) - else: - replayed_processes = CONFIGS - - all_vision_pubs = {pub for cfg in replayed_processes for pub in cfg.vision_pubs} - lr, frs = setup_data_readers(route, sidx, - needs_driver_cam="driverCameraState" in all_vision_pubs, - needs_road_cam="roadCameraState" in all_vision_pubs or "wideRoadCameraState" in all_vision_pubs, - dummy_driver_cam=dummy_driver_cam) - output_logs = regen_segment(lr, frs, replayed_processes, disable_tqdm=disable_tqdm) - - log_dir = os.path.join(outdir, time.strftime("%Y-%m-%d--%H-%M-%S--0", time.gmtime())) - rel_log_dir = os.path.relpath(log_dir) - rpath = os.path.join(log_dir, "rlog.zst") - - os.makedirs(log_dir) - save_log(rpath, output_logs, compress=True) - - print("\n\n", "*"*30, "\n\n", sep="") - print("New route:", rel_log_dir, "\n") - - if not check_openpilot_enabled(output_logs): - raise Exception("Route did not engage for long enough") - if not check_most_messages_valid(output_logs): - raise Exception("Route has too many invalid messages") - - if upload: - upload_route(rel_log_dir) - - return rel_log_dir - - -if __name__ == "__main__": - def comma_separated_list(string): - return string.split(",") - - all_procs = [p.proc_name for p in CONFIGS] - parser = argparse.ArgumentParser(description="Generate new segments from old ones") - parser.add_argument("--upload", action="store_true", help="Upload the new segment to the CI bucket") - parser.add_argument("--outdir", help="log output dir", default=FAKEDATA) - parser.add_argument("--dummy-dcamera", action='store_true', help="Use dummy blank driver camera") - parser.add_argument("--whitelist-procs", type=comma_separated_list, default=all_procs, - help="Comma-separated whitelist of processes to regen (e.g. controlsd,radard)") - parser.add_argument("--blacklist-procs", type=comma_separated_list, default=[], - help="Comma-separated blacklist of processes to regen (e.g. controlsd,radard)") - parser.add_argument("route", type=str, help="The source route") - parser.add_argument("seg", type=int, help="Segment in source route") - args = parser.parse_args() - - blacklist_set = set(args.blacklist_procs) - processes = [p for p in args.whitelist_procs if p not in blacklist_set] - regen_and_save(args.route, args.seg, processes=processes, upload=args.upload, outdir=args.outdir, dummy_driver_cam=args.dummy_dcamera) diff --git a/openpilot/selfdrive/test/process_replay/regen_all.py b/openpilot/selfdrive/test/process_replay/regen_all.py deleted file mode 100755 index 78a90b420c..0000000000 --- a/openpilot/selfdrive/test/process_replay/regen_all.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import concurrent.futures -import os -import random -import traceback -from tqdm import tqdm - -from openpilot.common.prefix import OpenpilotPrefix -from openpilot.selfdrive.test.process_replay.regen import regen_and_save -from openpilot.selfdrive.test.process_replay.test_processes import FAKEDATA, source_segments as segments -from openpilot.tools.lib.route import SegmentName - - -def regen_job(segment, upload, disable_tqdm): - with OpenpilotPrefix(): - sn = SegmentName(segment[1]) - fake_dongle_id = 'regen' + ''.join(random.choice('0123456789ABCDEF') for _ in range(11)) - try: - relr = regen_and_save(sn.route_name.canonical_name, sn.segment_num, upload=upload, - outdir=os.path.join(FAKEDATA, fake_dongle_id), disable_tqdm=disable_tqdm, dummy_driver_cam=True) - relr = '|'.join(relr.split('/')[-2:]) - return f' ("{segment[0]}", "{relr}"), ' - except Exception as e: - err = f" {segment} failed: {str(e)}" - err += traceback.format_exc() - err += "\n\n" - return err - - -if __name__ == "__main__": - all_cars = {car for car, _ in segments} - - parser = argparse.ArgumentParser(description="Generate new segments from old ones") - parser.add_argument("-j", "--jobs", type=int, default=1) - parser.add_argument("--no-upload", action="store_true") - parser.add_argument("--whitelist-cars", type=str, nargs="*", default=all_cars, - help="Whitelist given cars from the test (e.g. HONDA)") - parser.add_argument("--blacklist-cars", type=str, nargs="*", default=[], - help="Blacklist given cars from the test (e.g. HONDA)") - args = parser.parse_args() - - tested_cars = set(args.whitelist_cars) - set(args.blacklist_cars) - tested_cars = {c.upper() for c in tested_cars} - tested_segments = [(car, segment) for car, segment in segments if car in tested_cars] - - with concurrent.futures.ProcessPoolExecutor(max_workers=args.jobs) as pool: - p = pool.map(regen_job, tested_segments, [not args.no_upload] * len(tested_segments), [args.jobs > 1] * len(tested_segments)) - msg = "Copy these new segments into test_processes.py:" - for seg in tqdm(p, desc="Generating segments", total=len(tested_segments)): - msg += "\n" + str(seg) - print() - print() - print(msg) diff --git a/openpilot/selfdrive/test/process_replay/test_fuzzy.py b/openpilot/selfdrive/test/process_replay/test_fuzzy.py index 372b368608..3a87f3a7f8 100644 --- a/openpilot/selfdrive/test/process_replay/test_fuzzy.py +++ b/openpilot/selfdrive/test/process_replay/test_fuzzy.py @@ -1,12 +1,10 @@ import copy -import os -from hypothesis import given, HealthCheck, Phase, settings -import hypothesis.strategies as st +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized +from openpilot.common.fuzzy import capnp_random_dict, fuzzy_test from openpilot.cereal import log from opendbc.car.toyota.values import CAR as TOYOTA -from openpilot.selfdrive.test.fuzzy_generation import FuzzyGenerator import openpilot.selfdrive.test.process_replay.process_replay as pr # These processes currently fail because of unrealistic data breaking assumptions @@ -15,17 +13,16 @@ import openpilot.selfdrive.test.process_replay.process_replay as pr NOT_TESTED = ['selfdrived', 'controlsd', 'card', 'plannerd', 'calibrationd', 'dmonitoringd', 'paramsd', 'dmonitoringmodeld', 'modeld'] TEST_CASES = [(cfg.proc_name, copy.deepcopy(cfg)) for cfg in pr.CONFIGS if cfg.proc_name not in NOT_TESTED] -MAX_EXAMPLES = int(os.environ.get("MAX_EXAMPLES", "10")) -class TestFuzzProcesses: +class TestFuzzProcesses(OpenpilotTestCase): # TODO: make this faster and increase examples @parameterized.expand(TEST_CASES) - @given(st.data()) - @settings(phases=[Phase.generate, Phase.target], max_examples=MAX_EXAMPLES, deadline=1000, - suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large]) - def test_fuzz_process(self, proc_name, cfg, data): - msgs = FuzzyGenerator.get_random_event_msg(data.draw, events=cfg.pubs, real_floats=True) + @fuzzy_test(max_examples=10) + def test_fuzz_process(self, proc_name, cfg, fuzzy): + msgs = [capnp_random_dict(fuzzy, log.Event.schema, event, real_floats=True) for event in sorted(cfg.pubs)] + for i, msg in enumerate(msgs): + msg["logMonoTime"] = i * 1_000_000_000 lr = [log.Event.new_message(**m).as_reader() for m in msgs] cfg.timeout = 5 pr.replay_process(cfg, lr, fingerprint=TOYOTA.TOYOTA_COROLLA_TSS2, disable_progress=True) diff --git a/openpilot/selfdrive/test/process_replay/test_processes.py b/openpilot/selfdrive/test/process_replay/test_processes.py index d9d827add5..5b72f7b712 100755 --- a/openpilot/selfdrive/test/process_replay/test_processes.py +++ b/openpilot/selfdrive/test/process_replay/test_processes.py @@ -7,7 +7,7 @@ import traceback from collections import defaultdict from tqdm import tqdm from typing import Any -from opendbc.car.car_helpers import interface_names +from opendbc.car.car_helpers import interface_names, interfaces from openpilot.common.git import get_commit from openpilot.tools.lib.openpilotci import get_url from openpilot.selfdrive.test.process_replay.compare_logs import compare_logs, format_diff @@ -64,9 +64,10 @@ segments = [ ] # dashcamOnly makes don't need to be tested until a full port is done -excluded_interfaces = ["mock", "body", "psa"] +excluded_interfaces = {brand for brand, platforms in interface_names.items() + if all(interfaces[platform].get_non_essential_params(platform).dashcamOnly for platform in platforms)} | {"body"} -BASE_URL = "https://raw.githubusercontent.com/commaai/ci-artifacts/refs/heads/process-replay/" +BASE_URL = "https://raw.githubusercontent.com/sunnypilot/ci-artifacts/refs/heads/process-replay/" REF_COMMIT_FN = os.path.join(PROC_REPLAY_DIR, "ref_commit") EXCLUDED_PROCS = {"modeld", "dmonitoringmodeld"} diff --git a/openpilot/selfdrive/test/process_replay/test_regen.py b/openpilot/selfdrive/test/process_replay/test_regen.py deleted file mode 100644 index f4942e486c..0000000000 --- a/openpilot/selfdrive/test/process_replay/test_regen.py +++ /dev/null @@ -1,37 +0,0 @@ -from openpilot.common.parameterized import parameterized - -from openpilot.selfdrive.test.process_replay.regen import regen_segment -from openpilot.selfdrive.test.process_replay.process_replay import check_openpilot_enabled -from openpilot.tools.lib.openpilotci import get_url -from openpilot.tools.lib.logreader import LogReader -from openpilot.tools.lib.framereader import FrameReader - -TESTED_SEGMENTS = [ - ("PRIUS_C2", "0982d79ebb0de295|2021-01-04--17-13-21--13"), # TOYOTA.TOYOTA_PRIUS: NEO, pandaStateDEPRECATED, no peripheralState, sensorEventsDEPRECATED - # Enable these once regen on CI becomes faster or use them for different tests running controlsd in isolation - # ("MAZDA_C3", "bd6a637565e91581|2021-10-30--15-14-53--4"), # MAZDA.CX9_2021: TICI, incomplete managerState - # ("FORD_C3", "54827bf84c38b14f|2023-01-26--21-59-07--4"), # FORD.BRONCO_SPORT_MK1: TICI -] - - -def ci_setup_data_readers(route, sidx): - lr = LogReader(get_url(route, sidx, "rlog.bz2")) - frs = { - 'roadCameraState': FrameReader(get_url(route, sidx, "fcamera.hevc")), - 'driverCameraState': FrameReader(get_url(route, sidx, "fcamera.hevc")), - } - if next((True for m in lr if m.which() == "wideRoadCameraState"), False): - frs["wideRoadCameraState"] = FrameReader(get_url(route, sidx, "ecamera.hevc")) - - return lr, frs - - -class TestRegen: - @parameterized.expand(TESTED_SEGMENTS) - def test_engaged(self, case_name, segment): - route, sidx = segment.rsplit("--", 1) - lr, frs = ci_setup_data_readers(route, sidx) - output_logs = regen_segment(lr, frs, disable_tqdm=True) - - engaged = check_openpilot_enabled(output_logs) - assert engaged, f"openpilot not engaged in {case_name}" diff --git a/openpilot/selfdrive/test/process_replay/vision_meta.py b/openpilot/selfdrive/test/process_replay/vision_meta.py index 12deb58724..6942d88367 100644 --- a/openpilot/selfdrive/test/process_replay/vision_meta.py +++ b/openpilot/selfdrive/test/process_replay/vision_meta.py @@ -1,17 +1,17 @@ from collections import namedtuple -from msgq.visionipc import VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType from openpilot.common.realtime import DT_MDL, DT_DMON from openpilot.common.transformations.camera import DEVICE_CAMERAS VideoStreamMeta = namedtuple("VideoStreamMeta", ["camera_state", "encode_index", "stream", "dt", "frame_sizes"]) -ROAD_CAMERA_FRAME_SIZES = {k: (v.dcam.width, v.dcam.height) for k, v in DEVICE_CAMERAS.items()} -WIDE_ROAD_CAMERA_FRAME_SIZES = {k: (v.ecam.width, v.ecam.height) for k, v in DEVICE_CAMERAS.items() if v.ecam is not None} -DRIVER_CAMERA_FRAME_SIZES = {k: (v.dcam.width, v.dcam.height) for k, v in DEVICE_CAMERAS.items()} +NARROW_ROAD_CAMERA_FRAME_SIZES = {k: (v.narrow_road.width, v.narrow_road.height) for k, v in DEVICE_CAMERAS.items()} +WIDE_ROAD_CAMERA_FRAME_SIZES = {k: (v.wide_road.width, v.wide_road.height) for k, v in DEVICE_CAMERAS.items() if v.wide_road is not None} +CABIN_CAMERA_FRAME_SIZES = {k: (v.cabin.width, v.cabin.height) for k, v in DEVICE_CAMERAS.items()} VIPC_STREAM_METADATA = [ # metadata: (state_msg_type, encode_msg_type, stream_type, dt, frame_sizes) - ("roadCameraState", "roadEncodeIdx", VisionStreamType.VISION_STREAM_ROAD, DT_MDL, ROAD_CAMERA_FRAME_SIZES), + ("narrowRoadCameraState", "narrowRoadEncodeIdx", VisionStreamType.VISION_STREAM_NARROW_ROAD, DT_MDL, NARROW_ROAD_CAMERA_FRAME_SIZES), ("wideRoadCameraState", "wideRoadEncodeIdx", VisionStreamType.VISION_STREAM_WIDE_ROAD, DT_MDL, WIDE_ROAD_CAMERA_FRAME_SIZES), - ("driverCameraState", "driverEncodeIdx", VisionStreamType.VISION_STREAM_DRIVER, DT_DMON, DRIVER_CAMERA_FRAME_SIZES), + ("cabinCameraState", "cabinEncodeIdx", VisionStreamType.VISION_STREAM_CABIN, DT_DMON, CABIN_CAMERA_FRAME_SIZES), ] diff --git a/openpilot/selfdrive/test/test_onroad.py b/openpilot/selfdrive/test/test_onroad.py old mode 100644 new mode 100755 index b15d890452..f524046abe --- a/openpilot/selfdrive/test/test_onroad.py +++ b/openpilot/selfdrive/test/test_onroad.py @@ -1,13 +1,16 @@ +#!/usr/bin/env python3 + import math import json import os -import pytest import shutil import subprocess import time +import unittest import numpy as np from collections import Counter, defaultdict from pathlib import Path +from openpilot.common.test import OpenpilotTestCase from openpilot.common.utils import tabulate from openpilot.cereal import log @@ -54,7 +57,6 @@ PROCS = { "openpilot.selfdrive.locationd.paramsd": 9.0, "openpilot.selfdrive.locationd.lagd": 11.0, "openpilot.selfdrive.ui.soundd": 3.0, - "openpilot.selfdrive.ui.feedback.feedbackd": 1.0, "openpilot.selfdrive.monitoring.dmonitoringd": 4.0, "openpilot.system.proclogd": 7.0, "openpilot.system.logmessaged": 1.0, @@ -65,9 +67,9 @@ PROCS = { "openpilot.selfdrive.pandad.pandad": 0, "openpilot.system.loggerd.uploader": 15.0, "openpilot.system.loggerd.deleter": 1.0, - "./pandad": 19.0, + "./pandad": 40.0, "openpilot.system.qcomgpsd.qcomgpsd": 1.0, - "openpilot.common.hardware.tici.modem": 10.0, + "openpilot.common.hardware.comma.modem": 10.0, } TIMINGS = { @@ -81,12 +83,12 @@ TIMINGS = { "controlsState": [2.5, 0.35], "longitudinalPlan": [2.5, 0.5], "driverAssistance": [2.5, 0.5], - "roadCameraState": [2.5, 0.35], - "driverCameraState": [2.5, 0.35], + "narrowRoadCameraState": [2.5, 0.35], + "cabinCameraState": [2.5, 0.35], "modelV2": [2.5, 0.35], "driverStateV2": [2.5, 0.40], - "livePose": [2.5, 0.35], - "liveParameters": [2.5, 0.35], + "deviceMotion": [2.5, 0.35], + "vehicleParameters": [2.5, 0.35], "wideRoadCameraState": [1.5, 0.35], } @@ -102,9 +104,12 @@ def cputime_total(ct): return ct.cpuUser + ct.cpuSystem + ct.cpuChildrenUser + ct.cpuChildrenSystem -@pytest.mark.tici -@pytest.mark.skip_tici_setup -class TestOnroad: +class TestOnroad(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True + + def setUp(self): + # Hardware setup is handled once for the full onroad test in setup_class. + unittest.TestCase.setUp(self) @classmethod def setup_class(cls): @@ -303,7 +308,7 @@ class TestOnroad: result += "------------------------------------------------\n" result += "----------------- SOF Timing ------------------\n" result += "------------------------------------------------\n" - for name in ['roadCameraState', 'wideRoadCameraState', 'driverCameraState']: + for name in ['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']: ts = self.ts[name]['timestampSof'] d_ms = np.diff(ts) / 1e6 d50 = np.abs(d_ms-50) @@ -316,8 +321,8 @@ class TestOnroad: print(result) def test_camera_sync(self, subtests): - cam_states = ['roadCameraState', 'wideRoadCameraState', 'driverCameraState'] - encode_cams = ['roadEncodeIdx', 'wideRoadEncodeIdx', 'driverEncodeIdx'] + cam_states = ['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState'] + encode_cams = ['narrowRoadEncodeIdx', 'wideRoadEncodeIdx', 'cabinEncodeIdx'] for cams in (cam_states, encode_cams): with subtests.test(cams=cams): # sanity checks within a single cam @@ -331,32 +336,40 @@ class TestOnroad: assert np.all(eof_sof_diff < 50*1e6) first_fid = {min(self.ts[c]['frameId']) for c in cams} - assert len(first_fid) == 1, "Cameras don't start on same frame ID" - if cam.endswith('CameraState'): + if cams[0].endswith('CameraState'): # camerad guarantees that all cams start on frame ID 0 # (note loggerd also needs to start up fast enough to catch it) - assert next(iter(first_fid)) < 100, "Cameras start on frame ID too high" + assert min(first_fid) < 100, "Cameras start on frame ID too high" + else: + # encoderd synchronizes all camera encoders to the same starting frame + assert len(first_fid) == 1, "Camera encoders don't start on same frame ID" # we don't do a full segment rotation, so these might not match exactly last_fid = {max(self.ts[c]['frameId']) for c in cams} assert max(last_fid) - min(last_fid) < 10 - start, end = min(first_fid), min(last_fid) - for i in range(end-start): - # road and wide cameras (first two) should be synced within 2ms - ts = {c: round(self.ts[c]['timestampSof'][i]/1e6, 1) for c in cams[:2]} - diff = (max(ts.values()) - min(ts.values())) - assert diff < 2, f"Cameras not synced properly: frame_id={start+i}, {diff=:.1f}ms, {ts=}" + timestamps = { + cam: dict(zip(self.ts[cam]['frameId'], self.ts[cam]['timestampSof'], strict=True)) + for cam in cams + } + common_frame_ids = set.intersection(*(set(ts) for ts in timestamps.values())) + assert common_frame_ids, "Cameras have no overlapping frame IDs" - # driver camera should be staggered ~25ms from road camera - offset_ms = abs(self.ts[cams[2]]['timestampSof'][i] - self.ts[cams[0]]['timestampSof'][i]) / 1e6 - assert 20 < offset_ms < 30, f"driver camera stagger out of range at frame {start+i}: {offset_ms:.1f}ms" + for frame_id in sorted(common_frame_ids): + # road and wide cameras (first two) should be synced within 2ms + ts = {cam: timestamps[cam][frame_id] / 1e6 for cam in cams[:2]} + diff = max(ts.values()) - min(ts.values()) + assert diff < 2, f"Cameras not synced properly: {frame_id=}, {diff=:.1f}ms, {ts=}" + + # cabin camera should be staggered ~25ms from road camera + offset_ms = abs(timestamps[cams[2]][frame_id] - timestamps[cams[0]][frame_id]) / 1e6 + assert 20 < offset_ms < 30, f"cabin camera stagger out of range at frame {frame_id}: {offset_ms:.1f}ms" def test_camera_encoder_matches(self, subtests): # sanity check that the frame metadata is consistent with the encoded frames - pairs = [('roadCameraState', 'roadEncodeIdx'), + pairs = [('narrowRoadCameraState', 'narrowRoadEncodeIdx'), ('wideRoadCameraState', 'wideRoadEncodeIdx'), - ('driverCameraState', 'driverEncodeIdx')] + ('cabinCameraState', 'cabinEncodeIdx')] for cam, enc in pairs: with subtests.test(camera=cam, encoder=enc): cam_frames = {fid: (sof, eof) for fid, sof, eof in zip( @@ -442,3 +455,7 @@ class TestOnroad: eng = [m.selfdriveState.engageable for m in self.msgs['selfdriveState'][offset:]] assert all(eng), \ f"Not engageable for whole segment:\n- selfdriveState.engageable: {Counter(eng)}\n- No entry events: {no_entries}" + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/selfdrive/test/test_power_draw.py b/openpilot/selfdrive/test/test_power_draw.py old mode 100644 new mode 100755 index 9bc012ceaa..1d0be24ef0 --- a/openpilot/selfdrive/test/test_power_draw.py +++ b/openpilot/selfdrive/test/test_power_draw.py @@ -1,8 +1,11 @@ +#!/usr/bin/env python3 + from collections import defaultdict, deque -import pytest import time +import unittest import numpy as np from dataclasses import dataclass +from openpilot.common.test import OpenpilotTestCase from openpilot.common.utils import tabulate import openpilot.cereal.messaging as messaging @@ -10,7 +13,7 @@ from openpilot.cereal.services import SERVICE_LIST from opendbc.car.car_helpers import get_demo_car_params from openpilot.common.mock import mock_messages from openpilot.common.params import Params -from openpilot.common.hardware.tici.power_monitor import get_power +from openpilot.common.hardware.comma.power_monitor import get_power from openpilot.system.manager.process_config import managed_processes from openpilot.system.manager.manager import manager_cleanup @@ -31,15 +34,15 @@ class Proc: PROCS = [ - Proc(['camerad'], 1.65, atol=0.4, msgs=['roadCameraState', 'wideRoadCameraState', 'driverCameraState']), + Proc(['camerad'], 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']), Proc(['modeld'], 1.5, atol=0.2, msgs=['modelV2']), Proc(['dmonitoringmodeld'], 0.65, atol=0.35, msgs=['driverStateV2']), Proc(['encoderd'], 0.23, msgs=[]), ] -@pytest.mark.tici -class TestPowerDraw: +class TestPowerDraw(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True def setup_method(self): Params().put("CarParams", get_demo_car_params().to_bytes(), block=True) @@ -92,7 +95,7 @@ class TestPowerDraw: return now, msg_counts, time.monotonic() - start_time - SAMPLE_TIME - @mock_messages(['livePose']) + @mock_messages(['deviceMotion']) def test_camera_procs(self, subtests): baseline = get_power() @@ -123,3 +126,7 @@ class TestPowerDraw: assert self.valid_power_draw(proc, cur), f"expected {expected:.2f}W, got {cur:.2f}W" print(tabulate(tab)) print(f"Baseline {baseline:.2f}W\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/selfdrive/ui/SConscript b/openpilot/selfdrive/ui/SConscript index d187b4ca3c..b5c81a9009 100644 --- a/openpilot/selfdrive/ui/SConscript +++ b/openpilot/selfdrive/ui/SConscript @@ -1,24 +1,9 @@ -from pathlib import Path import importlib.util +from pathlib import Path Import('env', 'arch', 'common') -# build the fonts -generator = File("#openpilot/selfdrive/assets/fonts/process.py") -source_files = Glob("#openpilot/selfdrive/assets/fonts/*.ttf") + Glob("#openpilot/selfdrive/assets/fonts/*.otf") -output_files = [ - (f"#{Path(f.path).with_suffix('.fnt')}", f"#{Path(f.path).with_suffix('.png')}") - for f in source_files - if "NotoColor" not in f.name -] -env.Command( - target=output_files, - source=[generator, source_files], - action=f"python3 {generator}", -) - - -if GetOption('extras') and arch == "larch64": +if GetOption('extras') and arch == "comma_arm64": # build installers raylib_dir = Path(importlib.util.find_spec("raylib").submodule_search_locations[0]) / "install" raylib_env = env.Clone() diff --git a/openpilot/selfdrive/ui/body/animations.py b/openpilot/selfdrive/ui/body/animations.py index c40f7ecdef..3aecd52e97 100644 --- a/openpilot/selfdrive/ui/body/animations.py +++ b/openpilot/selfdrive/ui/body/animations.py @@ -87,21 +87,12 @@ BROW_LOWERED = [ (2, 0) ] BROW_STRAIGHT = [(1, 0), (1, 1), (1, 2)] -BROW_DOWN = [ -(0, 1), (0, 2), - (1, 3) -] - # Mouths (centered, not mirrored) MOUTH_SMILE = [ (6, 6), (6, 9), (7, 7), (7, 8), ] MOUTH_NORMAL = [(7, 7), (7, 8)] -MOUTH_SAD = [ - (6, 7), (6, 8), -(7, 6), (7, 9) -] # --- Animations --- @@ -168,16 +159,6 @@ INQUISITIVE = Animation( repeat_interval=10 ) -WINK = Animation( - frames=[ - _make_frame(EYE_OPEN, _mirror(EYE_OPEN), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), - _make_frame(EYE_OPEN, _mirror(EYE_CLOSED), BROW_HIGH, _mirror(_shift(BROW_DOWN, (0, 2))), MOUTH_SMILE), - ], - mode=AnimationMode.ONCE_FORWARD_BACKWARD, - frame_duration=0.75, -) - - # --- Face Animator Class --- class FaceAnimator: @@ -204,7 +185,10 @@ class FaceAnimator: frames_back = round(rewind_elapsed / self._animation.frame_duration) frame_index = self._rewind_from - frames_back if frame_index <= 0: - return self._switch_to_next(now) + if self._next is None: + self._rewinding = False + return self._animation.frames[0] + return self._switch_to_next(now, self._next) return self._animation.frames[frame_index] # Play starting frames first (once) @@ -223,7 +207,7 @@ class FaceAnimator: if self._next is not None: if frame_index == 0 and (len(self._animation.frames) == 1 or self._seen_nonzero): - return self._switch_to_next(now) + return self._switch_to_next(now, self._next) # No natural return to frame 0 — start rewinding if self._animation.mode in (AnimationMode.ONCE_FORWARD, AnimationMode.REPEAT_FORWARD): self._rewinding = True @@ -232,8 +216,8 @@ class FaceAnimator: return self._animation.frames[frame_index] - def _switch_to_next(self, now: float) -> list[tuple[int, int]]: - self._animation = self._next + def _switch_to_next(self, now: float, animation: Animation) -> list[tuple[int, int]]: + self._animation = animation self._next = None self._rewinding = False self._seen_nonzero = False diff --git a/openpilot/selfdrive/ui/body/layouts/onroad.py b/openpilot/selfdrive/ui/body/layouts/onroad.py index d7e9f419cc..a48e525628 100644 --- a/openpilot/selfdrive/ui/body/layouts/onroad.py +++ b/openpilot/selfdrive/ui/body/layouts/onroad.py @@ -67,8 +67,8 @@ class BodyLayout(Widget): self._animator.set_animation(ASLEEP) steer = sm['testJoystick'].axes[1] if len(sm['testJoystick'].axes) > 1 else 0 - self._turning_left = steer <= -0.05 - self._turning_right = steer >= 0.05 + self._turning_left = steer >= 0.05 + self._turning_right = steer <= -0.05 # play animation on screen tap def _handle_mouse_release(self, mouse_pos): diff --git a/openpilot/selfdrive/ui/feedback/feedbackd.py b/openpilot/selfdrive/ui/feedback/feedbackd.py deleted file mode 100755 index 8056a580c6..0000000000 --- a/openpilot/selfdrive/ui/feedback/feedbackd.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -import openpilot.cereal.messaging as messaging -from openpilot.common.params import Params -from openpilot.common.swaglog import cloudlog -from opendbc.car.structs import car -from openpilot.system.micd import SAMPLE_RATE, SAMPLE_BUFFER - -FEEDBACK_MAX_DURATION = 10.0 -ButtonType = car.CarState.ButtonEvent.Type - - -def main(): - params = Params() - pm = messaging.PubMaster(['userBookmark', 'audioFeedback']) - sm = messaging.SubMaster(['rawAudioData', 'bookmarkButton', 'carState', 'selfdriveStateSP']) - should_record_audio = False - block_num = 0 - waiting_for_release = False - early_stop_triggered = False - - while True: - sm.update() - should_send_bookmark = False - - # TODO: https://github.com/commaai/openpilot/issues/36015 - # only allow the LKAS button to record feedback when MADS is disabled - if False and sm.updated['carState'] and sm['carState'].canValid and not sm['selfdriveStateSP'].mads.available: - for be in sm['carState'].buttonEvents: - if be.type == ButtonType.lkas: - if be.pressed: - if not should_record_audio: - if params.get_bool("RecordAudioFeedback"): # Start recording on first press if toggle set - should_record_audio = True - block_num = 0 - waiting_for_release = False - early_stop_triggered = False - cloudlog.info("LKAS button pressed - starting 10-second audio feedback") - else: - should_send_bookmark = True # immediately send bookmark if toggle false - cloudlog.info("LKAS button pressed - bookmarking") - elif should_record_audio and not waiting_for_release: # Wait for release of second press to stop recording early - waiting_for_release = True - elif waiting_for_release: # Second press released - waiting_for_release = False - early_stop_triggered = True - cloudlog.info("LKAS button released - ending recording early") - - if should_record_audio and sm.updated['rawAudioData']: - raw_audio = sm['rawAudioData'] - msg = messaging.new_message('audioFeedback', valid=True) - msg.audioFeedback.audio.data = raw_audio.data - msg.audioFeedback.audio.sampleRate = raw_audio.sampleRate - msg.audioFeedback.blockNum = block_num - block_num += 1 - if (block_num * SAMPLE_BUFFER / SAMPLE_RATE) >= FEEDBACK_MAX_DURATION or early_stop_triggered: # Check for timeout or early stop - should_send_bookmark = True # send bookmark at end of audio segment - should_record_audio = False - early_stop_triggered = False - cloudlog.info("10-second recording completed or second button press - stopping audio feedback") - pm.send('audioFeedback', msg) - - if sm.updated['bookmarkButton']: - cloudlog.info("Bookmark button pressed!") - should_send_bookmark = True - - if should_send_bookmark: - msg = messaging.new_message('userBookmark', valid=True) - pm.send('userBookmark', msg) - - -if __name__ == '__main__': - main() diff --git a/openpilot/selfdrive/ui/layouts/main.py b/openpilot/selfdrive/ui/layouts/main.py index 134e925b16..c4224d6467 100644 --- a/openpilot/selfdrive/ui/layouts/main.py +++ b/openpilot/selfdrive/ui/layouts/main.py @@ -13,6 +13,7 @@ from openpilot.selfdrive.ui.body.layouts.onroad import BodyLayout if gui_app.sunnypilot_ui(): from openpilot.selfdrive.ui.sunnypilot.layouts.settings.settings import SettingsLayoutSP as SettingsLayout + from openpilot.selfdrive.ui.sunnypilot.layouts.home import HomeLayoutSP as HomeLayout class MainState(IntEnum): @@ -25,7 +26,7 @@ class MainLayout(Widget): def __init__(self): super().__init__() - self._pm = messaging.PubMaster(['bookmarkButton']) + self._pm = messaging.PubMaster(['bookmarkButton', 'userBookmark']) self._sidebar = Sidebar() self._current_mode = MainState.HOME @@ -34,7 +35,11 @@ class MainLayout(Widget): # Initialize layouts self._home_layout = HomeLayout() self._home_body_layout = BodyLayout() - self._layouts = {MainState.HOME: self._home_layout, MainState.SETTINGS: SettingsLayout(), MainState.ONROAD: AugmentedRoadView()} + self._layouts: dict[MainState, Widget] = { + MainState.HOME: self._home_layout, + MainState.SETTINGS: SettingsLayout(), + MainState.ONROAD: AugmentedRoadView(), + } self._sidebar_rect = rl.Rectangle(0, 0, 0, 0) self._content_rect = rl.Rectangle(0, 0, 0, 0) @@ -110,9 +115,9 @@ class MainLayout(Widget): self.open_settings(PanelType.DEVICE) def _on_bookmark_clicked(self): - user_bookmark = messaging.new_message('bookmarkButton') - user_bookmark.valid = True - self._pm.send('bookmarkButton', user_bookmark) + for service in ('bookmarkButton', 'userBookmark'): + msg = messaging.new_message(service, valid=True) + self._pm.send(service, msg) def _on_onroad_clicked(self): self._sidebar.set_visible(not self._sidebar.is_visible) diff --git a/openpilot/selfdrive/ui/layouts/settings/device.py b/openpilot/selfdrive/ui/layouts/settings/device.py index 5671f309e3..5be78cf652 100644 --- a/openpilot/selfdrive/ui/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/layouts/settings/device.py @@ -5,7 +5,7 @@ from openpilot.cereal import messaging, log from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog -from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog +from openpilot.selfdrive.ui.onroad.cabin_camera_dialog import CabinCameraDialog from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.layouts.onboarding import TrainingGuide from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog @@ -61,7 +61,7 @@ class DeviceLayout(Widget): text_item(lambda: tr("Serial"), self._params.get("HardwareSerial") or (lambda: tr("N/A"))), self._pair_device_btn, button_item(lambda: tr("Driver Camera"), lambda: tr("PREVIEW"), lambda: tr(DESCRIPTIONS['driver_camera']), - callback=lambda: gui_app.push_widget(DriverCameraDialog()), enabled=ui_state.is_offroad), + callback=lambda: gui_app.push_widget(CabinCameraDialog()), enabled=ui_state.is_offroad), self._reset_calib_btn, button_item(lambda: tr("Review Training Guide"), lambda: tr("REVIEW"), lambda: tr(DESCRIPTIONS['review_guide']), self._on_review_training_guide, enabled=ui_state.is_offroad), @@ -105,7 +105,6 @@ class DeviceLayout(Widget): self._params.remove("CalibrationParams") self._params.remove("LiveTorqueParameters") - self._params.remove("LiveParameters") self._params.remove("LiveParametersV2") self._params.remove("LiveDelay") self._params.put_bool("OnroadCycleRequested", True, block=True) @@ -120,9 +119,9 @@ class DeviceLayout(Widget): calib_bytes = self._params.get("CalibrationParams") if calib_bytes: try: - calib = messaging.log_from_bytes(calib_bytes, log.Event).liveCalibration + calib = messaging.log_from_bytes(calib_bytes, log.Event).extrinsicsCalibration - if calib.calStatus != log.LiveCalibrationData.Status.uncalibrated: + if calib.calStatus != log.ExtrinsicsCalibration.Status.uncalibrated: pitch = math.degrees(calib.rpyCalib[1]) yaw = math.degrees(calib.rpyCalib[2]) desc += tr(" Your device is pointed {:.1f}° {} and {:.1f}° {}.").format(abs(pitch), tr("down") if pitch > 0 else tr("up"), @@ -134,7 +133,7 @@ class DeviceLayout(Widget): lag_bytes = self._params.get("LiveDelay") if lag_bytes: try: - lag_perc = messaging.log_from_bytes(lag_bytes, log.Event).liveDelay.calPerc + lag_perc = messaging.log_from_bytes(lag_bytes, log.Event).lateralDelay.calPerc except Exception: cloudlog.exception("invalid LiveDelay") if lag_perc < 100: @@ -145,7 +144,7 @@ class DeviceLayout(Widget): torque_bytes = self._params.get("LiveTorqueParameters") if torque_bytes: try: - torque = messaging.log_from_bytes(torque_bytes, log.Event).liveTorqueParameters + torque = messaging.log_from_bytes(torque_bytes, log.Event).lateralTorqueParameters # don't add for non-torque cars if torque.useParams: torque_perc = torque.calPerc diff --git a/openpilot/selfdrive/ui/layouts/settings/settings.py b/openpilot/selfdrive/ui/layouts/settings/settings.py index 68f45df77d..48b75e5dbd 100644 --- a/openpilot/selfdrive/ui/layouts/settings/settings.py +++ b/openpilot/selfdrive/ui/layouts/settings/settings.py @@ -1,5 +1,5 @@ import pyray as rl -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import IntEnum from collections.abc import Callable from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout @@ -43,7 +43,7 @@ class PanelType(IntEnum): class PanelInfo: name: str instance: Widget - button_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0) + button_rect: rl.Rectangle = field(default_factory=lambda: rl.Rectangle(0, 0, 0, 0)) class SettingsLayout(Widget): diff --git a/openpilot/selfdrive/ui/layouts/sidebar.py b/openpilot/selfdrive/ui/layouts/sidebar.py index b892e5bfe8..5429a35851 100644 --- a/openpilot/selfdrive/ui/layouts/sidebar.py +++ b/openpilot/selfdrive/ui/layouts/sidebar.py @@ -68,7 +68,7 @@ class Sidebar(Widget, SidebarSP): def __init__(self): Widget.__init__(self) SidebarSP.__init__(self) - self._net_type = NETWORK_TYPES.get(NetworkType.none) + self._net_type = NETWORK_TYPES[NetworkType.none] self._net_strength = 0 self._temp_status = MetricData(tr_noop("TEMP"), tr_noop("GOOD"), Colors.GOOD) @@ -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 1f05958781..fce3db4605 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -9,7 +9,7 @@ from openpilot.system.ui.widgets.layouts import HBoxLayout from openpilot.system.ui.widgets.icon_widget import IconWidget from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos -from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.ui_state import ui_state, ChestnutState from openpilot.common.version import RELEASE_BRANCHES HEAD_BUTTON_FONT_SIZE = 40 @@ -139,8 +139,8 @@ class MiciHomeLayout(Widget): self._version_text = self._get_version_text() self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) - self._egpu_icon = IconWidget("icons_mici/egpu.png", (50, 37)) - self._egpu_icon_gray = IconWidget("icons_mici/egpu_gray.png", (50, 37)) + self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (68, 40)) + self._chestnut_failed_icon = IconWidget("icons_mici/chestnut_orange.png", (68, 40)) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) self._body_icon = IconWidget("icons_mici/body.png", (54, 37)) @@ -150,8 +150,8 @@ class MiciHomeLayout(Widget): IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9), NetworkIcon(), self._experimental_icon, - self._egpu_icon, - self._egpu_icon_gray, + self._chestnut_icon, + self._chestnut_failed_icon, self._body_icon, self._mic_icon, ], spacing=18) @@ -174,7 +174,7 @@ class MiciHomeLayout(Widget): if self._mouse_down_t is not None: if time.monotonic() - self._mouse_down_t > 0.5: # long gating for experimental mode - only allow toggle if longitudinal control is available - if ui_state.has_longitudinal_control: + if ui_state.has_longitudinal_control and ui_state.experimental_mode_confirmed: ui_state.experimental_mode = not ui_state.experimental_mode ui_state.params.put("ExperimentalMode", ui_state.experimental_mode, block=True) self._mouse_down_t = None @@ -248,10 +248,13 @@ class MiciHomeLayout(Widget): # ***** Center-aligned bottom section icons ***** self._experimental_icon.set_visible(ui_state.experimental_mode) - self._egpu_icon.set_visible(ui_state.usbgpu and ui_state.usbgpu_compiled) - self._egpu_icon_gray.set_visible(ui_state.usbgpu and not ui_state.usbgpu_compiled) + if gui_app.sunnypilot_ui(): + self._set_chestnut_visibility() + else: + self._chestnut_icon.set_visible(ui_state.chestnut_state in (ChestnutState.READY, ChestnutState.LOADING, ChestnutState.ACTIVE)) + self._chestnut_failed_icon.set_visible(ui_state.chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED)) self._mic_icon.set_visible(ui_state.recording_audio) - self._body_icon.set_visible(ui_state.is_body) + self._body_icon.set_visible(bool(ui_state.is_body)) footer_rect = rl.Rectangle(self.rect.x + HOME_PADDING, self.rect.y + self.rect.height - 48, self.rect.width - HOME_PADDING, 48) self._status_bar_layout.render(footer_rect) diff --git a/openpilot/selfdrive/ui/mici/layouts/main.py b/openpilot/selfdrive/ui/mici/layouts/main.py index 47e3779ab2..7b96366894 100644 --- a/openpilot/selfdrive/ui/mici/layouts/main.py +++ b/openpilot/selfdrive/ui/mici/layouts/main.py @@ -13,6 +13,7 @@ from openpilot.system.ui.lib.application import gui_app if gui_app.sunnypilot_ui(): from openpilot.selfdrive.ui.sunnypilot.mici.layouts.settings import SettingsLayoutSP as SettingsLayout + from openpilot.selfdrive.ui.sunnypilot.mici.layouts.home import MiciHomeLayoutSP as MiciHomeLayout ONROAD_DELAY = 2.5 # seconds @@ -21,7 +22,7 @@ class MiciMainLayout(Scroller): def __init__(self): super().__init__(snap_items=True, spacing=0, pad=0, scroll_indicator=False, edge_shadows=False) - self._pm = messaging.PubMaster(['bookmarkButton']) + self._pm = messaging.PubMaster(['bookmarkButton', 'userBookmark']) self._prev_onroad = False self._prev_standstill = False @@ -63,6 +64,9 @@ class MiciMainLayout(Scroller): if not self._onboarding_window.completed: gui_app.push_widget(self._onboarding_window) + # initialize correct onroad layout + self._on_body_changed() + @property def _onroad_layout(self) -> Widget: # For scroll_to @@ -142,10 +146,10 @@ class MiciMainLayout(Scroller): self._scroll_to(self._home_layout) def _on_bookmark_clicked(self): - user_bookmark = messaging.new_message('bookmarkButton') - user_bookmark.valid = True - self._pm.send('bookmarkButton', user_bookmark) + for service in ('bookmarkButton', 'userBookmark'): + msg = messaging.new_message(service, valid=True) + self._pm.send(service, msg) def _on_body_changed(self): self._car_onroad_layout.set_visible(not ui_state.is_body) - self._body_onroad_layout.set_visible(ui_state.is_body) + self._body_onroad_layout.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 0f4694a85f..879ac76df5 100644 --- a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -250,12 +250,12 @@ class MiciOffroadAlerts(Scroller): {alert_data.key: self.params.get(alert_data.key) for alert_data in self.sorted_alerts}) time.sleep(REFRESH_INTERVAL) - def _refresh(self) -> int: + def _refresh(self, pending_params: dict) -> int: """Refresh alerts from params and return active count.""" active_count = 0 # Handle UpdateAvailable alert specially - update_available = self._pending_params["UpdateAvailable"] + update_available = pending_params["UpdateAvailable"] update_alert_data = next((alert_data for alert_data in self.sorted_alerts if alert_data.key == "UpdateAvailable"), None) if update_alert_data: @@ -263,7 +263,7 @@ class MiciOffroadAlerts(Scroller): version_string = "" # Get new version description and parse version and date - new_desc = self._pending_params["UpdaterNewDescription"] or "" + new_desc = pending_params["UpdaterNewDescription"] or "" if new_desc: # format: "version / branch / commit / date" parts = new_desc.split(" / ") @@ -284,7 +284,7 @@ class MiciOffroadAlerts(Scroller): continue # Skip, already handled above text = "" - alert_json = self._pending_params[alert_data.key] + alert_json = pending_params[alert_data.key] if alert_json: text = alert_json.get("text", "").replace("%1", alert_json.get("extra", "")) @@ -311,8 +311,9 @@ class MiciOffroadAlerts(Scroller): def _update_state(self): """Periodically refresh alerts.""" # Refresh alerts when thread updates params - if self._pending_params is not None: - self._refresh() + pending_params = self._pending_params + if pending_params is not None: + self._refresh(pending_params) self._pending_params = None def _render(self, rect: rl.Rectangle): diff --git a/openpilot/selfdrive/ui/mici/layouts/onboarding.py b/openpilot/selfdrive/ui/mici/layouts/onboarding.py index 3c5ad27a17..80483d1b01 100644 --- a/openpilot/selfdrive/ui/mici/layouts/onboarding.py +++ b/openpilot/selfdrive/ui/mici/layouts/onboarding.py @@ -1,9 +1,9 @@ import math import numpy as np -import qrcode 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.widgets import Widget from openpilot.system.ui.widgets.button import SmallCircleIconButton @@ -17,11 +17,11 @@ from openpilot.common.version import sunnylink_consent_version, sunnylink_consen from openpilot.selfdrive.ui.ui_state import ui_state, device from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationCircleButton from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer -from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import BaseDriverCameraDialog +from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import BaseCabinCameraDialog from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkConsentPage -class DriverCameraSetupDialog(BaseDriverCameraDialog): +class DriverCameraSetupDialog(BaseCabinCameraDialog): def __init__(self): super().__init__() self.driver_state_renderer = DriverStateRenderer(inset=True) @@ -282,25 +282,7 @@ class QRCodeWidget(Widget): super().__init__() self.set_rect(rl.Rectangle(0, 0, size, size)) self._size = size - self._qr_texture: rl.Texture | None = None - self._generate_qr(url) - - def _generate_qr(self, url: str): - qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0) - qr.add_data(url) - qr.make(fit=True) - - pil_img = qr.make_image(fill_color="white", back_color="black").convert('RGBA') - img_array = np.array(pil_img, dtype=np.uint8) - - rl_image = rl.Image() - rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data) - rl_image.width = pil_img.width - rl_image.height = pil_img.height - rl_image.mipmaps = 1 - rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 - - self._qr_texture = rl.load_texture_from_image(rl_image) + self._qr_texture = make_texture(url, inverted=True) def _render(self, _): if self._qr_texture: diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device.py index 0adcf53752..6038ff7d35 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device.py @@ -9,14 +9,14 @@ from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog -from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog +from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide, TermsPage from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget from openpilot.selfdrive.ui.ui_state import device, ui_state from openpilot.system.ui.widgets.label import UnifiedLabel -from openpilot.system.ui.widgets.html_render import HtmlModal, HtmlRenderer +from openpilot.system.ui.widgets.html_render import HtmlRenderer from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID @@ -160,7 +160,7 @@ class DeviceLayoutMici(NavScroller): def __init__(self): super().__init__() - self._fcc_dialog: HtmlModal | None = None + self._fcc_dialog: MiciFccModal | None = None def power_off_callback(): ui_state.params.put_bool("DoShutdown", True, block=True) @@ -172,7 +172,6 @@ class DeviceLayoutMici(NavScroller): params = ui_state.params params.remove("CalibrationParams") params.remove("LiveTorqueParameters") - params.remove("LiveParameters") params.remove("LiveParametersV2") params.remove("LiveDelay") params.put_bool("OnroadCycleRequested", True, block=True) @@ -190,9 +189,9 @@ class DeviceLayoutMici(NavScroller): regulatory_btn = BigButton("regulatory info", "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64)) regulatory_btn.set_click_callback(self._on_regulatory) - driver_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64)) - driver_cam_btn.set_click_callback(lambda: gui_app.push_widget(DriverCameraDialog())) - driver_cam_btn.set_enabled(lambda: ui_state.is_offroad()) + cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64)) + cabin_cam_btn.set_click_callback(lambda: gui_app.push_widget(CabinCameraDialog())) + cabin_cam_btn.set_enabled(lambda: ui_state.is_offroad()) review_training_guide_btn = BigButton("review\ntraining guide", "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64)) review_training_guide_btn.set_click_callback(lambda: gui_app.push_widget(ReviewTrainingGuide(completed_callback=lambda: gui_app.pop_widgets_to(self)))) @@ -205,7 +204,7 @@ class DeviceLayoutMici(NavScroller): DeviceInfoLayoutMici(), PairBigButton(), review_training_guide_btn, - driver_cam_btn, + cabin_cam_btn, terms_btn, regulatory_btn, reset_calibration_btn, diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py index 56a953a65d..eb7789cba9 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py @@ -50,7 +50,6 @@ class SettingsLayout(NavScroller): device_btn, software_btn, PairBigButton(), - #BigDialogButton("manual", "", "icons_mici/settings/manual_icon.png", "Check out the mici user\nmanual at comma.ai/setup"), firehose_btn, developer_btn, ]) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index d71982ca26..35fc94142b 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -47,7 +47,7 @@ class SoftwareInfoLayoutMici(Widget): self._branch_label = UnifiedLabel("branch", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) self._branch_text_label = UnifiedLabel("", 32, max_width=max_width, text_color=subheader_color, - font_weight=FontWeight.ROMAN, wrap_text=False) + font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True) def _update_state(self): desc = _split_description(ui_state.params.get("UpdaterCurrentDescription") or "") diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index 52bbb65e6a..2dba124df5 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -1,7 +1,10 @@ +from collections.abc import Callable + from openpilot.cereal import log from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle +from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle, BigToggle, GreyBigButton +from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationCircleButton from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback from openpilot.selfdrive.ui.ui_state import ui_state @@ -9,16 +12,42 @@ from openpilot.selfdrive.ui.ui_state import ui_state PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants +class ExperimentalModeConfirmPage(NavScroller): + def __init__(self, on_confirm: Callable[[], None]): + super().__init__() + + accept = BigConfirmationCircleButton("enable\nexperimental mode", + gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64), + lambda: self.dismiss(on_confirm)) + + self._scroller.add_widgets([ + GreyBigButton("enabling\nexperimental mode", "scroll to continue", + gui_app.texture("icons_mici/setup/warning.png", 64, 64)), + GreyBigButton("", "openpilot defaults to driving in chill mode."), + GreyBigButton("", "Experimental mode enables alpha-level features that aren't ready for chill mode."), + GreyBigButton("End-to-End Longitudinal Control"), + GreyBigButton("", "Let the driving model control the gas and brakes."), + GreyBigButton("", "openpilot will drive as it thinks a human would, including stopping for red lights and stop signs."), + GreyBigButton("", "The set speed will only act as an upper bound."), + GreyBigButton("", "This is an alpha quality feature; mistakes should be expected."), + GreyBigButton("New Driving Visualization"), + GreyBigButton("", "The path will change colors to communicate acceleration intent."), + GreyBigButton("", "Red for braking, green for acceleration, and gray for coasting."), + accept, + ]) + + class TogglesLayoutMici(NavScroller): def __init__(self): super().__init__() self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"]) - self._experimental_btn = BigParamControl("experimental mode", "ExperimentalMode") + self._experimental_btn = BigToggle("experimental mode", initial_state=ui_state.params.get_bool("ExperimentalMode"), + toggle_callback=self._on_experimental_mode) is_metric_toggle = BigParamControl("use metric units", "IsMetric") ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled") always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM") - record_front = BigParamControl("record & upload driver camera", "RecordFront", toggle_callback=restart_needed_callback) + record_front = BigParamControl("record & upload cabin camera", "RecordFront", toggle_callback=restart_needed_callback) record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback) enable_openpilot = BigParamControl("enable sunnypilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) @@ -85,3 +114,17 @@ class TogglesLayoutMici(NavScroller): # Refresh toggles from params to mirror external changes for key, item in self._refresh_toggles: item.set_checked(ui_state.params.get_bool(key)) + + def _on_experimental_mode(self, state: bool): + if state and not ui_state.params.get_bool("ExperimentalModeConfirmed"): + # Don't show enabled state until confirm + self._experimental_btn.set_checked(False) + + def on_confirm(): + ui_state.params.put_bool("ExperimentalModeConfirmed", True) + ui_state.params.put_bool("ExperimentalMode", True) + self._experimental_btn.set_checked(True) + + gui_app.push_widget(ExperimentalModeConfirmPage(on_confirm)) + else: + ui_state.params.put_bool("ExperimentalMode", state) diff --git a/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py b/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py index 016e09a0f1..125524b95d 100644 --- a/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py @@ -9,7 +9,7 @@ from openpilot.cereal import messaging, log 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 TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel @@ -132,11 +132,11 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer): # 1. Never received selfdriveState since going onroad waiting_for_startup = recv_frame < ui_state.started_frame - if waiting_for_startup and time_since_onroad > 5: + if waiting_for_startup and time_since_onroad > 10: return ALERT_STARTUP_PENDING # 2. Lost communication with selfdriveState after receiving it - if TICI and not waiting_for_startup: + if COMMA_HARDWARE and not waiting_for_startup: ss_missing = time.monotonic() - sm.recv_time['selfdriveState'] if ss_missing > SELFDRIVE_STATE_TIMEOUT: if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT: @@ -310,13 +310,10 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer): # TODO: hack alert_text1 = alert.text1.lower().replace('calibrating: ', 'calibrating:\n') - can_draw_second_line = False # TODO: there should be a common way to determine font size based on text length to maximize rect if len(alert_text1) <= 12: - can_draw_second_line = True font_size = 92 - 10 elif len(alert_text1) <= 16: - can_draw_second_line = True font_size = 70 else: font_size = 64 - 10 @@ -348,13 +345,13 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer): self._text_gen_time = time.monotonic() alert_text2 = self._alert_text2_gen or alert_text2 - if can_draw_second_line and alert_text2: + if alert_text2: last_line_h = self._alert_text1_label.rect.y + self._alert_text1_label.get_content_height(int(alert_layout.text_rect.width)) last_line_h -= 4 - if len(alert_text2) > 18: - small_font_size = 36 - elif len(alert_text2) > 24: + if len(alert_text2) > 24: small_font_size = 32 + elif len(alert_text2) > 18: + small_font_size = 36 else: small_font_size = 40 text_rect2 = rl.Rectangle( diff --git a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py index b5b9a54be8..e37e9b7ea9 100644 --- a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -2,7 +2,7 @@ import numpy as np import pyray as rl from openpilot.cereal import log from opendbc.car.structs import car -from msgq.visionipc import VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer @@ -24,8 +24,8 @@ if gui_app.sunnypilot_ui(): from openpilot.selfdrive.ui.sunnypilot.ui_state import OnroadTimerStatus OpState = log.SelfdriveState.OpenpilotState -CALIBRATED = log.LiveCalibrationData.Status.calibrated -ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD +CALIBRATED = log.ExtrinsicsCalibration.Status.calibrated +NARROW_ROAD_CAM = VisionStreamType.VISION_STREAM_NARROW_ROAD WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"] @@ -134,7 +134,7 @@ class BookmarkIcon(Widget): class AugmentedRoadView(CameraView): - def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD): + def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_NARROW_ROAD): super().__init__("camerad", stream_type) self._bookmark_callback = bookmark_callback self._set_placeholder_color(rl.BLACK) @@ -254,12 +254,12 @@ class AugmentedRoadView(CameraView): if v_ego < WIDE_CAM_MAX_SPEED: target = WIDE_CAM elif v_ego > ROAD_CAM_MIN_SPEED: - target = ROAD_CAM + target = NARROW_ROAD_CAM else: # Hysteresis zone - keep current stream target = self.stream_type else: - target = ROAD_CAM + target = NARROW_ROAD_CAM if self.stream_type != target: self.switch_stream(target) @@ -267,14 +267,14 @@ class AugmentedRoadView(CameraView): def _update_calibration(self): # Update device camera if not already set sm = ui_state.sm - if not self.device_camera and sm.seen['roadCameraState'] and sm.seen['deviceState']: - self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] + if not self.device_camera and sm.seen['narrowRoadCameraState'] and sm.seen['deviceState']: + self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['narrowRoadCameraState'].sensor))] - # Check if live calibration data is available and valid - if not (sm.updated["liveCalibration"] and sm.valid['liveCalibration']): + # Check if camera calibration data is available and valid + if not (sm.updated["extrinsicsCalibration"] and sm.valid['extrinsicsCalibration']): return - calib = sm['liveCalibration'] + calib = sm['extrinsicsCalibration'] if len(calib.rpyCalib) != 3 or calib.calStatus != CALIBRATED: return @@ -289,7 +289,7 @@ class AugmentedRoadView(CameraView): def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray: cache_key = ( - ui_state.sm.recv_frame['liveCalibration'], + ui_state.sm.recv_frame['extrinsicsCalibration'], int(self._content_rect.width), int(self._content_rect.height), self.stream_type, @@ -302,7 +302,7 @@ class AugmentedRoadView(CameraView): # Get camera configuration device_camera = self.device_camera or DEFAULT_DEVICE_CAMERA is_wide_camera = self.stream_type == WIDE_CAM - intrinsic = device_camera.ecam.intrinsics if is_wide_camera else device_camera.fcam.intrinsics + intrinsic = device_camera.wide_road.intrinsics if is_wide_camera else device_camera.narrow_road.intrinsics calibration = self.view_from_wide_calib if is_wide_camera else self.view_from_calib if is_wide_camera: zoom = 0.7 * 1.5 @@ -365,14 +365,14 @@ class AugmentedRoadView(CameraView): if __name__ == "__main__": gui_app.init_window("OnRoad Camera View") - road_camera_view = AugmentedRoadView(lambda: None, stream_type=ROAD_CAM) + road_camera_view = AugmentedRoadView(lambda: None, stream_type=NARROW_ROAD_CAM) print("***press space to switch camera view***") try: for _ in gui_app.render(): ui_state.update() if rl.is_key_released(rl.KeyboardKey.KEY_SPACE): if WIDE_CAM in road_camera_view.available_streams: - stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM + stream = NARROW_ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM road_camera_view.switch_stream(stream) road_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) finally: diff --git a/openpilot/selfdrive/ui/mici/onroad/driver_camera_dialog.py b/openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py similarity index 95% rename from openpilot/selfdrive/ui/mici/onroad/driver_camera_dialog.py rename to openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py index e81877b402..e86c0aa739 100644 --- a/openpilot/selfdrive/ui/mici/onroad/driver_camera_dialog.py +++ b/openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py @@ -1,6 +1,6 @@ import pyray as rl from openpilot.cereal import log, messaging -from msgq.visionipc import VisionStreamType +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 @@ -11,7 +11,7 @@ from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.system.ui.widgets.label import gui_label -class DriverCameraView(CameraView): +class CabinCameraView(CameraView): def _calc_frame_matrix(self, rect: rl.Rectangle): base = super()._calc_frame_matrix(rect) driver_view_ratio = 1.5 @@ -20,11 +20,11 @@ class DriverCameraView(CameraView): return base -class BaseDriverCameraDialog(Widget): +class BaseCabinCameraDialog(Widget): # Not a NavWidget so training guide can use this without back navigation def __init__(self): super().__init__() - self._camera_view = DriverCameraView("camerad", VisionStreamType.VISION_STREAM_DRIVER) + self._camera_view = CabinCameraView("camerad", VisionStreamType.VISION_STREAM_CABIN) self.driver_state_renderer = DriverStateRenderer(lines=True) self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 200, 200)) self.driver_state_renderer.load_icons() @@ -229,7 +229,7 @@ class BaseDriverCameraDialog(Widget): rl.draw_texture_v(self._glasses_texture, glasses_pos, rl.Color(70, 80, 161, int(255 * glasses_prob))) -class DriverCameraDialog(NavWidget, BaseDriverCameraDialog): +class CabinCameraDialog(NavWidget, BaseCabinCameraDialog): def __init__(self): super().__init__() # TODO: this can grow unbounded, should be given some thought @@ -237,12 +237,12 @@ class DriverCameraDialog(NavWidget, BaseDriverCameraDialog): if __name__ == "__main__": - gui_app.init_window("Driver Camera View (mici)") + gui_app.init_window("Cabin Camera View (mici)") - driver_camera_view = DriverCameraDialog() - gui_app.push_widget(driver_camera_view) + cabin_camera_view = CabinCameraDialog() + gui_app.push_widget(cabin_camera_view) try: for _ in gui_app.render(): ui_state.update() finally: - driver_camera_view.close() + cabin_camera_view.close() diff --git a/openpilot/selfdrive/ui/mici/onroad/cameraview.py b/openpilot/selfdrive/ui/mici/onroad/cameraview.py index 991349dbf0..8a3e2cbed5 100644 --- a/openpilot/selfdrive/ui/mici/onroad/cameraview.py +++ b/openpilot/selfdrive/ui/mici/onroad/cameraview.py @@ -2,9 +2,10 @@ import platform import numpy as np import pyray as rl -from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcClient, VisionBuf from openpilot.common.swaglog import cloudlog -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage from openpilot.system.ui.widgets import Widget @@ -38,7 +39,7 @@ void main() { """ # Choose fragment shader based on platform capabilities -if TICI: +if COMMA_HARDWARE: FRAME_FRAGMENT_SHADER = """ #version 300 es #extension GL_OES_EGL_image_external_essl3 : enable @@ -110,7 +111,7 @@ class CameraView(Widget): self._name = name # Primary stream self.client = VisionIpcClient(name, stream_type, conflate=True) - self._stream_type = stream_type + self._stream_type: VisionStreamType = stream_type self.available_streams: list[VisionStreamType] = [] # Target stream for switching @@ -121,11 +122,11 @@ class CameraView(Widget): self._texture_needs_update = True self.last_connection_attempt: float = 0.0 self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER) - self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1 + self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not COMMA_HARDWARE else -1 self._engaged_loc = rl.get_shader_location(self.shader, "engaged") self._engaged_val = rl.ffi.new("int[1]", [1]) self._enhance_driver_loc = rl.get_shader_location(self.shader, "enhance_driver") - self._enhance_driver_val = rl.ffi.new("int[1]", [1 if stream_type == VisionStreamType.VISION_STREAM_DRIVER else 0]) + self._enhance_driver_val = rl.ffi.new("int[1]", [1 if stream_type == VisionStreamType.VISION_STREAM_CABIN else 0]) self.frame: VisionBuf | None = None self.texture_y: rl.Texture | None = None @@ -137,8 +138,8 @@ class CameraView(Widget): self._placeholder_color: rl.Color | None = None - # Initialize EGL for zero-copy rendering on TICI - if TICI: + # Initialize EGL for zero-copy rendering on COMMA_HARDWARE + if COMMA_HARDWARE: if not init_egl(): raise RuntimeError("Failed to initialize EGL") @@ -185,7 +186,7 @@ class CameraView(Widget): self._clear_textures() # Clean up EGL texture - if TICI and self.egl_texture: + if COMMA_HARDWARE and self.egl_texture: rl.unload_texture(self.egl_texture) self.egl_texture = None @@ -219,7 +220,7 @@ class CameraView(Widget): [0.0, 0.0, 1.0] ]) - def _render(self, rect: rl.Rectangle): + def _render(self, rect: rl.Rectangle, /): if self._switching: self._handle_switch() @@ -242,8 +243,8 @@ class CameraView(Widget): transform = self._calc_frame_matrix(rect) src_rect = rl.Rectangle(0, 0, float(self.frame.width), float(self.frame.height)) - # Flip driver camera horizontally - if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER: + # Flip cabin camera horizontally + if self._stream_type == VisionStreamType.VISION_STREAM_CABIN: src_rect.width = -src_rect.width # Calculate scale @@ -260,7 +261,7 @@ class CameraView(Widget): dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y) # Render with appropriate method - if TICI: + if COMMA_HARDWARE: self._render_egl(src_rect, dst_rect) else: self._render_textures(src_rect, dst_rect) @@ -370,6 +371,7 @@ class CameraView(Widget): del self.client # Switch to target + assert self._target_client is not None and self._target_stream_type is not None self.client = self._target_client self._stream_type = self._target_stream_type self._texture_needs_update = True @@ -383,12 +385,12 @@ class CameraView(Widget): self._initialize_textures() def _initialize_textures(self): - self._clear_textures() - if not TICI: - self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), - int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) - self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), - int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA)) + self._clear_textures() + if not COMMA_HARDWARE: + self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), + int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) + self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), + int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA)) def _clear_textures(self): if self.texture_y and self.texture_y.id: @@ -400,7 +402,7 @@ class CameraView(Widget): self.texture_uv = None # Clean up EGL resources - if TICI: + if COMMA_HARDWARE: for data in self.egl_images.values(): destroy_egl_image(data) self.egl_images = {} @@ -408,6 +410,6 @@ class CameraView(Widget): if __name__ == "__main__": gui_app.init_window("camera view") - road = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD) + road = CameraView("camerad", VisionStreamType.VISION_STREAM_NARROW_ROAD) for _ in gui_app.render(): road.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) diff --git a/openpilot/selfdrive/ui/mici/onroad/driver_state.py b/openpilot/selfdrive/ui/mici/onroad/driver_state.py index 85b90704ac..ad5a521518 100644 --- a/openpilot/selfdrive/ui/mici/onroad/driver_state.py +++ b/openpilot/selfdrive/ui/mici/onroad/driver_state.py @@ -169,8 +169,8 @@ class DriverStateRenderer(Widget): self._is_rhd = dm_state.isRHD self._face_detected = dm_state.visionPolicyState.faceDetected self._awareness_unfull = self.effective_active and dm_state.visionPolicyState.awarenessPercent < self.AWARENESS_UNFULL_PERCENT - self._face_pitch = dm_state.visionPolicyState.pose.pitch + math.radians(6) # calib or DM pose is not accurate, add a fake upward pitch to bias forward - self._face_yaw = -dm_state.visionPolicyState.pose.yaw # undo sign flip in face_orientation_from_model to match UI convention + self._face_pitch = dm_state.visionPolicyState.pose.pitch + math.radians(6) # calib or DM pose is not accurate, add a fake upward pitch to bias forward + self._face_yaw = dm_state.visionPolicyState.pose.yaw * (1 if self._is_rhd else -1) # undo sign flip in face_orientation_from_model to match UI convention driverstate = sm["driverStateV2"] driver_data = driverstate.rightDriverData if self._is_rhd else driverstate.leftDriverData diff --git a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py index 67f9833021..05abae82eb 100644 --- a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py @@ -1,8 +1,9 @@ +import math 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 @@ -106,6 +107,7 @@ class HudRenderer(Widget): self.speed: float = 0.0 self.v_ego_cluster_seen: bool = False self._engaged: bool = False + self._chestnut_fade_time: float = 0 self._can_draw_top_icons = True self._show_wheel_critical = False @@ -121,11 +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_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._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.""" @@ -158,6 +164,8 @@ class HudRenderer(Widget): engaged = sm['selfdriveState'].enabled 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 @@ -177,8 +185,39 @@ class HudRenderer(Widget): if self.is_cruise_set: self._draw_set_speed(rect) + self._draw_model_source(rect) + self._draw_steering_wheel(rect) + def _draw_model_source(self, rect: rl.Rectangle) -> None: + if ui_state.sm.recv_frame['selfdriveState'] < ui_state.started_frame: + return + + loading = ui_state.chestnut_state == ChestnutState.LOADING + if loading: + 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: + return + + 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 + + pos = rl.Vector2(rect.x + rect.width - 10 - icon.width, + rect.y + rect.height - 14 - (self._txt_wheel.height + icon.height) / 2) + rl.draw_texture_ex(icon, pos, 0.0, 1.0, rl.Color(255, 255, 255, int(255 * opacity * alpha))) + def _draw_steering_wheel(self, rect: rl.Rectangle) -> None: wheel_txt = self._txt_wheel_critical if self._show_wheel_critical else self._txt_wheel diff --git a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py index 87174cf7d9..805bdcc335 100644 --- a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py @@ -47,8 +47,8 @@ class ModelPoints: @dataclass class LeadVehicle: - glow: list[float] = field(default_factory=list) - chevron: list[float] = field(default_factory=list) + glow: list[tuple[float, float]] = field(default_factory=list) + chevron: list[tuple[float, float]] = field(default_factory=list) fill_alpha: int = 0 @@ -111,7 +111,7 @@ class ModelRenderer(Widget, ModelRendererSP): self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque) # Check if data is up-to-date - if (sm.recv_frame["liveCalibration"] < ui_state.started_frame or + if (sm.recv_frame["extrinsicsCalibration"] < ui_state.started_frame or sm.recv_frame["modelV2"] < ui_state.started_frame): return @@ -123,8 +123,8 @@ class ModelRenderer(Widget, ModelRendererSP): # Update state self._experimental_mode = sm['selfdriveState'].experimentalMode - live_calib = sm['liveCalibration'] - self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0] + extrinsics_calibration = sm['extrinsicsCalibration'] + self._path_offset_z = extrinsics_calibration.height[0] if extrinsics_calibration.height else HEIGHT_INIT[0] if sm.updated['carParams']: self._longitudinal_control = sm['carParams'].openpilotLongitudinalControl diff --git a/openpilot/selfdrive/ui/mici/onroad/torque_bar.py b/openpilot/selfdrive/ui/mici/onroad/torque_bar.py index 85edd89e98..c4ccafa0a3 100644 --- a/openpilot/selfdrive/ui/mici/onroad/torque_bar.py +++ b/openpilot/selfdrive/ui/mici/onroad/torque_bar.py @@ -168,7 +168,7 @@ class TorqueBar(Widget): if ui_state.sm['controlsState'].lateralControlState.which() in ('angleState', 'curvatureState'): controls_state = ui_state.sm['controlsState'] car_state = ui_state.sm['carState'] - live_parameters = ui_state.sm['liveParameters'] + vehicle_parameters = ui_state.sm['vehicleParameters'] car_control = ui_state.sm['carControl'] # Include lateral accel error in estimated torque utilization @@ -178,7 +178,7 @@ class TorqueBar(Widget): # Include road roll in estimated torque utilization # Roll is less accurate near standstill, so reduce its effect at low speed - roll_compensation = live_parameters.roll * ACCELERATION_DUE_TO_GRAVITY * np.interp(car_state.vEgo, [5, 15], [0.0, 1.0]) + roll_compensation = vehicle_parameters.roll * ACCELERATION_DUE_TO_GRAVITY * np.interp(car_state.vEgo, [5, 15], [0.0, 1.0]) lateral_acceleration = actual_lateral_accel - roll_compensation max_lateral_acceleration = ui_state.CP.maxLateralAccel if ui_state.CP else DEFAULT_MAX_LAT_ACCEL diff --git a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py index 09142a8e11..c0f1ac1510 100755 --- a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py +++ b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py @@ -1,16 +1,16 @@ import gc import weakref -import pytest +import unittest # FIXME: known small leaks not worth worrying about at the moment KNOWN_LEAKS = { - "openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog.DriverCameraView", + "openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog.CabinCameraView", "openpilot.selfdrive.ui.mici.layouts.onboarding.TermsPage", "openpilot.selfdrive.ui.mici.layouts.onboarding.TrainingGuide", "openpilot.selfdrive.ui.mici.layouts.onboarding.DeclinePage", "openpilot.selfdrive.ui.mici.layouts.onboarding.OnboardingWindow", "openpilot.selfdrive.ui.onroad.driver_state.DriverStateRenderer", - "openpilot.selfdrive.ui.onroad.driver_camera_dialog.DriverCameraDialog", + "openpilot.selfdrive.ui.onroad.cabin_camera_dialog.CabinCameraDialog", "openpilot.selfdrive.ui.layouts.onboarding.TermsPage", "openpilot.selfdrive.ui.layouts.onboarding.DeclinePage", "openpilot.selfdrive.ui.layouts.onboarding.OnboardingWindow", @@ -41,80 +41,82 @@ def get_child_widgets(widget) -> list: return children -@pytest.mark.skip(reason="segfaults") -def test_dialogs_do_not_leak(): - import pyray as rl - rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN) - from openpilot.system.ui.lib.application import gui_app +from openpilot.common.test import OpenpilotTestCase +class TestWidgetLeaks(OpenpilotTestCase): + @unittest.skip("segfaults") + def test_dialogs_do_not_leak(self): + import pyray as rl + rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN) + from openpilot.system.ui.lib.application import gui_app - # mici dialogs - from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide as MiciTrainingGuide, OnboardingWindow as MiciOnboardingWindow - from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog as MiciDriverCameraDialog - from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog as MiciPairingDialog - from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog - from openpilot.selfdrive.ui.mici.layouts.settings.device import MiciFccModal + # mici dialogs + from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide as MiciTrainingGuide, OnboardingWindow as MiciOnboardingWindow + from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog as MiciCabinCameraDialog + from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog as MiciPairingDialog + from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog + from openpilot.selfdrive.ui.mici.layouts.settings.device import MiciFccModal - # tici dialogs - from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog as TiciDriverCameraDialog - from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow as TiciOnboardingWindow - from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog as TiciPairingDialog - from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog - from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog - from openpilot.system.ui.widgets.html_render import HtmlModal - from openpilot.system.ui.widgets.keyboard import Keyboard + # tici dialogs + from openpilot.selfdrive.ui.onroad.cabin_camera_dialog import CabinCameraDialog as TiciCabinCameraDialog + from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow as TiciOnboardingWindow + from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog as TiciPairingDialog + from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog + from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog + from openpilot.system.ui.widgets.html_render import HtmlModal + from openpilot.system.ui.widgets.keyboard import Keyboard - gui_app.init_window("ref-test") + gui_app.init_window("ref-test") - leaked_widgets = set() + leaked_widgets = set() - for ctor in ( - # mici - MiciDriverCameraDialog, MiciPairingDialog, - lambda: MiciTrainingGuide(lambda: None), - lambda: MiciOnboardingWindow(lambda: None), - lambda: BigDialog("test", "test"), - lambda: BigConfirmationDialog("test", gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64), lambda: None), - lambda: BigInputDialog("test"), - lambda: MiciFccModal(text="test"), - # tici - TiciDriverCameraDialog, TiciOnboardingWindow, TiciPairingDialog, Keyboard, - lambda: ConfirmDialog("test", "ok"), - lambda: MultiOptionDialog("test", ["a", "b"]), - lambda: HtmlModal(text="test"), - ): - widget = ctor() - all_refs = [weakref.ref(w) for w in get_child_widgets(widget) + [widget]] + for ctor in ( + # mici + MiciCabinCameraDialog, MiciPairingDialog, + lambda: MiciTrainingGuide(lambda: None), + lambda: MiciOnboardingWindow(lambda: None), + lambda: BigDialog("test", "test"), + lambda: BigConfirmationDialog("test", gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64), lambda: None), + lambda: BigInputDialog("test"), + lambda: MiciFccModal(text="test"), + # tici + TiciCabinCameraDialog, TiciOnboardingWindow, TiciPairingDialog, Keyboard, + lambda: ConfirmDialog("test", "ok"), + lambda: MultiOptionDialog("test", ["a", "b"]), + lambda: HtmlModal(text="test"), + ): + widget = ctor() + all_refs = [weakref.ref(w) for w in get_child_widgets(widget) + [widget]] - del widget + del widget - for ref in all_refs: - if ref() is not None: - obj = ref() - name = f"{type(obj).__module__}.{type(obj).__qualname__}" - leaked_widgets.add(name) + for ref in all_refs: + if ref() is not None: + obj = ref() + name = f"{type(obj).__module__}.{type(obj).__qualname__}" + leaked_widgets.add(name) - print(f"\n=== Widget {name} alive after del") - print(" Referrers:") - for r in gc.get_referrers(obj): - if r is obj: - continue + print(f"\n=== Widget {name} alive after del") + print(" Referrers:") + for r in gc.get_referrers(obj): + if r is obj: + continue - if hasattr(r, '__self__') and r.__self__ is not obj: - print(f" bound method: {type(r.__self__).__qualname__}.{r.__name__}") - elif hasattr(r, '__func__'): - print(f" method: {r.__name__}") - else: - print(f" {type(r).__module__}.{type(r).__qualname__}") - del obj + if hasattr(r, '__self__') and r.__self__ is not obj: + print(f" bound method: {type(r.__self__).__qualname__}.{r.__name__}") + elif hasattr(r, '__func__'): + print(f" method: {r.__name__}") + else: + print(f" {type(r).__module__}.{type(r).__qualname__}") + del obj - gui_app.close() + gui_app.close() - unexpected = leaked_widgets - KNOWN_LEAKS - assert not unexpected, f"New leaked widgets: {unexpected}" + unexpected = leaked_widgets - KNOWN_LEAKS + assert not unexpected, f"New leaked widgets: {unexpected}" - fixed = KNOWN_LEAKS - leaked_widgets - assert not fixed, f"These leaks are fixed, remove from KNOWN_LEAKS: {fixed}" + fixed = KNOWN_LEAKS - leaked_widgets + assert not fixed, f"These leaks are fixed, remove from KNOWN_LEAKS: {fixed}" if __name__ == "__main__": - test_dialogs_do_not_leak() + TestWidgetLeaks().test_dialogs_do_not_leak() diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index 1dceb79691..e2912d00cf 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -1,6 +1,6 @@ import math import pyray as rl -from typing import Union +from typing import TYPE_CHECKING, Union from enum import Enum from collections.abc import Callable from openpilot.system.ui.widgets import Widget @@ -9,12 +9,14 @@ from openpilot.system.ui.widgets.scroller import DO_ZOOM from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.common.filter_simple import BounceFilter -try: +if TYPE_CHECKING: from openpilot.common.params import Params -except ImportError: - Params = None +else: + try: + from openpilot.common.params import Params + except (ImportError, OSError): + Params = None -SCROLLING_SPEED_PX_S = 50 COMPLICATION_SIZE = 36 LABEL_COLOR = rl.Color(255, 255, 255, int(255 * 0.9)) COMPLICATION_GREY = rl.Color(0xAA, 0xAA, 0xAA, 255) @@ -147,11 +149,15 @@ class BigButton(Widget): def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: super().set_touch_valid_callback(lambda: touch_callback() and self._grow_animation_until is None) - def _width_hint(self) -> int: - # Single line if scrolling, so hide behind icon if exists - icon_size = self._txt_icon.width if self._txt_icon and self._scroll and self.value else 0 + def _title_width_hint(self) -> int: + # A value moves the title to the top, where it shares space with the icon + icon_size = self._txt_icon.width if self._txt_icon and self.value else 0 return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_size) + def _subtitle_width_hint(self) -> int: + # Bottom aligned, so it sits below the icon + return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2) + def _get_label_font_size(self): if len(self.text) <= 18: return 48 @@ -226,14 +232,14 @@ class BigButton(Widget): label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35)) self._label.set_color(label_color) - label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._width_hint(), + label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._title_width_hint(), self._rect.height - self.LABEL_VERTICAL_PADDING * 2) self._label.render(label_rect) if self.value: - label_y = btn_y + self.LABEL_VERTICAL_PADDING + self._label.get_content_height(self._width_hint()) + label_y = label_rect.y + self._label.get_content_height(int(label_rect.width)) sub_label_height = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING - label_y - sub_label_rect = rl.Rectangle(label_x, label_y, self._width_hint(), sub_label_height) + sub_label_rect = rl.Rectangle(label_x, label_y, self._subtitle_width_hint(), sub_label_height) self._sub_label.render(sub_label_rect) # ICON ------------------------------------------------------------------- @@ -310,9 +316,6 @@ class BigMultiToggle(BigToggle): self.set_value(self._options[0]) - def _width_hint(self) -> int: - return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width) - def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) cur_idx = self._options.index(self.value) @@ -361,9 +364,6 @@ class GreyBigButton(BigButton): def LABEL_VERTICAL_PADDING(self): return BigButton.LABEL_VERTICAL_PADDING if self._label.text else 18 - def _width_hint(self) -> int: - return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2) - def _get_label_font_size(self): return 36 @@ -375,6 +375,7 @@ class GreyBigButton(BigButton): class BigMultiParamToggle(BigMultiToggle): def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None, select_callback: Callable | None = None): + assert Params is not None super().__init__(text, options, toggle_callback, select_callback) self._param = param @@ -392,6 +393,7 @@ class BigMultiParamToggle(BigMultiToggle): class BigParamControl(BigToggle): def __init__(self, text: str, param: str, toggle_callback: Callable | None = None): + assert Params is not None super().__init__(text, "", toggle_callback=toggle_callback) self.param = param self.params = Params() @@ -409,6 +411,7 @@ class BigParamControl(BigToggle): class BigCircleParamControl(BigCircleToggle): def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)): + assert Params is not None super().__init__(icon, toggle_callback, icon_offset=icon_offset) self._param = param self.params = Params() diff --git a/openpilot/selfdrive/ui/mici/widgets/dialog.py b/openpilot/selfdrive/ui/mici/widgets/dialog.py index ed1466449b..77dfa3cb97 100644 --- a/openpilot/selfdrive/ui/mici/widgets/dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/dialog.py @@ -10,7 +10,7 @@ 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.widgets.slider import RedBigSlider, BigSlider from openpilot.common.filter_simple import FirstOrderFilter -from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton, BigButton, GreyBigButton +from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton, GreyBigButton DEBUG = False @@ -216,18 +216,6 @@ class BigInputDialog(BigDialogBase): self._confirm_callback() -class BigDialogButton(BigButton): - def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""): - super().__init__(text, value, icon) - self._description = description - - def _handle_mouse_release(self, mouse_pos: MousePos): - super()._handle_mouse_release(mouse_pos) - - dlg = BigDialog(self.text, self._description) - gui_app.push_widget(dlg) - - class BigConfirmationCircleButton(BigCircleButton): def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], exit_on_confirm: bool = True, red: bool = False, icon_offset: tuple[int, int] = (0, 0)): diff --git a/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py b/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py index a18b26ec02..7e05bc2fa0 100644 --- a/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py @@ -1,9 +1,8 @@ import pyray as rl -import qrcode -import numpy as np import time from openpilot.common.api import Api +from openpilot.common.qrcode import make_texture from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params from openpilot.selfdrive.ui.ui_state import ui_state @@ -37,24 +36,9 @@ class PairingDialog(NavWidget): def _generate_qr_code(self) -> None: try: - qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0) - qr.add_data(self._get_pairing_url()) - qr.make(fit=True) - - pil_img = qr.make_image(fill_color="white", back_color="black").convert('RGBA') - img_array = np.array(pil_img, dtype=np.uint8) - if self._qr_texture and self._qr_texture.id != 0: rl.unload_texture(self._qr_texture) - - rl_image = rl.Image() - rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data) - rl_image.width = pil_img.width - rl_image.height = pil_img.height - rl_image.mipmaps = 1 - rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 - - self._qr_texture = rl.load_texture_from_image(rl_image) + self._qr_texture = make_texture(self._get_pairing_url(), inverted=True) except Exception as e: cloudlog.warning(f"QR code generation failed: {e}") self._qr_texture = None diff --git a/openpilot/selfdrive/ui/onroad/alert_renderer.py b/openpilot/selfdrive/ui/onroad/alert_renderer.py index b77b1539f4..7a69679db3 100644 --- a/openpilot/selfdrive/ui/onroad/alert_renderer.py +++ b/openpilot/selfdrive/ui/onroad/alert_renderer.py @@ -3,7 +3,7 @@ import pyray as rl from dataclasses import dataclass from openpilot.cereal import messaging, log from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE 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 @@ -92,11 +92,11 @@ class AlertRenderer(Widget): # 1. Never received selfdriveState since going onroad waiting_for_startup = recv_frame < ui_state.started_frame - if waiting_for_startup and time_since_onroad > 5: + if waiting_for_startup and time_since_onroad > 10: return ALERT_STARTUP_PENDING # 2. Lost communication with selfdriveState after receiving it - if TICI and not waiting_for_startup: + if COMMA_HARDWARE and not waiting_for_startup: ss_missing = time.monotonic() - sm.recv_time['selfdriveState'] if ss_missing > SELFDRIVE_STATE_TIMEOUT: if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT: diff --git a/openpilot/selfdrive/ui/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/onroad/augmented_road_view.py index c255804f2d..70b75d13bd 100644 --- a/openpilot/selfdrive/ui/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/onroad/augmented_road_view.py @@ -1,7 +1,7 @@ import numpy as np import pyray as rl from openpilot.cereal import log -from msgq.visionipc import VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType from openpilot.selfdrive.ui import UI_BORDER_SIZE from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.onroad.alert_renderer import AlertRenderer @@ -21,8 +21,8 @@ if gui_app.sunnypilot_ui(): from openpilot.selfdrive.ui.sunnypilot.ui_state import OnroadTimerStatus OpState = log.SelfdriveState.OpenpilotState -CALIBRATED = log.LiveCalibrationData.Status.calibrated -ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD +CALIBRATED = log.ExtrinsicsCalibration.Status.calibrated +NARROW_ROAD_CAM = VisionStreamType.VISION_STREAM_NARROW_ROAD WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"] @@ -39,7 +39,7 @@ INF_POINT = np.array([1000.0, 0.0, 0.0]) class AugmentedRoadView(CameraView, AugmentedRoadViewSP): - def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD): + def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_NARROW_ROAD): CameraView.__init__(self, "camerad", stream_type) AugmentedRoadViewSP.__init__(self) self._set_placeholder_color(BORDER_COLORS[UIStatus.DISENGAGED]) @@ -125,12 +125,12 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewSP): if v_ego < WIDE_CAM_MAX_SPEED: target = WIDE_CAM elif v_ego > ROAD_CAM_MIN_SPEED: - target = ROAD_CAM + target = NARROW_ROAD_CAM else: # Hysteresis zone - keep current stream target = self.stream_type else: - target = ROAD_CAM + target = NARROW_ROAD_CAM if self.stream_type != target: self.switch_stream(target) @@ -138,14 +138,14 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewSP): def _update_calibration(self): # Update device camera if not already set sm = ui_state.sm - if not self.device_camera and sm.seen['roadCameraState'] and sm.seen['deviceState']: - self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] + if not self.device_camera and sm.seen['narrowRoadCameraState'] and sm.seen['deviceState']: + self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['narrowRoadCameraState'].sensor))] - # Check if live calibration data is available and valid - if not (sm.updated["liveCalibration"] and sm.valid['liveCalibration']): + # Check if camera calibration data is available and valid + if not (sm.updated["extrinsicsCalibration"] and sm.valid['extrinsicsCalibration']): return - calib = sm['liveCalibration'] + calib = sm['extrinsicsCalibration'] if len(calib.rpyCalib) != 3 or calib.calStatus != CALIBRATED: return @@ -161,7 +161,7 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewSP): def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray: # Check if we can use cached matrix cache_key = ( - ui_state.sm.recv_frame['liveCalibration'], + ui_state.sm.recv_frame['extrinsicsCalibration'], self._content_rect.width, self._content_rect.height, self.stream_type @@ -172,7 +172,7 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewSP): # Get camera configuration device_camera = self.device_camera or DEFAULT_DEVICE_CAMERA is_wide_camera = self.stream_type == WIDE_CAM - intrinsic = device_camera.ecam.intrinsics if is_wide_camera else device_camera.fcam.intrinsics + intrinsic = device_camera.wide_road.intrinsics if is_wide_camera else device_camera.narrow_road.intrinsics calibration = self.view_from_wide_calib if is_wide_camera else self.view_from_calib zoom = 2.0 if is_wide_camera else 1.1 @@ -231,7 +231,7 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewSP): if __name__ == "__main__": gui_app.init_window("OnRoad Camera View") - road_camera_view = AugmentedRoadView(ROAD_CAM) + road_camera_view = AugmentedRoadView(NARROW_ROAD_CAM) gui_app.push_widget(road_camera_view) print("***press space to switch camera view***") try: @@ -239,7 +239,7 @@ if __name__ == "__main__": ui_state.update() if rl.is_key_released(rl.KeyboardKey.KEY_SPACE): if WIDE_CAM in road_camera_view.available_streams: - stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM + stream = NARROW_ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM road_camera_view.switch_stream(stream) finally: road_camera_view.close() diff --git a/openpilot/selfdrive/ui/onroad/driver_camera_dialog.py b/openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py similarity index 90% rename from openpilot/selfdrive/ui/onroad/driver_camera_dialog.py rename to openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py index 7e07e44210..7bb1917c35 100644 --- a/openpilot/selfdrive/ui/onroad/driver_camera_dialog.py +++ b/openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py @@ -1,6 +1,6 @@ import numpy as np import pyray as rl -from msgq.visionipc import VisionStreamType +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 @@ -9,9 +9,9 @@ from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets.label import gui_label -class DriverCameraDialog(CameraView): +class CabinCameraDialog(CameraView): def __init__(self): - super().__init__("camerad", VisionStreamType.VISION_STREAM_DRIVER) + super().__init__("camerad", VisionStreamType.VISION_STREAM_CABIN) self.driver_state_renderer = DriverStateRenderer() # TODO: this can grow unbounded, should be given some thought device.add_interactive_timeout_callback(gui_app.pop_widget) @@ -100,12 +100,12 @@ class DriverCameraDialog(CameraView): if __name__ == "__main__": - gui_app.init_window("Driver Camera View") + gui_app.init_window("Cabin Camera View") - driver_camera_view = DriverCameraDialog() - gui_app.push_widget(driver_camera_view) + cabin_camera_view = CabinCameraDialog() + gui_app.push_widget(cabin_camera_view) try: for _ in gui_app.render(): ui_state.update() finally: - driver_camera_view.close() + cabin_camera_view.close() diff --git a/openpilot/selfdrive/ui/onroad/cameraview.py b/openpilot/selfdrive/ui/onroad/cameraview.py index 846bf20bbc..aa4f2271dc 100644 --- a/openpilot/selfdrive/ui/onroad/cameraview.py +++ b/openpilot/selfdrive/ui/onroad/cameraview.py @@ -2,9 +2,10 @@ import platform import numpy as np import pyray as rl -from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcClient, VisionBuf from openpilot.common.swaglog import cloudlog -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage from openpilot.system.ui.widgets import Widget @@ -38,7 +39,7 @@ void main() { """ # Choose fragment shader based on platform capabilities -if TICI: +if COMMA_HARDWARE: FRAME_FRAGMENT_SHADER = """ #version 300 es #extension GL_OES_EGL_image_external_essl3 : enable @@ -71,7 +72,7 @@ class CameraView(Widget): self._name = name # Primary stream self.client = VisionIpcClient(name, stream_type, conflate=True) - self._stream_type = stream_type + self._stream_type: VisionStreamType = stream_type self.available_streams: list[VisionStreamType] = [] # Target stream for switching @@ -82,7 +83,7 @@ class CameraView(Widget): self._texture_needs_update = True self.last_connection_attempt: float = 0.0 self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER) - self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1 + self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not COMMA_HARDWARE else -1 self.frame: VisionBuf | None = None self.texture_y: rl.Texture | None = None @@ -94,8 +95,8 @@ class CameraView(Widget): self._placeholder_color: rl.Color | None = None - # Initialize EGL for zero-copy rendering on TICI - if TICI: + # Initialize EGL for zero-copy rendering on COMMA_HARDWARE + if COMMA_HARDWARE: if not init_egl(): raise RuntimeError("Failed to initialize EGL") @@ -146,7 +147,7 @@ class CameraView(Widget): self._clear_textures() # Clean up EGL texture - if TICI and self.egl_texture: + if COMMA_HARDWARE and self.egl_texture: rl.unload_texture(self.egl_texture) self.egl_texture = None @@ -202,8 +203,8 @@ class CameraView(Widget): transform = self._calc_frame_matrix(rect) src_rect = rl.Rectangle(0, 0, float(self.frame.width), float(self.frame.height)) - # Flip driver camera horizontally - if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER: + # Flip cabin camera horizontally + if self._stream_type == VisionStreamType.VISION_STREAM_CABIN: src_rect.width = -src_rect.width # Calculate scale @@ -220,7 +221,7 @@ class CameraView(Widget): dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y) # Render with appropriate method - if TICI: + if COMMA_HARDWARE: self._render_egl(src_rect, dst_rect) else: self._render_textures(src_rect, dst_rect) @@ -323,6 +324,7 @@ class CameraView(Widget): del self.client # Switch to target + assert self._target_client is not None and self._target_stream_type is not None self.client = self._target_client self._stream_type = self._target_stream_type self._texture_needs_update = True @@ -337,7 +339,7 @@ class CameraView(Widget): def _initialize_textures(self): self._clear_textures() - if not TICI: + if not COMMA_HARDWARE: self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), @@ -353,7 +355,7 @@ class CameraView(Widget): self.texture_uv = None # Clean up EGL resources - if TICI: + if COMMA_HARDWARE: for data in self.egl_images.values(): destroy_egl_image(data) self.egl_images = {} @@ -361,6 +363,6 @@ class CameraView(Widget): if __name__ == "__main__": gui_app.init_window("camera view") - road = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD) + road = CameraView("camerad", VisionStreamType.VISION_STREAM_NARROW_ROAD) for _ in gui_app.render(): road.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) diff --git a/openpilot/selfdrive/ui/onroad/model_renderer.py b/openpilot/selfdrive/ui/onroad/model_renderer.py index 4e0dead3df..f5a39a2a5b 100644 --- a/openpilot/selfdrive/ui/onroad/model_renderer.py +++ b/openpilot/selfdrive/ui/onroad/model_renderer.py @@ -39,8 +39,8 @@ class ModelPoints: @dataclass class LeadVehicle: - glow: list[float] = field(default_factory=list) - chevron: list[float] = field(default_factory=list) + glow: list[tuple[float, float]] = field(default_factory=list) + chevron: list[tuple[float, float]] = field(default_factory=list) fill_alpha: int = 0 @@ -90,7 +90,7 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): sm = ui_state.sm # Check if data is up-to-date - if (sm.recv_frame["liveCalibration"] < ui_state.started_frame or + if (sm.recv_frame["extrinsicsCalibration"] < ui_state.started_frame or sm.recv_frame["modelV2"] < ui_state.started_frame): return @@ -102,8 +102,8 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): # Update state self._experimental_mode = sm['selfdriveState'].experimentalMode - live_calib = sm['liveCalibration'] - self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0] + extrinsics_calibration = sm['extrinsicsCalibration'] + self._path_offset_z = extrinsics_calibration.height[0] if extrinsics_calibration.height else HEIGHT_INIT[0] if self._counter % 60 == 0: self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0 @@ -192,7 +192,7 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): max_idx = self._get_path_length_idx(path_x_array, max_distance) self._path.projected_points = self._map_line_to_polygon( - self._path.raw_points, 0.9, self._path_offset_z, max_idx, max_distance, allow_invert=False + self._path.raw_points, self._get_path_half_width(), self._path_offset_z, max_idx, max_distance, allow_invert=False ) self._update_experimental_gradient() @@ -292,7 +292,7 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control self._blend_filter.update(int(allow_throttle)) - if ui_state.rainbow_path: + if ui_state.rainbow_path and self._lateral_active: self.rainbow_path.draw_rainbow_path(self._rect, self._path) return diff --git a/openpilot/selfdrive/ui/soundd.py b/openpilot/selfdrive/ui/soundd.py index 72be459834..a61e26b0b6 100644 --- a/openpilot/selfdrive/ui/soundd.py +++ b/openpilot/selfdrive/ui/soundd.py @@ -24,7 +24,7 @@ ALERT_RAMP_TIME = 4 # seconds to ramp to max volume for warningImmediate SELFDRIVE_STATE_TIMEOUT = 5 # 5 seconds FILTER_DT = 1. / (micd.SAMPLE_RATE / micd.FFT_SAMPLES) -AMBIENT_DB = 24 # DB where MIN_VOLUME is applied +AMBIENT_DB = 26 # DB where MIN_VOLUME is applied DB_SCALE = 30 # AMBIENT_DB + DB_SCALE is where MAX_VOLUME is applied VOLUME_BASE = 20 @@ -48,22 +48,17 @@ sound_list: dict[int, tuple[str, int | None, float]] = { AudibleAlert.disengage: ("disengage.wav", 1, MAX_VOLUME), AudibleAlert.refuse: ("refuse.wav", 1, MAX_VOLUME), - AudibleAlert.prompt: ("prompt.wav", 1, MAX_VOLUME), - AudibleAlert.promptRepeat: ("prompt.wav", None, MAX_VOLUME), - AudibleAlert.promptDistracted: ("prompt_distracted.wav", None, MAX_VOLUME), + AudibleAlert.prompt: ("warning.wav", 1, MAX_VOLUME), + AudibleAlert.promptRepeat: ("warning.wav", None, MAX_VOLUME), + AudibleAlert.promptDistracted: ("dm_warning.wav", None, MAX_VOLUME), AudibleAlert.preAlert: ("pre_alert.wav", 1, MAX_VOLUME), - AudibleAlert.warningSoft: ("warning_soft.wav", None, MAX_VOLUME), - AudibleAlert.warningImmediate: ("warning_immediate.wav", None, MAX_VOLUME), + AudibleAlert.warningSoft: ("critical.wav", None, MAX_VOLUME), + AudibleAlert.warningImmediate: ("dm_critical.wav", None, MAX_VOLUME), **sound_list_sp, } -if HARDWARE.get_device_type() == "tizi": - sound_list.update({ - AudibleAlert.engage: ("engage_tizi.wav", 1, MAX_VOLUME), - AudibleAlert.disengage: ("disengage_tizi.wav", 1, MAX_VOLUME), - }) def check_selfdrive_timeout_alert(sm): ss_missing = time.monotonic() - sm.recv_time['selfdriveState'] @@ -89,6 +84,7 @@ class Soundd(QuietMode): self.ramp_start_time = 0. self.selfdrive_timeout_alert = False + self.pending_stop = False self.spl_filter_weighted = FirstOrderFilter(0, 2.5, FILTER_DT, initialized=False) @@ -127,6 +123,10 @@ class Soundd(QuietMode): self.current_sound_frame += frames_to_write current_sound_frame = self.current_sound_frame % len(sound_data) loops = self.current_sound_frame // len(sound_data) + if self.pending_stop and current_sound_frame == 0: + self.current_alert = AudibleAlert.none + self.pending_stop = False + break return ret * self.current_volume @@ -137,6 +137,15 @@ class Soundd(QuietMode): def update_alert(self, new_alert): current_alert_played_once = self.current_alert == AudibleAlert.none or self.current_sound_frame >= len(self.loaded_sounds[self.current_alert]) + # let looping sounds finish the current loop instead of cutting off mid tone + if new_alert == AudibleAlert.none and self.current_alert != AudibleAlert.none and sound_list[self.current_alert][1] is None: + if current_alert_played_once: + self.pending_stop = True + else: + self.current_alert = AudibleAlert.none + self.current_sound_frame = 0 + return + self.pending_stop = False if self.current_alert != new_alert and (new_alert != AudibleAlert.none or current_alert_played_once): if new_alert == AudibleAlert.warningImmediate: self.ramp_start_volume = self.current_volume @@ -169,6 +178,7 @@ class Soundd(QuietMode): def soundd_thread(self): # sounddevice must be imported after forking processes import sounddevice as sd + micd.patch_sounddevice(sd) sm = messaging.SubMaster(['selfdriveState', 'selfdriveStateSP', 'soundPressure']) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/home.py b/openpilot/selfdrive/ui/sunnypilot/layouts/home.py new file mode 100644 index 0000000000..a8c0790d3f --- /dev/null +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/home.py @@ -0,0 +1,68 @@ +""" +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 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.text_measure import measure_text_cached +from openpilot.system.ui.lib.multilang import tr, trn +from openpilot.system.ui.widgets.label import gui_label + +BRAND_FONT_SIZE = 48 +BRAND_DESC_SPACING = 12 + + +class HomeLayoutSP(HomeLayout): + def _render_header(self): + font = gui_app.font(FontWeight.MEDIUM) + + version_text_width = self.header_rect.width + + if self.update_available: + version_text_width -= self.update_notif_rect.width + + highlight_color = rl.Color(75, 95, 255, 255) if self.current_state == HomeLayoutState.UPDATE else rl.Color(54, 77, 239, 255) + rl.draw_rectangle_rounded(self.update_notif_rect, 0.3, 10, highlight_color) + + text = tr("UPDATE") + text_size = measure_text_cached(font, text, HEAD_BUTTON_FONT_SIZE) + text_x = self.update_notif_rect.x + (self.update_notif_rect.width - text_size.x) // 2 + text_y = self.update_notif_rect.y + (self.update_notif_rect.height - text_size.y) // 2 + rl.draw_text_ex(font, text, rl.Vector2(int(text_x), int(text_y)), HEAD_BUTTON_FONT_SIZE, 0, rl.WHITE) + + if self.alert_count > 0: + version_text_width -= self.alert_notif_rect.width + + highlight_color = rl.Color(255, 70, 70, 255) if self.current_state == HomeLayoutState.ALERTS else rl.Color(226, 44, 44, 255) + rl.draw_rectangle_rounded(self.alert_notif_rect, 0.3, 10, highlight_color) + + alert_text = trn("{} ALERT", "{} ALERTS", self.alert_count).format(self.alert_count) + text_size = measure_text_cached(font, alert_text, HEAD_BUTTON_FONT_SIZE) + text_x = self.alert_notif_rect.x + (self.alert_notif_rect.width - text_size.x) // 2 + text_y = self.alert_notif_rect.y + (self.alert_notif_rect.height - text_size.y) // 2 + rl.draw_text_ex(font, alert_text, rl.Vector2(int(text_x), int(text_y)), HEAD_BUTTON_FONT_SIZE, 0, rl.WHITE) + + if self.update_available or self.alert_count > 0: + version_text_width -= SPACING * 1.5 + + version_right = self.header_rect.x + self.header_rect.width + version_left = version_right - version_text_width + + brand = "sunnypilot" + description = self.params.get("UpdaterCurrentDescription") or "" + + desc_width = 0 + if description: + 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) + + brand_size = measure_text_cached(gui_app.font(FontWeight.AUDIOWIDE), brand, BRAND_FONT_SIZE) + spacing = BRAND_DESC_SPACING if description else 0 + brand_x = version_right - desc_width - spacing - brand_size.x + brand_rect = rl.Rectangle(max(version_left, brand_x), self.header_rect.y, brand_size.x, self.header_rect.height) + gui_label(brand_rect, brand, BRAND_FONT_SIZE, rl.WHITE, font_weight=FontWeight.AUDIOWIDE) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py b/openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py index a86677a7b1..ee4b479afe 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py @@ -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.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)) + self._title = self._child(Label(tr("sunnylink"), font_size=90, font_weight=FontWeight.AUDIOWIDE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)) self._content = [ { diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py index e5c956cd81..99462dfcec 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py @@ -5,7 +5,7 @@ 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.layouts.settings.device import DeviceLayout -from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog +from openpilot.selfdrive.ui.onroad.cabin_camera_dialog import CabinCameraDialog from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.common.hardware import HARDWARE from openpilot.system.ui.lib.application import gui_app @@ -82,7 +82,7 @@ class DeviceLayoutSP(DeviceLayout): left_text=lambda: tr("Quiet Mode"), right_text=lambda: tr("Driver Camera Preview"), left_callback=lambda: ui_state.params.put_bool("QuietMode", not ui_state.params.get_bool("QuietMode")), - right_callback=lambda: gui_app.push_widget(DriverCameraDialog()) + right_callback=lambda: gui_app.push_widget(CabinCameraDialog()) ) self._quiet_mode_and_dcam.action_item.right_button.set_button_style(ButtonStyle.NORMAL) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/display.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/display.py index 8ba5663662..897d34085a 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/display.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/display.py @@ -9,7 +9,7 @@ from enum import IntEnum from openpilot.system.ui.widgets import Widget from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets.scroller_tici import Scroller -from openpilot.system.ui.sunnypilot.widgets.list_view import option_item_sp +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp, option_item_sp from openpilot.sunnypilot.system.params_migration import ONROAD_BRIGHTNESS_TIMER_VALUES @@ -61,10 +61,26 @@ class DisplayLayout(Widget): f"{value} s" if value < 60 else f"{int(value/60)} m"), inline=True ) + self._screensaver_toggle = toggle_item_sp( + param="ScreenSaverEnabled", + title=lambda: tr("Screen Saver"), + description=lambda: tr("Show a screen saver when the device is offroad and idle, instead of turning the screen off."), + ) + self._screensaver_timeout = option_item_sp( + param="ScreenSaverTimeout", + title=lambda: tr("Screen Saver Duration"), + description=lambda: tr("How long the screen saver runs before the screen turns off."), + min_value=60, + max_value=600, + value_change_step=60, + label_callback=lambda value: f"{int(value/60)} m" + ) items = [ self._onroad_brightness, self._onroad_brightness_timer, self._interactivity_timeout, + self._screensaver_toggle, + self._screensaver_timeout, ] return items @@ -87,6 +103,8 @@ class DisplayLayout(Widget): brightness_val = self._onroad_brightness.action_item.current_value self._onroad_brightness_timer.action_item.set_enabled(brightness_val not in (OnroadBrightness.AUTO, OnroadBrightness.AUTO_DARK)) + self._screensaver_timeout.set_visible(self._screensaver_toggle.action_item.get_state()) + def _render(self, rect): self._scroller.render(rect) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index 7a175b4037..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 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 @@ -22,9 +23,9 @@ from openpilot.system.ui.widgets.toggle import ON_COLOR from openpilot.sunnypilot.models.runners.constants import CUSTOM_MODEL_PATH from openpilot.system.ui.sunnypilot.lib.styles import style -from openpilot.system.ui.sunnypilot.lib.utils import NoElideButtonAction +from openpilot.system.ui.sunnypilot.lib.utils import NoElideButtonAction, ScrollingButtonAction from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP, toggle_item_sp, option_item_sp -from openpilot.system.ui.sunnypilot.widgets.progress_bar import progress_item +from openpilot.system.ui.sunnypilot.widgets.download_status import download_status_item from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder if gui_app.sunnypilot_ui(): @@ -35,35 +36,40 @@ class ModelsLayout(Widget): def __init__(self): super().__init__() self.model_manager = None - self.download_status = None - self.prev_download_status = 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() self.clear_cache_item.action_item.set_value(f"{self.calculate_cache_size():.2f} MB") - for ctrl, key in [(self.lane_turn_value_control, "LaneTurnValue"), (self.delay_control, "LagdToggleDelay")]: + for ctrl, key in [(self.lane_turn_value_control, "LaneTurnValue"), (self.delay_control, "LagdToggleDelay"), (self.camera_offset, "CameraOffset")]: ctrl.action_item.set_value(int(float(ui_state.params.get(key, return_default=True)) * 100)) 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=NoElideButtonAction(tr("SELECT")), - callback=self._handle_current_model_clicked + action_item=ScrollingButtonAction(tr("SELECT")), + callback=lambda: self._open_source_dialog("qcom") ) - self.supercombo_label = progress_item(tr("Driving Model")) - self.vision_label = progress_item(tr("Vision Model")) - self.policy_label = progress_item(tr("Policy Model")) - self.off_policy_label = progress_item(tr("Off-Policy Model")) - self.on_policy_label = progress_item(tr("On-Policy Model")) + 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( @@ -73,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,31 +101,35 @@ class ModelsLayout(Widget): self.lagd_toggle = toggle_item_sp(tr("Live Learning Steer Delay"), "", param="LagdToggle") - self.items = [self.current_model_item, self.cancel_download_item, self.supercombo_label, self.vision_label, - self.policy_label, self.off_policy_label, self.on_policy_label, 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 = option_item_sp(tr("Adjust Camera Offset"), "CameraOffset", -35, 35, + tr("Virtually shift camera's perspective to move model's center to Left(+ values) or Right (- values)"), + 1, None, True, "", style.BUTTON_ACTION_WIDTH, None, True, + lambda v: f"{v / 100:.2f} m") + + 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): desc = tr("Enable this for the car to learn and adapt its steering response time. Disable to use a fixed steering response time. " + "Keeping this on provides the stock openpilot experience.") if lagd_toggle: - desc += f"
    {tr('Live Steer Delay:')} {ui_state.sm['liveDelay'].lateralDelay:.3f} s" + desc += f"
    {tr('Live Steer Delay:')} {ui_state.sm['lateralDelay'].lateralDelay:.3f} s" elif ui_state.CP is not None: sw = float(ui_state.params.get("LagdToggleDelay", "0.2")) cp = ui_state.CP.steerActuatorDelay desc += f"
    {tr('Actuator Delay:')} {cp:.2f} s + {tr('Software Delay:')} {sw:.2f} s = {tr('Total Delay:')} {cp + sw:.2f} s" self.lagd_toggle.set_description(desc) - 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): @@ -130,132 +142,212 @@ class ModelsLayout(Widget): gui_app.push_widget(dialog) def _handle_bundle_download_progress(self): - labels = {custom.ModelManagerSP.Model.Type.supercombo: self.supercombo_label, - custom.ModelManagerSP.Model.Type.vision: self.vision_label, - custom.ModelManagerSP.Model.Type.policy: self.policy_label, - custom.ModelManagerSP.Model.Type.offPolicy: self.off_policy_label, - custom.ModelManagerSP.Model.Type.onPolicy: self.on_policy_label} - for label in labels.values(): - label.set_visible(False) self.cancel_download_item.set_visible(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.download_status = bundle.status - status_changed = self.prev_download_status != self.download_status - self.prev_download_status = self.download_status - - self.cancel_download_item.set_visible(bool(self.model_manager.selectedBundle) and ui_state.params.get("ModelManager_DownloadIndex") is not None) + self._downloading = False + self._verifying = False + self.download_item.set_visible(True) if (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5: self.last_cache_calc_time = current_time self.clear_cache_item.action_item.set_value(f"{self.calculate_cache_size():.2f} MB") - if self.download_status == custom.ModelManagerSP.DownloadStatus.downloading: + 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() - for model in bundle.models: - if label := labels.get(getattr(model.type, 'raw', model.type)): - label.set_visible(True) - p = model.artifact.downloadProgress - text, show, color = f"pending - {bundle.displayName}", False, rl.GRAY - if p.status == custom.ModelManagerSP.DownloadStatus.downloading: - text, show = f"{int(p.progress)}% - {bundle.displayName}", True - elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded, custom.ModelManagerSP.DownloadStatus.cached): - status_text = tr("from cache" if p.status == custom.ModelManagerSP.DownloadStatus.cached else "downloaded") - text, color = f"{bundle.displayName} - {status_text if status_changed else tr('ready')}", ON_COLOR - elif p.status == custom.ModelManagerSP.DownloadStatus.failed: - text, color = f"download failed - {bundle.displayName}", rl.RED - label.action_item.update(p.progress, text, show, color) + 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 _show_reset_params_dialog(): - def _callback(response): - if response == DialogResult.CONFIRM: - ui_state.params.remove("CalibrationParams") - ui_state.params.remove("LiveTorqueParameters") - msg = tr("Model download has started in the background. We suggest resetting calibration. Would you like to do that now?") - dialog = ConfirmDialog(msg, tr("Reset Calibration"), callback=_callback) - gui_app.push_widget(dialog) + 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: + """Maps a bundle's artifact progress to DownloadStatusAction.update kwargs.""" + # .raw: _DynamicEnum equals its int but does not hash like it + statuses = {getattr(p.status, 'raw', p.status) for p in progresses} + progress = sum(p.progress for p in progresses) / len(progresses) + ds = custom.ModelManagerSP.DownloadStatus + + if ds.failed in statuses: + # 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}: + return {"name": name, "text_color": ON_COLOR, "icon": "icons/checkmark.png"} + # circled_slash is authored grey; tinting it again only darkens it + return {"name": name, "text_color": rl.GRAY, "icon": "icons/circled_slash.png", "icon_color": rl.WHITE} def _on_model_selected(self, result): if result != DialogResult.CONFIRM: + self.model_dialog = None return selected_ref = self.model_dialog.selection_ref - if selected_ref == "Default": - ui_state.params.remove("ModelManager_ActiveBundle") - self._show_reset_params_dialog() - 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) - if self.model_manager.activeBundle and selected_bundle.generation != self.model_manager.activeBundle.generation: - self._show_reset_params_dialog() 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"{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.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) self.lagd_toggle.action_item.set_state(live_delay) self.delay_control.set_visible(not live_delay and advanced_controls) new_step = int(round(100 / CV.MPH_TO_KPH)) if ui_state.is_metric else 100 - if self.lane_turn_value_control.action_item.value_change_step != new_step: + if self.lane_turn_value_control.action_item is not None and self.lane_turn_value_control.action_item.value_change_step != new_step: self.lane_turn_value_control.action_item.value_change_step = new_step + self.camera_offset.set_visible(camera_offset) self._update_lagd_description(live_delay) self.model_manager = ui_state.sm["modelManagerSP"] self._handle_bundle_download_progress() - active_name = self.model_manager.activeBundle.internalName if self.model_manager and self.model_manager.activeBundle.ref else f"{DEFAULT_MODEL} (Default)" - self.current_model_item.action_item.set_value(active_name) - if not ui_state.is_offroad(): - self.current_model_item.action_item.set_enabled(False) - self.current_model_item.set_description(tr("Only available when vehicle is off, or always offroad mode is on")) - else: - self.current_model_item.action_item.set_enabled(True) - self.current_model_item.set_description("") + carry_source, _, carry_display = carrying_model() + for item, item_source in ((self.small_model_item, "qcom"), (self.big_model_item, "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/network.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/network.py index 14f573c628..2663607fc8 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/network.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/network.py @@ -38,8 +38,8 @@ class NetworkUISP(NetworkUI): self.scan_button.set_text(tr("Scan")) self.scan_button.set_enabled(True) - def _render(self, rect: rl.Rectangle): - super()._render(rect) + def _render(self, _): + super()._render(_) if self._current_panel == PanelType.WIFI: self.scan_button.set_position(self._rect.x, self._rect.y + 20) 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/settings.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/settings.py index 4917c9a157..1b85c0923a 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/settings.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/settings.py @@ -37,7 +37,7 @@ from openpilot.system.ui.widgets.scroller_tici import Scroller OP.PANEL_COLOR = rl.Color(10, 10, 10, 255) ICON_SIZE = 70 -OP.PanelType = IntEnum( +OP.PanelType = IntEnum( # type: ignore[assignment] # ty: ignore[invalid-assignment] "PanelType", [es.name for es in OP.PanelType] + [ "SUNNYLINK", @@ -180,20 +180,18 @@ class SettingsLayoutSP(OP.SettingsLayout): self._sidebar_scroller.render(nav_rect) return - def _handle_mouse_release(self, mouse_pos: MousePos) -> bool: + def _handle_mouse_release(self, mouse_pos: MousePos) -> None: # Check close button if rl.check_collision_point_rec(mouse_pos, self._close_btn_rect): if self._close_callback: self._close_callback() - return True + return # Check navigation buttons for panel_type, panel_info in self._panels.items(): if rl.check_collision_point_rec(mouse_pos, panel_info.button_rect) and self._sidebar_scroller.scroll_panel.is_touch_valid(): self.set_current_panel(panel_type) - return True - - return False + return def show_event(self): super().show_event() diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering.py index 15cb6a15e0..28d9236361 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering.py @@ -139,7 +139,8 @@ class SteeringLayout(Widget): self._nnlc_toggle.action_item.set_state(False) enforce_torque_enabled = False nnlc_enabled = False - self._nnlc_toggle.action_item.set_enabled(ui_state.is_offroad() and torque_allowed and not enforce_torque_enabled) + jerk_aware_enabled = ui_state.params.get_bool("LateralJerkTorqueController") + self._nnlc_toggle.action_item.set_enabled(ui_state.is_offroad() and torque_allowed and not enforce_torque_enabled and not jerk_aware_enabled) self._torque_control_toggle.action_item.set_enabled(ui_state.is_offroad() and torque_allowed and not nnlc_enabled) self._torque_customization_button.action_item.set_enabled(self._torque_control_toggle.action_item.get_state()) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/lane_change_settings.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/lane_change_settings.py index fbb9ce7cf7..82419a5567 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/lane_change_settings.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/lane_change_settings.py @@ -51,11 +51,18 @@ class LaneChangeSettingsLayout(Widget): description=lambda: tr("Toggle to enable a delay timer for seamless lane changes when blind spot monitoring " + "(BSM) detects a obstructing vehicle, ensuring safe maneuvering."), ) + self._road_edge_block = toggle_item_sp( + param="RoadEdgeLaneChangeEnabled", + title=lambda: tr("Block Lane Change: Road Edge Detection"), + description=lambda: tr("Blocks the lane change if the model sees a road edge on your signaled side."), + ) items = [ self._lane_change_timer, LineSeparatorSP(40), self._bsm_delay, + LineSeparatorSP(40), + self._road_edge_block, ] return items diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py index 098fcf8ce8..4cde489954 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py @@ -7,7 +7,7 @@ See the LICENSE.md file in the root directory for more details. from collections.abc import Callable import pyray as rl -from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP +from opendbc.sunnypilot.car.tesla.values import MadsScreenButtonType, TeslaFlagsSP from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.sunnypilot.mads.helpers import MadsSteeringModeOnBrake from openpilot.system.ui.lib.multilang import tr, tr_noop @@ -96,7 +96,10 @@ class MadsSettingsLayout(Widget): if brand == "rivian": return True elif brand == "tesla": - return not (ui_state.CP_SP is not None and ui_state.CP_SP.flags & TeslaFlagsSP.HAS_VEHICLE_BUS) + if ui_state.CP_SP is None or not ui_state.CP_SP.flags & TeslaFlagsSP.HAS_VEHICLE_BUS: + return True + screen_button = int(ui_state.params.get("TeslaMadsScreenButton", return_default=True)) + return screen_button == MadsScreenButtonType.OFF return False def _update_steering_mode_description(self, button_index: int): diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/torque_settings.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/torque_settings.py index f3c4419e45..6dae8308cd 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/torque_settings.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/torque_settings.py @@ -40,6 +40,13 @@ class TorqueSettingsLayout(Widget): self.cached_torque_versions = json.load(f) def _initialize_items(self): + self._jerk_aware_toggle = toggle_item_sp( + param="LateralJerkTorqueController", + title=lambda: tr("Lateral Jerk Torque Controller"), + description=lambda: tr("Looks ahead at planned steering to reduce sudden corrections, so the wheel moves " + + "more smoothly through turns. Works with Self-Tune and custom tuning. " + + "Thanks to @twilsonco for the implementation."), + ) self._torque_control_versions = ListItemSP( title=tr("Torque Control Tune Version"), description="Select the version of Torque Control Tune to use.", @@ -95,6 +102,7 @@ class TorqueSettingsLayout(Widget): ) items = [ + self._jerk_aware_toggle, self._torque_control_versions, self._self_tune_toggle, self._relaxed_tune_toggle, @@ -107,6 +115,8 @@ class TorqueSettingsLayout(Widget): def _update_state(self): super()._update_state() + nnlc_enabled = ui_state.params.get_bool("NeuralNetworkLateralControl") + self._jerk_aware_toggle.action_item.set_enabled(ui_state.is_offroad() and not nnlc_enabled) if not ui_state.params.get_bool("LiveTorqueParamsToggle"): ui_state.params.remove("LiveTorqueParamsRelaxedToggle") self._relaxed_tune_toggle.action_item.set_state(False) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/tesla.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/tesla.py index 46d536c651..10fb3e1b4b 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/tesla.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/tesla.py @@ -4,10 +4,11 @@ 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 opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.multilang import tr -from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp +from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp, toggle_item_sp COOP_STEERING_MIN_KMH = 23 OEM_STEERING_MIN_KMH = 48 @@ -18,7 +19,14 @@ class TeslaSettings(BrandSettings): def __init__(self): super().__init__() self.coop_steering_toggle = toggle_item_sp(tr("Cooperative Steering (Beta)"), "", param="TeslaCoopSteering") - self.items = [self.coop_steering_toggle] + self.mads_screen_button = multiple_button_item_sp( + title=lambda: tr("MADS Screen Activation"), + description="", + buttons=[lambda: tr("Off"), lambda: tr("3-Finger"), lambda: tr("4-Finger"), lambda: tr("5-Finger")], + param="TeslaMadsScreenButton", + inline=False, + ) + self.items = [self.coop_steering_toggle, self.mads_screen_button] def update_settings(self): is_metric = ui_state.is_metric @@ -41,3 +49,18 @@ class TeslaSettings(BrandSettings): self.coop_steering_toggle.set_description(coop_steering_desc) self.coop_steering_toggle.action_item.set_enabled(ui_state.is_offroad()) + + has_vehicle_bus = ui_state.CP_SP is not None and bool(ui_state.CP_SP.flags & TeslaFlagsSP.HAS_VEHICLE_BUS) + self.mads_screen_button.set_visible(has_vehicle_bus) + + mads_screen_button_desc = ( + f"{tr('Use a multi-finger press on the infotainment screen to toggle MADS.')} " + + f"{tr('This allows the use of full MADS functionality when enabled.')}

    " + + f"{tr('Selecting a higher finger count may reduce accidental activations.')}

    " + + f"{tr('Note: Setting this to Off will reset your MADS settings to default.')}" + ) + if not ui_state.is_offroad(): + mads_screen_button_disabled_msg = tr("Enable \"Always Offroad\" in Device panel, or turn vehicle off to change.") + mads_screen_button_desc = f"{mads_screen_button_disabled_msg}

    {mads_screen_button_desc}" + self.mads_screen_button.set_description(mads_screen_button_desc) + self.mads_screen_button.action_item.set_enabled(ui_state.is_offroad()) 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 new file mode 100644 index 0000000000..8efb6c80e4 --- /dev/null +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/home.py @@ -0,0 +1,34 @@ +""" +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.icon_widget import IconWidget +from openpilot.system.ui.widgets.label import UnifiedLabel + + +class MiciHomeLayoutSP(MiciHomeLayout): + def __init__(self): + super().__init__() + self._openpilot_label = UnifiedLabel("sunnypilot", font_size=88, font_weight=FontWeight.AUDIOWIDE, max_width=480, wrap_text=False) + self._chestnut_loading_icon = IconWidget("icons_mici/chestnut.png", (68, 40)) + self._chestnut_loading_icon.set_visible(False) + failed_idx = self._status_bar_layout.widgets.index(self._chestnut_failed_icon) + self._status_bar_layout.widgets.insert(failed_idx + 1, self._chestnut_loading_icon) + + def _set_chestnut_visibility(self): + # stock has no loading tier: it shows green from the moment a big model is available. keep the + # pulse so the status bar and the onroad HUD agree on what loading looks like. + loading = ui_state.chestnut_state == ChestnutState.LOADING + self._chestnut_loading_icon._opacity = 0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0)) + self._chestnut_loading_icon.set_visible(loading) + self._chestnut_icon.set_visible(not loading and ui_state.chestnut_state in (ChestnutState.READY, ChestnutState.ACTIVE)) + self._chestnut_failed_icon.set_visible(ui_state.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 5f3f77d62c..4c769498f5 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -4,20 +4,40 @@ 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 collections.abc import Callable import pyray as rl from openpilot.cereal import custom -from openpilot.sunnypilot.models.default_model import 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__() @@ -27,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"{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) @@ -48,22 +68,21 @@ class CurrentModelInfo(Widget): self.info_text.render() class ModelsLayoutMici(NavScroller): - def __init__(self, back_callback: Callable): + def __init__(self): super().__init__() - self.set_back_callback(back_callback) - self.original_back_callback = back_callback self.focused_widget = None self.current_model_info = CurrentModelInfo() 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) @@ -72,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"), "") @@ -85,63 +103,81 @@ class ModelsLayoutMici(NavScroller): return folders - def _show_selection_view(self, items, back_callback: Callable): - self._scroller._items = items - for item in items: - item.set_touch_valid_callback(lambda: self._scroller.scroll_panel.is_touch_valid() and self._scroller.enabled) - self._scroller.scroll_panel.set_offset(0) - self.set_back_callback(back_callback) + def _push_selection_view(self, items): + scroller = NavScroller() + scroller._scroller.add_widgets(items) + gui_app.push_widget(scroller) 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"{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) - self._show_selection_view(folder_buttons, self._reset_main_view) + 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) - self._reset_main_view() + ui_state.params.put("ModelManager_DownloadRef", bundle.ref) + self._pop_to_main() - def _select_default(self): - ui_state.params.remove("ModelManager_ActiveBundle") - self._reset_main_view() + 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._show_selection_view(btns, self._show_folders) - - def _reset_main_view(self): - self._scroller._items = self.main_items - self.set_back_callback(self.original_back_callback) - self._scroller.scroll_panel.set_offset(0) - self._scroller.scroll_to(0) + self._push_selection_view(btns) def hide_event(self): super().hide_event() @@ -170,10 +206,10 @@ class ModelsLayoutMici(NavScroller): self._was_downloading = is_downloading self.current_model_info.current_model_header.set_text(tr("active model")) - model_text = manager.activeBundle.displayName.lower() if manager.activeBundle.ref else f"{DEFAULT_MODEL} (Default)".lower() - self.current_model_info.current_model_text.set_text(model_text) - self.current_model_info.info_header.set_text(tr("cache size")) - self.current_model_info.info_text.set_text(f"{ModelsLayout.calculate_cache_size():.2f} MB") + active_text, info_header, info_text = _model_info() + self.current_model_info.current_model_text.set_text(active_text) + self.current_model_info.info_header.set_text(info_header) + self.current_model_info.info_text.set_text(info_text) if manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed: self.current_model_info.info_header.set_text(tr("error") + self._download_progress) @@ -184,19 +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/onboarding.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py index a98f5a2e2e..5aed8fac38 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py @@ -16,6 +16,9 @@ class SunnylinkConsentPage(NavScroller): def __init__(self, on_accept: Callable | None = None, on_decline: Callable | None = None): super().__init__() + assert on_accept is not None and callable(on_accept) + assert on_decline is not None and callable(on_decline) + self._accept_button = BigConfirmationCircleButton("enable\nsunnylink", gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64), on_accept, exit_on_confirm=False) diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py index 96a4c789c1..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) @@ -32,11 +42,11 @@ class SettingsLayoutSP(OP.SettingsLayout): BIG_ICON_SIZE) self.icon_offroad_slider = gui_app.texture("icons_mici/settings/device/lkas.png", BIG_ICON_SIZE, BIG_ICON_SIZE) - sunnylink_panel = SunnylinkLayoutMici(back_callback=gui_app.pop_widget) - sunnylink_btn = SettingsBigButton(tr("sunnylink"), "", gui_app.texture("icons_mici/settings/developer/ssh.png", 55, 55)) + sunnylink_panel = SunnylinkLayoutMici() + 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(back_callback=gui_app.pop_widget) + models_panel = ModelsLayoutMici() models_btn = SettingsBigButton(tr("models"), "", gui_app.texture("../../sunnypilot/selfdrive/assets/offroad/icon_models.png", ICON_SIZE, ICON_SIZE)) models_btn.set_click_callback(lambda: gui_app.push_widget(models_panel)) @@ -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/mici/layouts/sunnylink.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py index e804c78035..7c42f99f83 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py @@ -6,7 +6,6 @@ See the LICENSE.md file in the root directory for more details. """ import pyray as rl -from collections.abc import Callable from openpilot.cereal import custom from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle @@ -54,9 +53,8 @@ class SunnylinkInfo(Widget): self.sponsor_text.render() class SunnylinkLayoutMici(NavScroller): - def __init__(self, back_callback: Callable): + def __init__(self): super().__init__() - self.set_back_callback(back_callback) self._restore_in_progress = False self._backup_in_progress = False self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled") 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/onroad/developer_ui/__init__.py b/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py index 889e1737ee..9d6cfd15a7 100644 --- a/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py +++ b/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py @@ -142,7 +142,7 @@ class DeveloperUiRenderer(Widget): # Add torque-specific elements if using torque control if sm['controlsState'].lateralControlState.which() == 'torqueState': override_active = ui_state.enforce_torque_control and ui_state.custom_torque_params and ui_state.torque_override_enabled - if sm.valid['liveTorqueParameters'] or override_active: + if sm.valid['lateralTorqueParameters'] or override_active: elements.extend([ self.friction_elem.update(sm, ui_state.is_metric), self.lat_accel_factor_elem.update(sm, ui_state.is_metric), diff --git a/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py b/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py index 4119990f22..a8ecb5f8ab 100644 --- a/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py +++ b/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py @@ -160,7 +160,7 @@ class ActualLateralAccelElement(LateralControlElement): controls_state = sm['controlsState'] curvature = controls_state.curvature v_ego = sm['carState'].vEgo - roll = sm['liveParameters'].roll if sm.valid['liveParameters'] else 0.0 + roll = sm['vehicleParameters'].roll if sm.valid['vehicleParameters'] else 0.0 lat_active = sm['carControl'].latActive steer_override = sm['carState'].steeringPressed @@ -179,7 +179,7 @@ class DesiredLateralAccelElement(LateralControlElement): controls_state = sm['controlsState'] desired_curvature = controls_state.desiredCurvature v_ego = sm['carState'].vEgo - roll = sm['liveParameters'].roll if sm.valid['liveParameters'] else 0.0 + roll = sm['vehicleParameters'].roll if sm.valid['vehicleParameters'] else 0.0 lat_active = sm['carControl'].latActive steer_override = sm['carState'].steeringPressed @@ -250,9 +250,9 @@ class FrictionCoefficientElement: if ui_state.enforce_torque_control and ui_state.custom_torque_params and ui_state.torque_override_enabled: return UiElement(f"{ui_state.torque_override_friction:.3f}", "FRIC.", self.unit, rl.WHITE) - ltp = sm['liveTorqueParameters'] + ltp = sm['lateralTorqueParameters'] value = f"{ltp.frictionCoefficientFiltered:.3f}" - color = rl.Color(0, 255, 0, 255) if ltp.liveValid else rl.WHITE + color = rl.Color(0, 255, 0, 255) if ltp.valid else rl.WHITE return UiElement(value, "FRIC.", self.unit, color) @@ -264,9 +264,9 @@ class LatAccelFactorElement: if ui_state.enforce_torque_control and ui_state.custom_torque_params and ui_state.torque_override_enabled: return UiElement(f"{ui_state.torque_override_lat_accel_factor:.3f}", "L.A.F.", self.unit, rl.WHITE) - ltp = sm['liveTorqueParameters'] + ltp = sm['lateralTorqueParameters'] value = f"{ltp.latAccelFactorFiltered:.3f}" - color = rl.Color(0, 255, 0, 255) if ltp.liveValid else rl.WHITE + color = rl.Color(0, 255, 0, 255) if ltp.valid else rl.WHITE return UiElement(value, "L.A.F.", self.unit, color) diff --git a/openpilot/selfdrive/ui/sunnypilot/onroad/model_renderer.py b/openpilot/selfdrive/ui/sunnypilot/onroad/model_renderer.py index 5d78997662..4be82bef4a 100644 --- a/openpilot/selfdrive/ui/sunnypilot/onroad/model_renderer.py +++ b/openpilot/selfdrive/ui/sunnypilot/onroad/model_renderer.py @@ -4,11 +4,29 @@ 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.filter_simple import FirstOrderFilter +from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.sunnypilot.onroad.chevron_metrics import ChevronMetrics from openpilot.selfdrive.ui.sunnypilot.onroad.rainbow_path import RainbowPath +from openpilot.selfdrive.ui.sunnypilot.ui_state import MADSState +from openpilot.system.ui.lib.application import gui_app class ModelRendererSP: def __init__(self): self.rainbow_path = RainbowPath() self.chevron_metrics = ChevronMetrics() + self._width_filter = FirstOrderFilter(0.9, 0.1, 1 / gui_app.target_fps) + + @property + def _lateral_active(self) -> bool: + sm = ui_state.sm + if sm.valid["selfdriveStateSP"]: + mads = sm["selfdriveStateSP"].mads + if mads.available: + return mads.enabled and mads.state != MADSState.paused + return ui_state.status in (UIStatus.ENGAGED, UIStatus.LAT_ONLY) + + def _get_path_half_width(self) -> float: + target = 0.9 if self._lateral_active else 0.40 + return self._width_filter.update(target) diff --git a/openpilot/selfdrive/ui/sunnypilot/onroad/speed_limit.py b/openpilot/selfdrive/ui/sunnypilot/onroad/speed_limit.py index 7851698683..fa8143eb32 100644 --- a/openpilot/selfdrive/ui/sunnypilot/onroad/speed_limit.py +++ b/openpilot/selfdrive/ui/sunnypilot/onroad/speed_limit.py @@ -198,7 +198,7 @@ class SpeedLimitRenderer(Widget, SpeedLimitAlertRenderer): self._draw_ahead_info(sign_rect) def _draw_sign_main(self, rect, alpha=1.0): - speed_limit_warning_enabled = ui_state.speed_limit_mode >= SpeedLimitMode.warning + speed_limit_warning_enabled = ui_state.speed_limit_mode is not None and ui_state.speed_limit_mode >= SpeedLimitMode.warning has_limit = self.speed_limit_valid or self.speed_limit_last_valid is_overspeed = has_limit and round(self.speed_limit_final_last) < round(self.speed) diff --git a/openpilot/selfdrive/ui/sunnypilot/ui_state.py b/openpilot/selfdrive/ui/sunnypilot/ui_state.py index 4b91bd4021..d130c3862a 100644 --- a/openpilot/selfdrive/ui/sunnypilot/ui_state.py +++ b/openpilot/selfdrive/ui/sunnypilot/ui_state.py @@ -10,8 +10,10 @@ 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 OpenpilotState = log.SelfdriveState.OpenpilotState MADSState = custom.ModularAssistiveDrivingSystem.ModularAssistiveDrivingSystemState @@ -33,12 +35,16 @@ class UIStateSP: self.is_sp_release: bool = self.params.get_bool("IsReleaseSpBranch") self.sm_services_ext = [ "modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP", - "gpsLocation", "liveTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "liveDelay" + "gpsLocation", "lateralTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "lateralDelay" ] self.sunnylink_state = SunnylinkState() + self.screensaver = ScreenSaverSP(params=self.params) + 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 @@ -146,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) @@ -170,6 +182,7 @@ class UIStateSP: self.turn_signals = self.params.get_bool("ShowTurnSignals") self.boot_offroad_mode = self.params.get("DeviceBootMode", return_default=True) self.always_offroad = self.params.get_bool("OffroadMode") + self.screensaver_enabled = self.params.get_bool("ScreenSaverEnabled") if not self._sp_initialized: self._sp_initialized = True @@ -184,10 +197,15 @@ class UIStateSP: self.params.put_bool("EnforceTorqueControl", False, block=True) self.params.put_bool("NeuralNetworkLateralControl", False, block=True) + if self.params.get_bool("LateralJerkTorqueController") and self.params.get_bool("NeuralNetworkLateralControl"): + self.params.put_bool("LateralJerkTorqueController", False, block=True) + self.params.put_bool("NeuralNetworkLateralControl", False, block=True) + # Angle steering: no torque-based lateral controls if CP.steerControlType == car.CarParams.SteerControlType.angle: self.params.remove("EnforceTorqueControl") self.params.remove("NeuralNetworkLateralControl") + self.params.remove("LateralJerkTorqueController") # Alpha longitudinal: clear if not available if not CP.alphaLongitudinalAvailable: @@ -200,6 +218,7 @@ class UIStateSP: # No CarParams: clear all car-dependent params as safety default self.params.remove("EnforceTorqueControl") self.params.remove("NeuralNetworkLateralControl") + self.params.remove("LateralJerkTorqueController") self.params.remove("AlphaLongitudinalEnabled") # No longitudinal control: no experimental mode or DEC @@ -224,11 +243,33 @@ class UIStateSP: class DeviceSP: - @staticmethod - def _set_awake(on: bool, _ui_state): - if _ui_state.boot_offroad_mode == 1 and not on: + def __init__(self): + self._blocked_by_screensaver: bool = False + + def _set_awake(self, on: bool, _ui_state=None): + self._blocked_by_screensaver = False + + if not on and _ui_state.screensaver_enabled: + if _ui_state.screensaver.was_dismissed: + self.dismiss_screensaver(_ui_state) + elif _ui_state.screensaver.is_active: + self._blocked_by_screensaver = True + else: + _ui_state.screensaver.initialize() + gui_app.push_widget(_ui_state.screensaver) + self._blocked_by_screensaver = True + else: + self.dismiss_screensaver(_ui_state) + + # blocked runs every frame, so write only when actually sleeping + if _ui_state.boot_offroad_mode == 1 and not on and not self._blocked_by_screensaver: _ui_state.params.put_bool("OffroadMode", True) + def dismiss_screensaver(self, _ui_state) -> None: + if gui_app.get_active_widget() == _ui_state.screensaver: + gui_app.pop_widget() + self._blocked_by_screensaver = False + @staticmethod def set_onroad_brightness(_ui_state, awake: bool, cur_brightness: float) -> float: if not awake or not _ui_state.started: diff --git a/openpilot/selfdrive/ui/tests/diff/replay_script.py b/openpilot/selfdrive/ui/tests/diff/replay_script.py index 8517f0dece..fb654c5e8b 100644 --- a/openpilot/selfdrive/ui/tests/diff/replay_script.py +++ b/openpilot/selfdrive/ui/tests/diff/replay_script.py @@ -138,21 +138,25 @@ def setup_update_available(available: bool = True) -> None: params.remove("UpdaterTargetBranch") +def set_updater_state(state: str) -> None: + Params().put("UpdaterState", state, block=True) + + def setup_calibration_params() -> None: params = Params() - # live calibration - calib = messaging.new_message('liveCalibration') - calib.liveCalibration.calStatus = log.LiveCalibrationData.Status.calibrated - calib.liveCalibration.rpyCalib = [0.0, math.radians(2.5), math.radians(-1.2)] + # camera calibration + calib = messaging.new_message('extrinsicsCalibration') + calib.extrinsicsCalibration.calStatus = log.ExtrinsicsCalibration.Status.calibrated + calib.extrinsicsCalibration.rpyCalib = [0.0, math.radians(2.5), math.radians(-1.2)] params.put("CalibrationParams", calib.to_bytes(), block=True) - # live delay - delay = messaging.new_message('liveDelay') - delay.liveDelay.calPerc = 75 + # lateral delay + delay = messaging.new_message('lateralDelay') + delay.lateralDelay.calPerc = 75 params.put("LiveDelay", delay.to_bytes(), block=True) - # live torque parameters - torque = messaging.new_message('liveTorqueParameters') - torque.liveTorqueParameters.useParams = True - torque.liveTorqueParameters.calPerc = 60 + # lateral torque parameters + torque = messaging.new_message('lateralTorqueParameters') + torque.lateralTorqueParameters.useParams = True + torque.lateralTorqueParameters.calPerc = 60 params.put("LiveTorqueParameters", torque.to_bytes(), block=True) @@ -317,7 +321,7 @@ def build_mici_script(pm: PubMaster, main_layout, script: Script) -> None: lambda: swipe_left(width * 2), click, # first page, click next lambda: swipe_left(width * 2), swipe_down # second page, go back (TODO: make driver cam preview work) ), - None, # TODO: preview driver camera; enabling this causes MultiplePublishersError later in onroad alert tests + None, # TODO: preview cabin camera; enabling this causes MultiplePublishersError later in onroad alert tests lambda: explore_setting(swipe_left), # terms & conditions (swipe to view QR code) lambda: explore_setting(lambda: swipe_up(height * 3), lambda: swipe_down(height * 3)), # regulatory info lambda: run_actions(click, lambda: swipe_left(width)), # reset calibration confirm (goes back automatically) @@ -338,8 +342,11 @@ def build_mici_script(pm: PubMaster, main_layout, script: Script) -> None: settings_cases: Cases = [ lambda: scroll_through_cases(toggle_cases), + None, # sunnylink (just open and close) + None, # models (just open and close) lambda: scroll_through_cases(network_cases), lambda: scroll_through_cases(device_cases), + lambda: script.wait(WAIT_SHORT), # software lambda: script.wait(WAIT_SHORT), # pairing lambda: run_actions(lambda: swipe_up(height * 3), lambda: swipe_down(height * 3)), # firehose (scroll down and back up) lambda: scroll_through_cases(developer_cases), @@ -357,7 +364,7 @@ def build_mici_script(pm: PubMaster, main_layout, script: Script) -> None: params = Params() main_layout._alerts_layout._pending_params = ({"UpdaterNewDescription": params.get("UpdaterNewDescription")} | {alert_data.key: params.get(alert_data.key) for alert_data in main_layout._alerts_layout.sorted_alerts}) - main_layout._alerts_layout._refresh() + main_layout._alerts_layout._update_state() swipe_right(width, wait_after=WAIT_SHORT) # open alerts script.setup(setup_offroad_alerts_and_refresh) # show alerts @@ -479,6 +486,9 @@ def build_tizi_script(pm: PubMaster, main_layout, script: Script) -> None: # === Settings - Software === script.setup(lambda: setup_update_available(False), wait_after=0) # start with no update available script.click(278, 720) # software + script.setup(lambda: set_updater_state("checking...")) # updater mid-check + script.setup(lambda: set_updater_state("downloading...")) # updater mid-download + script.setup(lambda: set_updater_state("idle"), wait_after=0) for _ in range(2): script.click(720, 120) # toggle current release notes script.setup(setup_update_available) # set update available diff --git a/openpilot/selfdrive/ui/tests/profile_onroad.py b/openpilot/selfdrive/ui/tests/profile_onroad.py index 18194d7363..98816f9ddf 100755 --- a/openpilot/selfdrive/ui/tests/profile_onroad.py +++ b/openpilot/selfdrive/ui/tests/profile_onroad.py @@ -5,7 +5,8 @@ import cProfile import pyray as rl import numpy as np -from msgq.visionipc import VisionIpcServer, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcServer from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout from openpilot.system.ui.lib.application import gui_app @@ -49,7 +50,7 @@ def patch_submaster(message_chunks): sm.recv_frame[service] = sm.frame sm.valid[service] = True sm.frame += 1 - ui_state.sm.update = mock_update + ui_state.sm.update = mock_update # ty: ignore[invalid-assignment] # profiling hook if __name__ == "__main__": @@ -89,17 +90,17 @@ if __name__ == "__main__": W, H = 2048, 1216 vipc = VisionIpcServer("camerad") - vipc.create_buffers(VisionStreamType.VISION_STREAM_ROAD, 5, W, H) + vipc.create_buffers(VisionStreamType.VISION_STREAM_NARROW_ROAD, 5, W, H) vipc.start_listener() yuv_buffer_size = W * H + (W // 2) * (H // 2) * 2 - yuv_data = np.random.randint(0, 256, yuv_buffer_size, dtype=np.uint8).tobytes() + yuv_data = np.random.default_rng().integers(0, 256, yuv_buffer_size, dtype=np.uint8).tobytes() with cProfile.Profile() as pr: for _ in gui_app.render(): if ui_state.sm.frame >= len(message_chunks): break if ui_state.sm.frame % 3 == 0: eof = int((ui_state.sm.frame % 3) * 0.05 * 1e9) - vipc.send(VisionStreamType.VISION_STREAM_ROAD, yuv_data, ui_state.sm.frame % 3, eof, eof) + vipc.send(VisionStreamType.VISION_STREAM_NARROW_ROAD, yuv_data, ui_state.sm.frame % 3, eof, eof) ui_state.update() pr.dump_stats(f'{args.output}_deterministic.stats') diff --git a/openpilot/selfdrive/ui/tests/test_feedbackd.py b/openpilot/selfdrive/ui/tests/test_feedbackd.py deleted file mode 100644 index 72bd499081..0000000000 --- a/openpilot/selfdrive/ui/tests/test_feedbackd.py +++ /dev/null @@ -1,53 +0,0 @@ -import pytest -import openpilot.cereal.messaging as messaging -from opendbc.car.structs import car -from openpilot.common.params import Params -from openpilot.system.manager.process_config import managed_processes - - -@pytest.mark.skip("tmp disabled") -class TestFeedbackd: - def setup_method(self): - self.pm = messaging.PubMaster(['carState', 'rawAudioData']) - self.sm = messaging.SubMaster(['audioFeedback']) - - def _send_lkas_button(self, pressed: bool): - msg = messaging.new_message('carState') - msg.carState.canValid = True - msg.carState.buttonEvents = [{'type': car.CarState.ButtonEvent.Type.lkas, 'pressed': pressed}] - self.pm.send('carState', msg) - - def _send_audio_data(self, count: int = 5): - for _ in range(count): - audio_msg = messaging.new_message('rawAudioData') - audio_msg.rawAudioData.data = bytes(1600) # 800 samples of int16 - audio_msg.rawAudioData.sampleRate = 16000 - self.pm.send('rawAudioData', audio_msg) - self.sm.update(timeout=100) - - @pytest.mark.parametrize("record_feedback", [False, True]) - def test_audio_feedback(self, record_feedback): - Params().put_bool("RecordAudioFeedback", record_feedback, block=True) - - managed_processes["feedbackd"].start() - assert self.pm.wait_for_readers_to_update('carState', timeout=5) - assert self.pm.wait_for_readers_to_update('rawAudioData', timeout=5) - - self._send_lkas_button(pressed=True) - self._send_audio_data() - self._send_lkas_button(pressed=False) - self._send_audio_data() - - if record_feedback: - assert self.sm.updated['audioFeedback'], "audioFeedback should be published when enabled" - else: - assert not self.sm.updated['audioFeedback'], "audioFeedback should not be published when disabled" - - self._send_lkas_button(pressed=True) - self._send_audio_data() - self._send_lkas_button(pressed=False) - self._send_audio_data() - - assert not self.sm.updated['audioFeedback'], "audioFeedback should not be published after second press" - - managed_processes["feedbackd"].stop() diff --git a/openpilot/selfdrive/ui/tests/test_raylib_ui.py b/openpilot/selfdrive/ui/tests/test_raylib_ui.py index 69ba946dcd..88f40ae6d9 100644 --- a/openpilot/selfdrive/ui/tests/test_raylib_ui.py +++ b/openpilot/selfdrive/ui/tests/test_raylib_ui.py @@ -1,8 +1,10 @@ import time +from openpilot.common.test import OpenpilotTestCase from openpilot.selfdrive.test.helpers import with_processes -@with_processes(["ui"]) -def test_raylib_ui(): - """Test initialization of the UI widgets is successful.""" - time.sleep(1) +class TestRaylibUi(OpenpilotTestCase): + @with_processes(["ui"]) + def test_raylib_ui(self): + """Test initialization of the UI widgets is successful.""" + time.sleep(1) diff --git a/openpilot/selfdrive/ui/tests/test_soundd.py b/openpilot/selfdrive/ui/tests/test_soundd.py index 95588a9fba..3416a22c98 100644 --- a/openpilot/selfdrive/ui/tests/test_soundd.py +++ b/openpilot/selfdrive/ui/tests/test_soundd.py @@ -1,35 +1,35 @@ +import threading +import time + +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import log, messaging from openpilot.cereal.messaging import SubMaster, PubMaster from openpilot.selfdrive.ui.soundd import SELFDRIVE_STATE_TIMEOUT, check_selfdrive_timeout_alert -import time - AudibleAlert = log.SelfdriveState.AudibleAlert -class TestSoundd: - def test_check_selfdrive_timeout_alert(self): +class TestSoundd(OpenpilotTestCase): + def test_check_selfdrive_timeout_alert(self, mocker): sm = SubMaster(['selfdriveState', 'selfdriveStateSP']) pm = PubMaster(['selfdriveState', 'selfdriveStateSP']) - for _ in range(100): - cs = messaging.new_message('selfdriveState') - cs.selfdriveState.enabled = True + cs = messaging.new_message('selfdriveState') + cs.selfdriveState.enabled = True + threading.Timer(0.01, pm.send, args=("selfdriveState", cs)).start() + sm.update(100) + assert sm.updated['selfdriveState'] - pm.send("selfdriveState", cs) - - time.sleep(0.01) - - sm.update(0) - - assert not check_selfdrive_timeout_alert(sm) - - for _ in range(SELFDRIVE_STATE_TIMEOUT * 110): - sm.update(0) - time.sleep(0.01) + sm.recv_time['selfdriveState'] = 0 + clock = mocker.patch("openpilot.selfdrive.ui.soundd.time.monotonic", return_value=SELFDRIVE_STATE_TIMEOUT) + assert not check_selfdrive_timeout_alert(sm) + clock.return_value = SELFDRIVE_STATE_TIMEOUT + 0.1 assert check_selfdrive_timeout_alert(sm) + clock.return_value = SELFDRIVE_STATE_TIMEOUT + 10 + assert not check_selfdrive_timeout_alert(sm) + def test_check_selfdrive_timeout_alert_mads_lateral_only(self): sm = SubMaster(['selfdriveState', 'selfdriveStateSP']) pm = PubMaster(['selfdriveState', 'selfdriveStateSP']) @@ -57,4 +57,3 @@ class TestSoundd: assert check_selfdrive_timeout_alert(sm) # TODO: add test with micd for checking that soundd actually outputs sounds - diff --git a/openpilot/selfdrive/ui/tests/test_translations.py b/openpilot/selfdrive/ui/tests/test_translations.py index fba595acad..0d636b61c8 100644 --- a/openpilot/selfdrive/ui/tests/test_translations.py +++ b/openpilot/selfdrive/ui/tests/test_translations.py @@ -3,8 +3,9 @@ import re import string from pathlib import Path -import pytest +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.selfdrive.ui.translations.potools import parse_po from openpilot.system.ui.lib.multilang import LANGUAGES_FILE, TRANSLATIONS_DIR @@ -46,61 +47,59 @@ def load_po_text(po_path: Path) -> str: return po_path.read_text(encoding='utf-8') -@pytest.mark.parametrize("language_code", sorted(TRANSLATION_LANGUAGES.values())) -def test_translation_file_exists(language_code: str): - po_path = PO_DIR / f"app_{language_code}.po" - assert po_path.exists(), f"missing translation file: {po_path}" +class TestTranslations(OpenpilotTestCase): + @parameterized.expand(sorted(TRANSLATION_LANGUAGES.values())) + def test_translation_file_exists(self, language_code: str): + po_path = PO_DIR / f"app_{language_code}.po" + assert po_path.exists(), f"missing translation file: {po_path}" + @parameterized.expand(sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) + def test_translation_placeholders_are_preserved(self, po_path: Path): + _, entries = parse_po(po_path) + language = po_path.stem.removeprefix("app_") -@pytest.mark.parametrize("po_path", sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) -def test_translation_placeholders_are_preserved(po_path: Path): - _, entries = parse_po(po_path) - language = po_path.stem.removeprefix("app_") + for entry in entries: + source_placeholders = extract_placeholders(entry.msgid) - for entry in entries: - source_placeholders = extract_placeholders(entry.msgid) + if entry.is_plural: + plural_placeholders = extract_placeholders(entry.msgid_plural) + message = ( + f"{language}: source plural placeholders do not match singular for " + + f"{entry.msgid!r}: {source_placeholders} vs {plural_placeholders}" + ) + assert plural_placeholders == source_placeholders, message - if entry.is_plural: - plural_placeholders = extract_placeholders(entry.msgid_plural) - message = ( - f"{language}: source plural placeholders do not match singular for " - + f"{entry.msgid!r}: {source_placeholders} vs {plural_placeholders}" - ) - assert plural_placeholders == source_placeholders, message + for idx, msgstr in sorted(entry.msgstr_plural.items()): + if not msgstr: + continue - for idx, msgstr in sorted(entry.msgstr_plural.items()): - if not msgstr: + translated_placeholders = extract_placeholders(msgstr) + message = ( + f"{language}: plural form {idx} changes placeholders for {entry.msgid!r}: " + + f"expected {source_placeholders}, got {translated_placeholders}" + ) + assert translated_placeholders == source_placeholders, message + else: + if not entry.msgstr: continue - translated_placeholders = extract_placeholders(msgstr) + translated_placeholders = extract_placeholders(entry.msgstr) message = ( - f"{language}: plural form {idx} changes placeholders for {entry.msgid!r}: " + f"{language}: translation changes placeholders for {entry.msgid!r}: " + f"expected {source_placeholders}, got {translated_placeholders}" ) assert translated_placeholders == source_placeholders, message - else: - if not entry.msgstr: - continue - translated_placeholders = extract_placeholders(entry.msgstr) - message = ( - f"{language}: translation changes placeholders for {entry.msgid!r}: " - + f"expected {source_placeholders}, got {translated_placeholders}" + @parameterized.expand(sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) + def test_translation_refs_do_not_include_line_numbers(self, po_path: Path): + for line in load_po_text(po_path).splitlines(): + assert not LINE_NUMBER_REF_RE.match(line), ( + f"{po_path.name}: line-number source reference found: {line}" ) - assert translated_placeholders == source_placeholders, message - -@pytest.mark.parametrize("po_path", sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) -def test_translation_refs_do_not_include_line_numbers(po_path: Path): - for line in load_po_text(po_path).splitlines(): - assert not LINE_NUMBER_REF_RE.match(line), ( - f"{po_path.name}: line-number source reference found: {line}" + @parameterized.expand(sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) + def test_translation_entities_are_valid(self, po_path: Path): + matches = BAD_ENTITY_RE.findall(load_po_text(po_path)) + assert not matches, ( + f"{po_path.name}: found '@...;' entity typo(s): {', '.join(sorted(set(matches)))}" ) - - -@pytest.mark.parametrize("po_path", sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) -def test_translation_entities_are_valid(po_path: Path): - matches = BAD_ENTITY_RE.findall(load_po_text(po_path)) - assert not matches, ( - f"{po_path.name}: found '@...;' entity typo(s): {', '.join(sorted(set(matches)))}" - ) diff --git a/openpilot/selfdrive/ui/translations/potools.py b/openpilot/selfdrive/ui/translations/potools.py index ac4dafb988..15da4c586c 100644 --- a/openpilot/selfdrive/ui/translations/potools.py +++ b/openpilot/selfdrive/ui/translations/potools.py @@ -67,22 +67,22 @@ def parse_po(path: str | Path) -> tuple[POEntry | None, list[POEntry]]: cur_field: str | None = None plural_idx = 0 - def finish(): - nonlocal cur, header - if cur is None: + def finish(entry: POEntry | None): + nonlocal header + if entry is None: return - if cur.msgid == "" and cur.msgstr: - header = cur - elif cur.msgid != "" or cur.is_plural: - entries.append(cur) - cur = None + if entry.msgid == "" and entry.msgstr: + header = entry + elif entry.msgid != "" or entry.is_plural: + entries.append(entry) for raw in lines: line = raw.rstrip('\n') stripped = line.strip() if not stripped: - finish() + finish(cur) + cur = None cur_field = None continue @@ -123,6 +123,8 @@ def parse_po(path: str | Path) -> tuple[POEntry | None, list[POEntry]]: continue if stripped.startswith('msgstr '): + if cur is None: + cur = POEntry() cur.msgstr = _parse_quoted(stripped[len('msgstr '):]) cur_field = 'msgstr' continue @@ -138,7 +140,7 @@ def parse_po(path: str | Path) -> tuple[POEntry | None, list[POEntry]]: elif cur_field == 'msgstr_plural': cur.msgstr_plural[plural_idx] += val - finish() + finish(cur) return header, entries diff --git a/openpilot/selfdrive/ui/translations/update_translations.py b/openpilot/selfdrive/ui/translations/update_translations.py index 6ff3667d8a..9b0d63ee12 100755 --- a/openpilot/selfdrive/ui/translations/update_translations.py +++ b/openpilot/selfdrive/ui/translations/update_translations.py @@ -12,9 +12,9 @@ POT_FILE = os.path.join(str(TRANSLATIONS_DIR), "app.pot") def update_translations(): files = [] for root, _, filenames in chain(os.walk(SYSTEM_UI_DIR), - os.walk(os.path.join(UI_DIR, "widgets")), - os.walk(os.path.join(UI_DIR, "layouts")), - os.walk(os.path.join(UI_DIR, "onroad"))): + os.walk(os.path.join(str(UI_DIR), "widgets")), + os.walk(os.path.join(str(UI_DIR), "layouts")), + os.walk(os.path.join(str(UI_DIR), "onroad"))): for filename in filenames: if filename.endswith(".py"): files.append(os.path.relpath(os.path.join(root, filename), BASEDIR)) @@ -25,7 +25,7 @@ def update_translations(): # Generate/update translation files for each language for name in multilang.languages.values(): - po_file = os.path.join(TRANSLATIONS_DIR, f"app_{name}.po") + po_file = os.path.join(str(TRANSLATIONS_DIR), f"app_{name}.po") if os.path.exists(po_file): merge_po(po_file, POT_FILE) else: diff --git a/openpilot/selfdrive/ui/ui.py b/openpilot/selfdrive/ui/ui.py index ff2bc7d3ce..30bba681c3 100755 --- a/openpilot/selfdrive/ui/ui.py +++ b/openpilot/selfdrive/ui/ui.py @@ -3,7 +3,7 @@ import os import time from openpilot.cereal import messaging -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.common.realtime import Priority, config_realtime_process, set_core_affinity from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.main import MainLayout @@ -31,7 +31,7 @@ def main(): if should_render: # reaffine after power save offlines our core - if TICI and os.sched_getaffinity(0) != cores: + if COMMA_HARDWARE and os.sched_getaffinity(0) != cores: try: set_core_affinity(list(cores)) except OSError: diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index e46a0595bb..4d93e1aacb 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -12,6 +12,7 @@ from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.ui.lib.prime_state import PrimeState from openpilot.system.ui.lib.application import gui_app from openpilot.common.hardware import HARDWARE, PC +from openpilot.selfdrive.modeld.helpers import chestnut_compiled from openpilot.selfdrive.ui.sunnypilot.ui_state import UIStateSP, DeviceSP @@ -27,6 +28,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 @@ -44,7 +54,7 @@ class UIState(UIStateSP): "modelV2", "controlsState", "onroadEvents", - "liveCalibration", + "extrinsicsCalibration", "radarState", "deviceState", "pandaStates", @@ -52,7 +62,7 @@ class UIState(UIStateSP): "driverMonitoringState", "carState", "driverStateV2", - "roadCameraState", + "narrowRoadCameraState", "wideRoadCameraState", "managerState", "selfdriveState", @@ -60,7 +70,7 @@ class UIState(UIStateSP): "gpsLocationExternal", "carOutput", "carControl", - "liveParameters", + "vehicleParameters", "testJoystick", "rawAudioData", ] + self.sm_services_ext @@ -80,15 +90,19 @@ class UIState(UIStateSP): self.is_release = False # self.params.get_bool("IsReleaseBranch") self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM") self.experimental_mode: bool = self.params.get_bool("ExperimentalMode") - self.usbgpu: bool = self.params.get_bool("UsbGpuPresent") - self.usbgpu_compiled: bool = self.params.get_bool("UsbGpuCompiled") + self.experimental_mode_confirmed: bool = self.params.get_bool("ExperimentalModeConfirmed") + self.chestnut_present: bool = False + self.chestnut_compiled: bool = chestnut_compiled() + self.chestnut_active: bool | None = None + self.chestnut_loading: bool = False + self.chestnut_state = ChestnutState.DISCONNECTED self.started: bool = False self.ignition: bool = False self.recording_audio: bool = False self.panda_type: log.PandaState.PandaType = log.PandaState.PandaType.unknown self.personality: log.LongitudinalPersonality = log.LongitudinalPersonality.standard self.has_longitudinal_control: bool = False - self.is_body: bool | None = None + self.is_body: bool | None = False self.CP: car.CarParams | None = None self.light_sensor: float = -1.0 @@ -127,6 +141,7 @@ class UIState(UIStateSP): self.sm.update(0) self._update_state() self._update_status() + self._update_chestnut_state() device.update() UIStateSP.update(self) @@ -190,12 +205,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 @@ -211,8 +249,11 @@ class UIState(UIStateSP): self.is_metric = self.params.get_bool("IsMetric") self.always_on_dm = self.params.get_bool("AlwaysOnDM") self.experimental_mode = self.params.get_bool("ExperimentalMode") - self.usbgpu = self.params.get_bool("UsbGpuPresent") - self.usbgpu_compiled = self.params.get_bool("UsbGpuCompiled") + self.experimental_mode_confirmed = self.params.get_bool("ExperimentalModeConfirmed") + 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") UIStateSP.update_params(self) @@ -314,9 +355,9 @@ class Device(DeviceSP): brightness = 0 if brightness != self._last_brightness: - self._brightness_target = brightness + self._brightness_target = int(brightness) self._brightness_event.set() - self._last_brightness = brightness + self._last_brightness = int(brightness) def _update_wakefulness(self): # Handle interactive timeout @@ -337,9 +378,15 @@ class Device(DeviceSP): self._set_awake(ui_state.ignition or not interaction_timeout or PC) - def _set_awake(self, on: bool): + def _set_awake(self, on: bool, _ui_state=None): + # screensaver holds _awake True, so waking is not a state change + if on and self._blocked_by_screensaver: + self.dismiss_screensaver(_ui_state or ui_state) + if on != self._awake: - DeviceSP._set_awake(on, ui_state) + super()._set_awake(on, _ui_state or ui_state) + if self._blocked_by_screensaver: + return self._awake = on cloudlog.debug(f"setting display power {int(on)}") HARDWARE.set_display_power(on) diff --git a/openpilot/selfdrive/ui/watch3.py b/openpilot/selfdrive/ui/watch3.py index bb64cdc4d5..c601ccabe9 100755 --- a/openpilot/selfdrive/ui/watch3.py +++ b/openpilot/selfdrive/ui/watch3.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 import pyray as rl -from msgq.visionipc import VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.onroad.cameraview import CameraView if __name__ == "__main__": gui_app.init_window("watch3") - road = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD) - driver = CameraView("camerad", VisionStreamType.VISION_STREAM_DRIVER) + road = CameraView("camerad", VisionStreamType.VISION_STREAM_NARROW_ROAD) + driver = CameraView("camerad", VisionStreamType.VISION_STREAM_CABIN) wide = CameraView("camerad", VisionStreamType.VISION_STREAM_WIDE_ROAD) for _ in gui_app.render(): road.render(rl.Rectangle(gui_app.width // 4, 0, gui_app.width // 2, gui_app.height // 2)) diff --git a/openpilot/selfdrive/ui/widgets/pairing_dialog.py b/openpilot/selfdrive/ui/widgets/pairing_dialog.py index 1ff550e4b6..54ccfe19be 100644 --- a/openpilot/selfdrive/ui/widgets/pairing_dialog.py +++ b/openpilot/selfdrive/ui/widgets/pairing_dialog.py @@ -1,9 +1,8 @@ import pyray as rl -import qrcode -import numpy as np import time from openpilot.common.api import Api +from openpilot.common.qrcode import make_texture from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params from openpilot.system.ui.widgets import Widget @@ -39,24 +38,9 @@ class PairingDialog(Widget): def _generate_qr_code(self) -> None: try: - qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4) - qr.add_data(self._get_pairing_url()) - qr.make(fit=True) - - pil_img = qr.make_image(fill_color="black", back_color="white").convert('RGBA') - img_array = np.array(pil_img, dtype=np.uint8) - if self.qr_texture and self.qr_texture.id != 0: rl.unload_texture(self.qr_texture) - - rl_image = rl.Image() - rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data) - rl_image.width = pil_img.width - rl_image.height = pil_img.height - rl_image.mipmaps = 1 - rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 - - self.qr_texture = rl.load_texture_from_image(rl_image) + self.qr_texture = make_texture(self._get_pairing_url()) except Exception: cloudlog.exception("QR code generation failed") self.qr_texture = None diff --git a/openpilot/selfdrive/ui/widgets/setup.py b/openpilot/selfdrive/ui/widgets/setup.py index c9452fc535..b00d1a42d4 100644 --- a/openpilot/selfdrive/ui/widgets/setup.py +++ b/openpilot/selfdrive/ui/widgets/setup.py @@ -18,7 +18,8 @@ class SetupWidget(Widget): self._pair_device_btn = Button(lambda: tr("Pair device"), self._show_pairing, button_style=ButtonStyle.PRIMARY) self._open_settings_btn = Button(lambda: tr("Open"), lambda: self._open_settings_callback() if self._open_settings_callback else None, button_style=ButtonStyle.PRIMARY) - self._firehose_label = Label(lambda: tr("🔥 Firehose Mode 🔥"), font_weight=FontWeight.MEDIUM, font_size=64) + self._firehose_label = Label(lambda: tr("Firehose Mode"), font_weight=FontWeight.MEDIUM, font_size=64) + self._fire_icon = gui_app.texture("icons/fire.png", 64, 64) def set_open_settings_callback(self, callback): self._open_settings_callback = callback @@ -67,6 +68,8 @@ class SetupWidget(Widget): # Title with fire emojis self._firehose_label.render(rl.Rectangle(rect.x, y, rect.width, 64)) + rl.draw_texture_ex(self._fire_icon, rl.Vector2(x, y), 0.0, 1.0, rl.WHITE) + rl.draw_texture_ex(self._fire_icon, rl.Vector2(x + w - 64, y), 0.0, 1.0, rl.WHITE) y += 64 + spacing # Description diff --git a/openpilot/sunnypilot/SConscript b/openpilot/sunnypilot/SConscript index 09ad39ab43..587deea5ff 100644 --- a/openpilot/sunnypilot/SConscript +++ b/openpilot/sunnypilot/SConscript @@ -1,3 +1,2 @@ SConscript(['common/transformations/SConscript']) -SConscript(['modeld_v2/SConscript']) SConscript(['selfdrive/locationd/SConscript']) diff --git a/openpilot/sunnypilot/common/version.h b/openpilot/sunnypilot/common/version.h index fccd807e64..26151e206f 100644 --- a/openpilot/sunnypilot/common/version.h +++ b/openpilot/sunnypilot/common/version.h @@ -1 +1 @@ -#define SUNNYPILOT_VERSION "2026.002.000" +#define SUNNYPILOT_VERSION "2026.003.000" diff --git a/openpilot/sunnypilot/livedelay/helpers.py b/openpilot/sunnypilot/livedelay/helpers.py index 0f7437ceea..7b36b57af1 100644 --- a/openpilot/sunnypilot/livedelay/helpers.py +++ b/openpilot/sunnypilot/livedelay/helpers.py @@ -8,7 +8,10 @@ from openpilot.common.params import Params def get_lat_delay(params: Params, stock_lat_delay: float) -> float: - if params.get_bool("LagdToggle"): - return float(params.get("LagdValueCache", return_default=True)) +# live learning on: use what lagd publishes. +# off: use the fixed steerActuatorDelay + software delay sum that LagdToggle caches. - return stock_lat_delay + if params.get_bool("LagdToggle"): + return stock_lat_delay + + return float(params.get("LagdValueCache", return_default=True)) diff --git a/openpilot/sunnypilot/livedelay/lagd_toggle.py b/openpilot/sunnypilot/livedelay/lagd_toggle.py index 3926cbebf6..a617aaa4c6 100644 --- a/openpilot/sunnypilot/livedelay/lagd_toggle.py +++ b/openpilot/sunnypilot/livedelay/lagd_toggle.py @@ -23,7 +23,7 @@ class LagdToggle: self.lagd_toggle = self.params.get_bool("LagdToggle") self.software_delay = self.params.get("LagdToggleDelay", return_default=True) - def update(self, lag_msg: log.LiveDelayData) -> None: + def update(self, lag_msg: log.LateralDelay) -> None: self.read_params() if not self.lagd_toggle: @@ -33,6 +33,6 @@ class LagdToggle: self.params.put("LagdValueCache", self.lag) return - lateral_delay = lag_msg.liveDelay.lateralDelay + lateral_delay = lag_msg.lateralDelay.lateralDelay self.lag = lateral_delay self.params.put("LagdValueCache", self.lag) diff --git a/openpilot/sunnypilot/mads/helpers.py b/openpilot/sunnypilot/mads/helpers.py index b9efd620f3..655d7bc9a2 100644 --- a/openpilot/sunnypilot/mads/helpers.py +++ b/openpilot/sunnypilot/mads/helpers.py @@ -9,7 +9,7 @@ from openpilot.common.params import Params from opendbc.car import structs from opendbc.safety import ALTERNATIVE_EXPERIENCE from opendbc.sunnypilot.car.hyundai.values import HyundaiFlagsSP, HyundaiSafetyFlagsSP -from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP +from opendbc.sunnypilot.car.tesla.values import MadsScreenButtonType, TeslaFlagsSP MADS_NO_ACC_MAIN_BUTTON = ("rivian", "tesla") @@ -21,17 +21,20 @@ class MadsSteeringModeOnBrake: DISENGAGE = 2 -def get_mads_limited_brands(CP: structs.CarParams, CP_SP: structs.CarParamsSP) -> bool: +def get_mads_limited_brands(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params) -> bool: if CP.brand == 'rivian': return True if CP.brand == 'tesla': - return not CP_SP.flags & TeslaFlagsSP.HAS_VEHICLE_BUS + if not CP_SP.flags & TeslaFlagsSP.HAS_VEHICLE_BUS: + return True + screen_button = int(params.get("TeslaMadsScreenButton", return_default=True)) + return screen_button == MadsScreenButtonType.OFF return False def read_steering_mode_param(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params): - if get_mads_limited_brands(CP, CP_SP): + if get_mads_limited_brands(CP, CP_SP, params): return MadsSteeringModeOnBrake.DISENGAGE return params.get("MadsSteeringMode", return_default=True) @@ -63,7 +66,7 @@ def set_car_specific_params(CP: structs.CarParams, CP_SP: structs.CarParamsSP, p # MADS is currently partially supported for these platforms due to lack of consistent states to engage controls # Only MadsSteeringModeOnBrake.DISENGAGE is supported for these platforms # TODO-SP: To enable MADS full support for Rivian and most Tesla, identify consistent signals for MADS toggling - mads_partial_support = get_mads_limited_brands(CP, CP_SP) + mads_partial_support = get_mads_limited_brands(CP, CP_SP, params) if mads_partial_support: params.put("MadsSteeringMode", 2, block=True) params.put_bool("MadsUnifiedEngagementMode", True, block=True) diff --git a/openpilot/sunnypilot/mads/tests/test_mads_state_machine.py b/openpilot/sunnypilot/mads/tests/test_mads_state_machine.py index 1782d04975..0147f2f4fb 100644 --- a/openpilot/sunnypilot/mads/tests/test_mads_state_machine.py +++ b/openpilot/sunnypilot/mads/tests/test_mads_state_machine.py @@ -5,14 +5,13 @@ 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 pytest -from pytest_mock import MockerFixture from openpilot.cereal import custom from openpilot.common.realtime import DT_CTRL from openpilot.sunnypilot.mads.state import StateMachine, SOFT_DISABLE_TIME from openpilot.selfdrive.selfdrived.events import ET, NormalPermanentAlert, Events from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP, EVENTS_SP +from openpilot.common.test import OpenpilotTestCase State = custom.ModularAssistiveDrivingSystem.ModularAssistiveDrivingSystemState EventNameSP = custom.OnroadEventSP.EventName @@ -29,21 +28,21 @@ def make_event(event_types): event = {} for ev in event_types: event[ev] = NormalPermanentAlert("alert") - EVENTS_SP[0] = event + EVENTS_SP[0] = event # type: ignore[assignment] # ty: ignore[invalid-assignment] return 0 class MockMADS: - def __init__(self, mocker: MockerFixture): + def __init__(self, mocker): self.selfdrive = mocker.MagicMock() self.selfdrive.state_machine = mocker.MagicMock() self.selfdrive.events = Events() self.selfdrive.events_sp = EventsSP() -class TestMADSStateMachine: - @pytest.fixture(autouse=True) - def setup_method(self, mocker: MockerFixture): +class TestMADSStateMachine(OpenpilotTestCase): + def setup_method(self): + mocker = self._fixture("mocker") self.mads = MockMADS(mocker) self.state_machine = StateMachine(self.mads) self.events = self.mads.selfdrive.events diff --git a/openpilot/sunnypilot/mads/tests/test_mads_steering_mode.py b/openpilot/sunnypilot/mads/tests/test_mads_steering_mode.py index 3b7e27a86f..2058a8b10c 100644 --- a/openpilot/sunnypilot/mads/tests/test_mads_steering_mode.py +++ b/openpilot/sunnypilot/mads/tests/test_mads_steering_mode.py @@ -5,7 +5,7 @@ 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 pytest +from openpilot.common.parameterized import parameterized from openpilot.cereal import log, custom from opendbc.car import structs @@ -13,7 +13,8 @@ from openpilot.selfdrive.selfdrived.events import Events from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP from openpilot.sunnypilot.mads.helpers import MadsSteeringModeOnBrake, read_steering_mode_param from openpilot.sunnypilot.mads.mads import ModularAssistiveDrivingSystem -from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP +from opendbc.sunnypilot.car.tesla.values import MadsScreenButtonType, TeslaFlagsSP +from openpilot.common.test import OpenpilotTestCase State = custom.ModularAssistiveDrivingSystem.ModularAssistiveDrivingSystemState EventName = log.OnroadEvent.EventName @@ -38,6 +39,12 @@ def make_panda_state(mocker, controls_allowed_lateral=True): return ps +def make_params_mock(mocker, values): + params = mocker.MagicMock() + params.get = mocker.MagicMock(side_effect=lambda k, **kwargs: values[k]) + return params + + def make_mads(mocker, steering_mode): sd = mocker.MagicMock() sd.CP = structs.CarParams() @@ -74,8 +81,8 @@ def run_frames(mads, sd, cs, n=1): # should_silent_lkas_enable across all modes -class TestShouldSilentLkasEnable: - @pytest.mark.parametrize("brake,regen", [(True, False), (False, True)]) +class TestShouldSilentLkasEnable(OpenpilotTestCase): + @parameterized.expand([(True, False), (False, True)], names=["brake", "regen"]) def test_pause_blocks_reenable_on_braking_at_standstill(self, mocker, brake, regen): mads, _ = make_mads(mocker, MadsSteeringModeOnBrake.PAUSE) cs = make_car_state(brake_pressed=brake, regen_braking=regen, standstill=True) @@ -99,7 +106,7 @@ class TestShouldSilentLkasEnable: # pause -class TestPauseMode: +class TestPauseMode(OpenpilotTestCase): def test_stays_paused_at_standstill_brake_held(self, mocker): mads, sd = make_mads(mocker, MadsSteeringModeOnBrake.PAUSE) mads.state_machine.state = State.enabled @@ -144,7 +151,7 @@ class TestPauseMode: # disengage -class TestDisengageMode: +class TestDisengageMode(OpenpilotTestCase): def test_brake_while_enabled_disables(self, mocker): mads, sd = make_mads(mocker, MadsSteeringModeOnBrake.DISENGAGE) mads.state_machine.state = State.enabled @@ -168,7 +175,7 @@ class TestDisengageMode: # remain active -class TestRemainActiveMode: +class TestRemainActiveMode(OpenpilotTestCase): def test_brake_does_not_pause_or_disable(self, mocker): mads, sd = make_mads(mocker, MadsSteeringModeOnBrake.REMAIN_ACTIVE) mads.state_machine.state = State.enabled @@ -182,7 +189,7 @@ class TestRemainActiveMode: # lateral mismatch counter -class TestLateralMismatchCounter: +class TestLateralMismatchCounter(OpenpilotTestCase): def test_no_accumulation_while_paused(self, mocker): mads, sd = make_mads(mocker, MadsSteeringModeOnBrake.PAUSE) mads.state_machine.state = State.paused @@ -206,7 +213,7 @@ class TestLateralMismatchCounter: # brand restrictions -class TestBrandSteeringModeRestrictions: +class TestBrandSteeringModeRestrictions(OpenpilotTestCase): def test_rivian_forced_to_disengage(self, mocker): CP = structs.CarParams() CP.brand = "rivian" @@ -223,16 +230,28 @@ class TestBrandSteeringModeRestrictions: params = mocker.MagicMock() assert read_steering_mode_param(CP, CP_SP, params) == MadsSteeringModeOnBrake.DISENGAGE - def test_tesla_with_vehicle_bus_uses_param(self, mocker): + @parameterized.expand([MadsScreenButtonType.THREE_FINGER, + MadsScreenButtonType.FOUR_FINGER, + MadsScreenButtonType.FIVE_FINGER], names=["screen_button"]) + def test_tesla_with_vehicle_bus_uses_param(self, mocker, screen_button): CP = structs.CarParams() CP.brand = "tesla" CP_SP = structs.CarParamsSP() CP_SP.flags = TeslaFlagsSP.HAS_VEHICLE_BUS - params = mocker.MagicMock() - params.get = mocker.MagicMock(return_value=MadsSteeringModeOnBrake.REMAIN_ACTIVE) + params = make_params_mock(mocker, {"TeslaMadsScreenButton": screen_button, + "MadsSteeringMode": MadsSteeringModeOnBrake.REMAIN_ACTIVE}) assert read_steering_mode_param(CP, CP_SP, params) == MadsSteeringModeOnBrake.REMAIN_ACTIVE - @pytest.mark.parametrize("brand", ["hyundai", "toyota", "honda", "gm"]) + def test_tesla_with_vehicle_bus_screen_button_off_forced_to_disengage(self, mocker): + CP = structs.CarParams() + CP.brand = "tesla" + CP_SP = structs.CarParamsSP() + CP_SP.flags = TeslaFlagsSP.HAS_VEHICLE_BUS + params = make_params_mock(mocker, {"TeslaMadsScreenButton": MadsScreenButtonType.OFF, + "MadsSteeringMode": MadsSteeringModeOnBrake.REMAIN_ACTIVE}) + assert read_steering_mode_param(CP, CP_SP, params) == MadsSteeringModeOnBrake.DISENGAGE + + @parameterized.expand(["hyundai", "toyota", "honda", "gm"], names=["brand"]) def test_other_brands_use_param(self, mocker, brand): CP = structs.CarParams() CP.brand = brand diff --git a/openpilot/sunnypilot/mapd/live_map_data/debug.py b/openpilot/sunnypilot/mapd/live_map_data/debug.py index 00783d58df..38a696d403 100644 --- a/openpilot/sunnypilot/mapd/live_map_data/debug.py +++ b/openpilot/sunnypilot/mapd/live_map_data/debug.py @@ -37,7 +37,7 @@ def live_map_data_sp_thread(): def live_map_data_sp_thread_debug(gps_location_service): - _sub_master = messaging.SubMaster(['carState', 'livePose', 'liveMapDataSP', 'longitudinalPlanSP', 'carStateSP', gps_location_service]) + _sub_master = messaging.SubMaster(['carState', 'deviceMotion', 'liveMapDataSP', 'longitudinalPlanSP', 'carStateSP', gps_location_service]) _sub_master.update() v_ego = _sub_master['carState'].vEgo diff --git a/openpilot/sunnypilot/mapd/mapd_installer.py b/openpilot/sunnypilot/mapd/mapd_installer.py index 9f2e72720a..ec3cf94e7e 100755 --- a/openpilot/sunnypilot/mapd/mapd_installer.py +++ b/openpilot/sunnypilot/mapd/mapd_installer.py @@ -26,7 +26,7 @@ VERSION = "v1.12.0" URL = f"https://github.com/pfeiferj/openpilot-mapd/releases/download/{VERSION}/mapd" -def update_installed_version(version: str, params: Params = None) -> None: +def update_installed_version(version: str, params: Params | None = None) -> None: if params is None: params = Params() diff --git a/openpilot/sunnypilot/mapd/mapd_manager.py b/openpilot/sunnypilot/mapd/mapd_manager.py index 084af50408..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) @@ -128,9 +141,13 @@ def main_thread(): cloudlog.exception(f"mapd: failed to make {Paths.mapd_root()}") while True: - show_alert = get_files_for_cleanup() and params.get_bool("OsmLocal") + 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/mapd/tests/test_mapd_version.py b/openpilot/sunnypilot/mapd/tests/test_mapd_version.py index 5619d2ec29..acbbe51c37 100644 --- a/openpilot/sunnypilot/mapd/tests/test_mapd_version.py +++ b/openpilot/sunnypilot/mapd/tests/test_mapd_version.py @@ -7,9 +7,10 @@ See the LICENSE.md file in the root directory for more details. from openpilot.sunnypilot import get_file_hash from openpilot.sunnypilot.mapd import MAPD_PATH from openpilot.sunnypilot.mapd.update_version import MAPD_HASH_PATH +from openpilot.common.test import OpenpilotTestCase -class TestMapdVersion: +class TestMapdVersion(OpenpilotTestCase): def test_compare_versions(self): mapd_hash = get_file_hash(MAPD_PATH) diff --git a/openpilot/sunnypilot/modeld_v2/SConscript b/openpilot/sunnypilot/modeld_v2/SConscript deleted file mode 100644 index 81affc625a..0000000000 --- a/openpilot/sunnypilot/modeld_v2/SConscript +++ /dev/null @@ -1,99 +0,0 @@ -import os -import glob - -from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye -from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE -from openpilot.common.hardware import HARDWARE, PC - -Import('env', 'arch', 'release') -lenv = env.Clone() -tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=env.Dir("#").abspath) if 'pycache' not in x] - - -def get_camera_configs(): - DEVICE_RESOLUTIONS = { - "tici": (_ar_ox_fisheye.width, _ar_ox_fisheye.height), - "tizi": (_ar_ox_fisheye.width, _ar_ox_fisheye.height), - "mici": (_os_fisheye.width, _os_fisheye.height), - } - if release or PC or 'CI' in os.environ: - return set(DEVICE_RESOLUTIONS.values()) - return [DEVICE_RESOLUTIONS[HARDWARE.get_device_type()]] - -CAMERA_CONFIGS = get_camera_configs() - -tg_flags = { - 'larch64': 'DEV=QCOM FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0', - 'Darwin': f'DEV=CPU HOME={os.path.expanduser("~")}', -}.get(arch, 'DEV=CPU:LLVM') - -image_flag = { - 'larch64': 'IMAGE=2', -}.get(arch, 'IMAGE=0') - -model_w, model_h = MEDMODEL_INPUT_SIZE -from openpilot.selfdrive.modeld.constants import ModelConstants -frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ -camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in CAMERA_CONFIGS) - -pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + ':' + env.Dir("#").abspath + '"' -compile_modeld_script = File("compile_modeld.py").abspath -upstream_compile_script = File(Dir("#openpilot/selfdrive/modeld").File("compile_modeld.py").abspath) -script_deps = [File("compile_modeld.py"), upstream_compile_script] - -def compile_combined(model_type, onnx_args, output_name): - output_pkl = File(f"models/{output_name}").abspath - cmd = (f'{pythonpath_string} {tg_flags} {image_flag} python3 {compile_modeld_script} ' - f'--model-type {model_type} ' - f'--model-size {model_w}x{model_h} ' - f'--camera-resolutions {camera_res_args} ' - f'{onnx_args} ' - f'--frame-skip {frame_skip} ' - f'--output {output_pkl}') - onnx_files = [f for f in onnx_args.split() if f.endswith('.onnx')] - return lenv.Command(output_pkl, tinygrad_files + script_deps + [File(f) for f in onnx_files if os.path.isfile(f)], cmd) - -# Vision + Policy (stock default model) -vision_onnx = File("models/driving_vision.onnx").abspath -policy_onnx = File("models/driving_policy.onnx").abspath -if os.path.isfile(vision_onnx) and os.path.isfile(policy_onnx): - compile_combined('vision_policy', - f'--vision-onnx {vision_onnx} --policy-onnx {policy_onnx}', - 'driving_combined_tinygrad.pkl') - -# Vision + Off-Policy -off_policy_onnx = File("models/driving_off_policy.onnx").abspath -if os.path.isfile(vision_onnx) and os.path.isfile(off_policy_onnx): - policy_arg = f'--policy-onnx {policy_onnx}' if os.path.isfile(policy_onnx) else '' - compile_combined('vision_multi_policy', - f'--vision-onnx {vision_onnx} {policy_arg} --off-policy-onnx {off_policy_onnx}', - 'driving_combined_multi_tinygrad.pkl') - -# Vision + On-Policy + Off-Policy -on_policy_onnx = File("models/driving_on_policy.onnx").abspath -if os.path.isfile(vision_onnx) and os.path.isfile(on_policy_onnx) and os.path.isfile(off_policy_onnx): - compile_combined('vision_multi_policy', - f'--vision-onnx {vision_onnx} --off-policy-onnx {off_policy_onnx} --on-policy-onnx {on_policy_onnx}', - 'driving_combined_tri_tinygrad.pkl') - -# Supercombo -supercombo_onnx = File("models/supercombo.onnx").abspath -if os.path.isfile(supercombo_onnx): - compile_combined('supercombo', - f'--supercombo-onnx {supercombo_onnx}', - 'driving_combined_supercombo_tinygrad.pkl') - -if PC: - inputs = tinygrad_files + [File(Dir("#openpilot/sunnypilot/modeld_v2").File("install_models_pc.py").abspath)] - outputs = [] - model_dir = Dir("models").abspath - cmd = f'python3 {Dir("#openpilot/sunnypilot/modeld_v2").abspath}/install_models_pc.py {model_dir}' - - for model_name in ['supercombo', 'driving_vision', 'driving_off_policy', 'driving_on_policy', 'driving_policy']: - if File(f"models/{model_name}.onnx").exists(): - inputs.append(File(f"models/{model_name}.onnx")) - inputs.append(File(f"models/{model_name}_tinygrad.pkl")) - outputs.append(File(f"models/{model_name}_metadata.pkl")) - if outputs: - lenv.Command(outputs, inputs, cmd) - diff --git a/openpilot/sunnypilot/modeld_v2/camera_offset_helper.py b/openpilot/sunnypilot/modeld_v2/camera_offset_helper.py index 7502c3eeb0..648ba01086 100644 --- a/openpilot/sunnypilot/modeld_v2/camera_offset_helper.py +++ b/openpilot/sunnypilot/modeld_v2/camera_offset_helper.py @@ -28,12 +28,12 @@ class CameraOffsetHelper: def update(self, model_transform_main, model_transform_extra, sm, main_wide_camera): self.actual_camera_offset = (0.9 * self.actual_camera_offset) + (0.1 * self.camera_offset) - dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] - height = sm["liveCalibration"].height[0] if sm['liveCalibration'].height else 1.22 + dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['narrowRoadCameraState'].sensor))] + height = sm["extrinsicsCalibration"].height[0] if sm['extrinsicsCalibration'].height else 1.22 - intrinsics_main = dc.ecam.intrinsics if main_wide_camera else dc.fcam.intrinsics + intrinsics_main = dc.wide_road.intrinsics if main_wide_camera else dc.narrow_road.intrinsics model_transform_main = self.apply_camera_offset(model_transform_main, intrinsics_main, height, self.actual_camera_offset) - intrinsics_extra = dc.ecam.intrinsics + intrinsics_extra = dc.wide_road.intrinsics model_transform_extra = self.apply_camera_offset(model_transform_extra, intrinsics_extra, height, self.actual_camera_offset) return model_transform_main, model_transform_extra diff --git a/openpilot/sunnypilot/modeld_v2/compile_modeld.py b/openpilot/sunnypilot/modeld_v2/compile_modeld.py index 9c8cdb2c0a..7a58352c36 100755 --- a/openpilot/sunnypilot/modeld_v2/compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/compile_modeld.py @@ -7,149 +7,248 @@ See the LICENSE.md file in the root directory for more details. """ import argparse +import math import os -import pickle +import tempfile import time from functools import partial -from collections import defaultdict - +from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob import numpy as np -from tinygrad.tensor import Tensor +os.environ['GMMU'] = '0' + +def _patch_tinygrad_fetch_fw(): + import hashlib + import pathlib + import zstandard + from tinygrad import helpers + _orig_fetch_fw = helpers.fetch_fw + def fetch_fw(path, name, sha256): + p = pathlib.Path(f"/lib/firmware/{path}/{name}.zst") + if p.is_file(): + blob = zstandard.ZstdDecompressor().stream_reader(p.read_bytes()).read() + if hashlib.sha256(blob).hexdigest() == sha256: + return blob + return _orig_fetch_fw(path, name, sha256) + helpers.fetch_fw = fetch_fw +_patch_tinygrad_fetch_fw() + +from openpilot.selfdrive.modeld.compile_modeld import NV12Frame, make_frame_prepare, sample_desire, sample_skip, shift_and_sample +from tinygrad import dtypes from tinygrad.device import Device from tinygrad.engine.jit import TinyJit - -from openpilot.selfdrive.modeld.compile_modeld import ( - NV12Frame, make_frame_prepare, - shift_and_sample, sample_skip, sample_desire, -) +from tinygrad.tensor import Tensor MODEL_TYPES = ('vision_policy', 'supercombo', 'vision_multi_policy') +WARP_INPUTS = ['tfm', 'big_tfm'] +POLICY_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'] +WARP_DEV = os.getenv('WARP_DEV') -def _detect_desire_key(policy_input_shapes): - for k in policy_input_shapes: - if k.startswith('desire'): - return k - return None +def _detect_desire_key(shapes: dict) -> str | None: + return next((key for key in shapes if key.startswith('desire')), None) -def _detect_vision_keys(vision_input_shapes): - img_keys = sorted([k for k in vision_input_shapes if 'img' in k]) - road_key = next((k for k in img_keys if 'big' not in k), None) - wide_key = next((k for k in img_keys if 'big' in k), None) - if road_key is None or wide_key is None: - raise ValueError(f"Cannot determine road/wide image keys from {list(vision_input_shapes.keys())}") - return road_key, wide_key +def _detect_vision_keys(shapes: dict) -> tuple[str | None, str | None]: + img_keys = sorted(key for key in shapes if 'img' in key) + return ( + next((key for key in img_keys if 'big' not in key), None), + next((key for key in img_keys if 'big' in key), None) + ) -def make_split_input_queues(vision_input_shapes, policy_input_shapes, frame_skip, device): - road_key, _ = _detect_vision_keys(vision_input_shapes) - img = vision_input_shapes[road_key] - n_frames = img[1] // 6 - img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img[2], img[3]) +def derive_frame_skip(vision_input_shapes: dict, policy_input_shapes: dict) -> int: + features_buffer = policy_input_shapes.get('features_buffer') + return 1 if not features_buffer or features_buffer[1] >= 99 else 4 - fb = policy_input_shapes['features_buffer'] - desire_key = _detect_desire_key(policy_input_shapes) - dp = policy_input_shapes[desire_key] - tc = policy_input_shapes.get('traffic_convention', (1, 2)) +def get_policy_npy_shapes(input_shapes: dict, is_supercombo: bool = False) -> tuple[dict, list[int]]: + desire_key = _detect_desire_key(input_shapes) + shapes = {} + if desire_key: + shapes['desire'] = (input_shapes[desire_key][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 + + +def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = Device.DEFAULT, + is_supercombo: bool = False) -> tuple[dict, dict]: + road_key, _ = _detect_vision_keys(input_shapes) + if not road_key: + raise ValueError("Vision road key missing from input shapes.") + + img_shape = input_shapes[road_key] + n_frames = img_shape[1] // 6 + img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img_shape[2], img_shape[3]) + + desire_key = _detect_desire_key(input_shapes) + if not desire_key: + raise ValueError("Desire key missing from input shapes.") + + desire_shape = input_shapes[desire_key] + features_buffer = input_shapes.get('features_buffer') + + npy_arrays = { + 'tfm': np.zeros((3, 3), dtype=np.float32), + 'big_tfm': np.zeros((3, 3), dtype=np.float32) + } + + shapes, sizes = get_policy_npy_shapes(input_shapes, is_supercombo=is_supercombo) + packed_npy_inputs = np.zeros(sum(sizes), dtype=np.float32) + + split_indices = np.cumsum(sizes[:-1]) if len(sizes) > 1 else [] + split_views = np.split(packed_npy_inputs, split_indices) if len(sizes) > 0 else [] + for (k, s), v in zip(shapes.items(), split_views, strict=True): + npy_arrays[k] = v.reshape(s) + + 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(), + 'desire_q': Tensor(np.zeros((frame_skip * desire_shape[1], desire_shape[0], desire_shape[2]), + dtype=np.float32), device=device).contiguous().realize(), + 'packed_npy_inputs': Tensor(packed_npy_inputs, device='NPY').realize(), + } + + 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], 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')}) + + return queues, npy_arrays + + +def make_split_input_queues(vision_input_shapes: dict, policy_input_shapes: dict, + frame_skip: int, device: str = Device.DEFAULT) -> tuple[dict, dict]: + return generate_queues_and_npy({**vision_input_shapes, **policy_input_shapes}, frame_skip, device, is_supercombo=False) + + +def make_supercombo_input_queues(input_shapes: dict, frame_skip: int, + device: str = Device.DEFAULT) -> tuple[dict, dict]: + return generate_queues_and_npy(input_shapes, frame_skip, device, is_supercombo=True) + + +def make_random_images(keys, shape, device): + return {k: Tensor.randint(shape, low=0, high=256, dtype=dtypes.uint8, device=device).realize() for k in keys} + + +def make_warp_queues(device=Device.DEFAULT): npy = { - 'desire': np.zeros(dp[2], dtype=np.float32), - 'traffic_convention': np.zeros(tc, dtype=np.float32), 'tfm': np.zeros((3, 3), dtype=np.float32), 'big_tfm': np.zeros((3, 3), dtype=np.float32), } - - handled = {'features_buffer', desire_key, 'traffic_convention'} - for key, shape in policy_input_shapes.items(): - if key in handled: - continue - npy[key] = np.zeros(shape, 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(), - 'feat_q': Tensor(np.zeros((frame_skip * (fb[1] - 1) + 1, fb[0], fb[2]), 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(), - **{k: Tensor(v, device='NPY').realize() for k, v in npy.items()}, - } - return input_queues, npy + queues = {k: Tensor(v, device='NPY').realize() for k, v in npy.items()} + return queues, npy -def make_run_split_policy(vision_runner, policy_runner, nv12: NV12Frame, model_w, model_h, - vision_features_slice, frame_skip, desire_key, extra_policy_keys, - vision_road_key, vision_wide_key, prepare_only=False): +def make_warp(nv12: NV12Frame, model_w: int, model_h: int): frame_prepare = make_frame_prepare(nv12, model_w, model_h) + WARP_DEV = os.getenv('WARP_DEV', Device.DEFAULT) + + 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) + + warped_frame = frame_prepare(frame, tfm).unsqueeze(0) + warped_big_frame = frame_prepare(big_frame, big_tfm).unsqueeze(0) + return Tensor.cat(warped_frame, warped_big_frame) + return warp + + +def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, frame_skip: int, input_shapes: dict): sample_skip_fn = partial(sample_skip, frame_skip=frame_skip) sample_desire_fn = partial(sample_desire, frame_skip=frame_skip) - def run_policy(img_q, big_img_q, feat_q, desire_q, desire, traffic_convention, tfm, big_tfm, frame, big_frame, **extra): - npy_tensors = [tfm.to(Device.DEFAULT), big_tfm.to(Device.DEFAULT), - desire.to(Device.DEFAULT), traffic_convention.to(Device.DEFAULT)] - extra_device = {k: extra[k].to(Device.DEFAULT) for k in extra_policy_keys} - Tensor.realize(*npy_tensors, *extra_device.values()) - tfm, big_tfm, desire, traffic_convention = npy_tensors + desire_key = _detect_desire_key(input_shapes) + road_key, wide_key = _detect_vision_keys(input_shapes) - img = shift_and_sample(img_q, frame_prepare(frame, tfm).unsqueeze(0), sample_skip_fn) - big_img = shift_and_sample(big_img_q, frame_prepare(big_frame, big_tfm).unsqueeze(0), sample_skip_fn) + if not desire_key or not road_key or not wide_key: + raise ValueError("Missing required vision or desire keys in input shapes.") - if prepare_only: - return img, big_img + is_supercombo = vision_runner is None + npy_shapes, npy_sizes = get_policy_npy_shapes(input_shapes, is_supercombo=is_supercombo) - vision_out = next(iter(vision_runner({vision_road_key: img, vision_wide_key: big_img}).values())).cast('float32') + def run_policy(warped, img_q, big_img_q, feat_q, packed_npy_inputs, **kwargs): + desire_q = kwargs['desire_q'] + packed_npy_inputs_dev = packed_npy_inputs.to(Device.DEFAULT) + warped_dev = warped.to(Device.DEFAULT) + Tensor.realize(packed_npy_inputs_dev, warped_dev) - new_feat = vision_out[:, vision_features_slice].reshape(1, -1).unsqueeze(0) - feat_buf = shift_and_sample(feat_q, new_feat, sample_skip_fn) - desire_buf = shift_and_sample(desire_q, desire.reshape(1, 1, -1), sample_desire_fn) + 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) - inputs = {'features_buffer': feat_buf, desire_key: desire_buf, 'traffic_convention': traffic_convention, **extra_device} - policy_out = next(iter(policy_runner(inputs).values())).cast('float32') + 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) + + inputs = {desire_key: desire_buf} + for key, tensor_val in unpacked_dict.items(): + if key not in ('desire', 'prev_feat'): + inputs[key] = tensor_val + + 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).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() + if 'features_buffer' not in inputs: + new_feat = vision_out_cast[:, features_slice].reshape(1, -1).unsqueeze(0) + inputs['features_buffer'] = shift_and_sample(feat_q, new_feat, sample_skip_fn).realize() + policy_outs = [next(iter(pol_runner(inputs).values())).cast('float32').realize() for pol_runner in policy_runners] + return (vision_out_cast, *policy_outs) if len(policy_outs) > 1 else (vision_out_cast, policy_outs[0]) + + inputs.update({road_key: img, wide_key: big_img}) + if 'features_buffer' not in inputs: + 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: + new_feat = policy_out[:, features_slice].reshape(1, -1).unsqueeze(0) + shift_and_sample(feat_q, new_feat, sample_skip_fn).realize() + return policy_out - return vision_out, policy_out return run_policy -def compile_split_policy(nv12: NV12Frame, model_w, model_h, prepare_only, frame_skip, - vision_runner, policy_runner, vision_metadata, policy_metadata): - print(f"Compiling combined policy JIT for {nv12.width}x{nv12.height} (prepare_only={prepare_only})...") - - vision_features_slice = vision_metadata['output_slices']['hidden_state'] - vision_input_shapes = vision_metadata['input_shapes'] - policy_input_shapes = policy_metadata['input_shapes'] - desire_key = _detect_desire_key(policy_input_shapes) - extra_policy_keys = [k for k in policy_input_shapes if k not in ('features_buffer', desire_key, 'traffic_convention')] - vision_road_key, vision_wide_key = _detect_vision_keys(vision_input_shapes) - - _run = make_run_split_policy(vision_runner, policy_runner, nv12, model_w, model_h, - vision_features_slice, frame_skip, desire_key, extra_policy_keys, - vision_road_key, vision_wide_key, prepare_only) - run_policy_jit = TinyJit(_run, prune=True) - +def compile_jit(jit, make_random_inputs, input_keys, make_queues): SEED = 42 - - def random_inputs_run_fn(fn, seed, test_val=None, test_buffers=None, expect_match=True): - input_queues, npy = make_split_input_queues(vision_input_shapes, policy_input_shapes, frame_skip, Device.DEFAULT) - np.random.seed(seed) + 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) testing = test_val is not None or test_buffers is not None n_runs = 1 if testing else 3 for i in range(n_runs): - frame = Tensor.randint(nv12.size, low=0, high=256, dtype='uint8').realize() - big_frame = Tensor.randint(nv12.size, low=0, high=256, dtype='uint8').realize() for v in npy.values(): - v[:] = np.random.randn(*v.shape).astype(v.dtype) + v[:] = rng.standard_normal(v.shape).astype(v.dtype) Device.default.synchronize() + random_inputs = make_random_inputs() st = time.perf_counter() - outs = fn(**input_queues, frame=frame, big_frame=big_frame) + outs = fn(**{k: input_queues[k] for k in input_keys if k in input_queues}, **random_inputs) mt = time.perf_counter() Device.default.synchronize() et = time.perf_counter() print(f" [{i+1}/{n_runs}] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") if i == 0: - val = [np.copy(v.numpy()) for v in outs] + val = [np.copy(v.numpy()) for v in (outs if isinstance(outs, tuple) else [outs])] if outs is not None else [] buffers = [np.copy(v.numpy().copy()) for v in input_queues.values()] if test_val is not None: @@ -158,323 +257,132 @@ def compile_split_policy(nv12: NV12Frame, model_w, model_h, prepare_only, frame_ if test_buffers is not None: match = all(np.array_equal(a, b) for a, b in zip(buffers, test_buffers, strict=True)) assert match == expect_match, f"buffers {'differ from' if expect_match else 'match'} baseline (seed={seed})" - return fn, val, buffers + return val, buffers print('capture + replay') - run_policy_jit, test_val, test_buffers = random_inputs_run_fn(run_policy_jit, SEED) - + test_val, test_buffers = random_inputs_run(jit, SEED) print('pickle round trip') - run_policy_jit = pickle.loads(pickle.dumps(run_policy_jit)) - random_inputs_run_fn(run_policy_jit, SEED, test_val, test_buffers, expect_match=True) - random_inputs_run_fn(run_policy_jit, SEED+1, test_val, test_buffers, expect_match=False) - return run_policy_jit + with tempfile.TemporaryFile(dir=".") as f: + dump_oob(jit, f) + f.seek(0) + deserialized_jit = load_oob(f) + random_inputs_run(deserialized_jit, SEED, test_val=test_val, test_buffers=test_buffers) + return deserialized_jit -def derive_frame_skip(vision_input_shapes, policy_input_shapes): - fb = policy_input_shapes.get('features_buffer') - if fb is None: - return 1 - fb_history = fb[1] - if fb_history >= 99: - return 1 - return 4 +def _parse_size(size_str: str) -> tuple[int, int]: + width, height = size_str.lower().split('x') + return int(width), int(height) -def make_supercombo_input_queues(input_shapes, frame_skip, device): - img_shape = input_shapes.get('img', input_shapes.get('input_imgs')) - if img_shape is None: - raise ValueError("No img input found in model shapes") - - n_frames = img_shape[1] // 6 - img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img_shape[2], img_shape[3]) - - numpy_keys = {} - queue_keys = {} - - for key, shape in input_shapes.items(): - if 'img' in key: - continue - if len(shape) == 3 and shape[1] > 1: - if key.startswith('desire'): - numpy_keys[key] = np.zeros(shape[2], dtype=np.float32) - queue_keys[f'{key}_q'] = Tensor( - np.zeros((frame_skip * shape[1], shape[0], shape[2]), dtype=np.float32), - device=device).contiguous().realize() - elif key == 'features_buffer': - queue_keys['feat_q'] = Tensor( - np.zeros((frame_skip * (shape[1] - 1) + 1, shape[0], shape[2]), dtype=np.float32), - device=device).contiguous().realize() - else: - numpy_keys[key] = np.zeros(shape, dtype=np.float32) - elif len(shape) == 2: - numpy_keys[key] = np.zeros(shape, dtype=np.float32) - - if 'traffic_convention' not in numpy_keys: - tc_shape = input_shapes.get('traffic_convention', (1, 2)) - numpy_keys['traffic_convention'] = np.zeros(tc_shape, dtype=np.float32) - - numpy_keys['tfm'] = np.zeros((3, 3), dtype=np.float32) - numpy_keys['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(), - **queue_keys, - **{k: Tensor(v, device='NPY').realize() for k, v in numpy_keys.items()}, - } - return input_queues, numpy_keys +def read_file_chunked_to_disk(path): + if not path: + return None + import atexit + import shutil + from openpilot.common.file_chunker import open_file_chunked + tmp_path = f'{path}.unchunked' + with open(tmp_path, 'wb') as f, open_file_chunked(path) as src: + shutil.copyfileobj(src, f) + atexit.register(lambda: os.path.exists(tmp_path) and os.remove(tmp_path)) + return tmp_path -def make_run_supercombo(model_runner, nv12: NV12Frame, model_w, model_h, - features_slice, frame_skip, input_shapes, prepare_only=False): - frame_prepare = make_frame_prepare(nv12, model_w, model_h) - sample_skip_fn = partial(sample_skip, frame_skip=frame_skip) - sample_desire_fn = partial(sample_desire, frame_skip=frame_skip) - - desire_key = _detect_desire_key(input_shapes) - if desire_key is None: - raise ValueError(f"No desire* key found in input_shapes: {list(input_shapes.keys())}") - road_img_key, wide_img_key = _detect_vision_keys(input_shapes) - extra_policy_keys = [k for k in input_shapes - if k not in (desire_key, 'features_buffer', 'traffic_convention') - and 'img' not in k] - - def run_supercombo(img_q, big_img_q, feat_q, desire_q, - frame, big_frame, **kwargs): - desire = kwargs.get(desire_key) - traffic_convention = kwargs.get('traffic_convention') - tfm = kwargs['tfm'] - big_tfm = kwargs['big_tfm'] - - tfm = tfm.to(Device.DEFAULT) - big_tfm = big_tfm.to(Device.DEFAULT) - desire = desire.to(Device.DEFAULT) - traffic_convention = traffic_convention.to(Device.DEFAULT) - Tensor.realize(tfm, big_tfm, desire, traffic_convention) - - img = shift_and_sample(img_q, frame_prepare(frame, tfm).unsqueeze(0), sample_skip_fn) - big_img = shift_and_sample(big_img_q, frame_prepare(big_frame, big_tfm).unsqueeze(0), sample_skip_fn) - - if prepare_only: - return img, big_img - - desire_buf = shift_and_sample(desire_q, desire.reshape(1, 1, -1), sample_desire_fn) - feat_buf = sample_skip_fn(feat_q) - - inputs = {road_img_key: img, wide_img_key: big_img, - desire_key: desire_buf, 'features_buffer': feat_buf, - 'traffic_convention': traffic_convention} - for k in extra_policy_keys: - if k in kwargs: - inputs[k] = kwargs[k].to(Device.DEFAULT) - - model_out = next(iter(model_runner(inputs).values())).cast('float32') - - new_feat = model_out[:, features_slice].reshape(1, -1).unsqueeze(0) - shift_and_sample(feat_q, new_feat, sample_skip_fn) - - return model_out - - return run_supercombo - - -def make_run_vision_multi_policy(vision_runner, policy_runners, nv12: NV12Frame, model_w, model_h, - vision_features_slice, frame_skip, desire_key, extra_policy_keys, - vision_road_key, vision_wide_key, prepare_only=False): - frame_prepare = make_frame_prepare(nv12, model_w, model_h) - sample_skip_fn = partial(sample_skip, frame_skip=frame_skip) - sample_desire_fn = partial(sample_desire, frame_skip=frame_skip) - - def run_multi_policy(img_q, big_img_q, feat_q, desire_q, desire, - traffic_convention, tfm, big_tfm, frame, big_frame, **extra): - npy_tensors = [tfm.to(Device.DEFAULT), big_tfm.to(Device.DEFAULT), - desire.to(Device.DEFAULT), traffic_convention.to(Device.DEFAULT)] - extra_device = {k: extra[k].to(Device.DEFAULT) for k in extra_policy_keys} - Tensor.realize(*npy_tensors, *extra_device.values()) - tfm, big_tfm, desire, traffic_convention = npy_tensors - - img = shift_and_sample(img_q, frame_prepare(frame, tfm).unsqueeze(0), sample_skip_fn) - big_img = shift_and_sample(big_img_q, frame_prepare(big_frame, big_tfm).unsqueeze(0), sample_skip_fn) - - if prepare_only: - return img, big_img - - vision_out = next(iter(vision_runner({vision_road_key: img, vision_wide_key: big_img}).values())).cast('float32') - - new_feat = vision_out[:, vision_features_slice].reshape(1, -1).unsqueeze(0) - feat_buf = shift_and_sample(feat_q, new_feat, sample_skip_fn) - desire_buf = shift_and_sample(desire_q, desire.reshape(1, 1, -1), sample_desire_fn) - - inputs = {'features_buffer': feat_buf, desire_key: desire_buf, 'traffic_convention': traffic_convention, **extra_device} - - policy_outputs = [] - for runner in policy_runners: - policy_out = next(iter(runner(inputs).values())).cast('float32') - policy_outputs.append(policy_out) - - return (vision_out, *policy_outputs) - - return run_multi_policy - - -def _warmup_and_serialize(run_jit, input_queues, npy, nv12): - for i in range(3): - np.random.seed(42 + i) - frame = Tensor.randint(nv12.size, low=0, high=256, dtype='uint8').realize() - big_frame = Tensor.randint(nv12.size, low=0, high=256, dtype='uint8').realize() - for v in npy.values(): - v[:] = np.random.randn(*v.shape).astype(v.dtype) - Device.default.synchronize() - st = time.perf_counter() - run_jit(**input_queues, frame=frame, big_frame=big_frame) - mt = time.perf_counter() - Device.default.synchronize() - et = time.perf_counter() - print(f" [{i + 1}/3] enqueue {(mt - st) * 1e3:6.2f} ms -- total {(et - st) * 1e3:6.2f} ms") - return pickle.loads(pickle.dumps(run_jit)) - - -def compile_supercombo(nv12: NV12Frame, model_w, model_h, prepare_only, frame_skip, - model_runner, metadata): - print(f"Compiling combined supercombo JIT for {nv12.width}x{nv12.height} (prepare_only={prepare_only})...") - - features_slice = metadata['output_slices']['hidden_state'] - input_shapes = metadata['input_shapes'] - - _run = make_run_supercombo(model_runner, nv12, model_w, model_h, - features_slice, frame_skip, input_shapes, prepare_only) - run_jit = TinyJit(_run, prune=True) - - input_queues, npy = make_supercombo_input_queues(input_shapes, frame_skip, Device.DEFAULT) - - run_jit = _warmup_and_serialize(run_jit, input_queues, npy, nv12) - return run_jit - - -def compile_multi_policy(nv12: NV12Frame, model_w, model_h, prepare_only, frame_skip, - vision_runner, policy_runners, vision_metadata, policy_metadata): - print(f"Compiling combined multi-policy JIT for {nv12.width}x{nv12.height} (prepare_only={prepare_only})...") - - vision_features_slice = vision_metadata['output_slices']['hidden_state'] - vision_input_shapes = vision_metadata['input_shapes'] - policy_input_shapes = policy_metadata['input_shapes'] - desire_key = _detect_desire_key(policy_input_shapes) - extra_policy_keys = [k for k in policy_input_shapes if k not in ('features_buffer', desire_key, 'traffic_convention')] - vision_road_key, vision_wide_key = _detect_vision_keys(vision_input_shapes) - - _run = make_run_vision_multi_policy(vision_runner, policy_runners, nv12, model_w, model_h, - vision_features_slice, frame_skip, desire_key, extra_policy_keys, - vision_road_key, vision_wide_key, prepare_only) - run_jit = TinyJit(_run, prune=True) - - input_queues, npy = make_split_input_queues(vision_input_shapes, policy_input_shapes, frame_skip, Device.DEFAULT) - - run_jit = _warmup_and_serialize(run_jit, input_queues, npy, nv12) - return run_jit - - -def _parse_size(s): - w, h = s.lower().split('x') - return int(w), int(h) +def _load_policy_runners(args: argparse.Namespace) -> tuple[list, list]: + runners, keys = [], [] + for name, onnx_arg in [('policy', args.policy_onnx), ('off_policy', args.off_policy_onnx), ('on_policy', args.on_policy_onnx)]: + if onnx_arg: + runners.append(OnnxRunner(onnx_arg)) + keys.append(name) + return runners, keys if __name__ == "__main__": - from tinygrad.nn.onnx import OnnxRunner - from openpilot.system.camerad.cameras.nv12_info import get_nv12_info - from openpilot.selfdrive.modeld.get_model_metadata import make_metadata_dict - - p = argparse.ArgumentParser(description="Compile combined JIT pkl for sunnypilot modeld_v2") - p.add_argument('--model-type', choices=MODEL_TYPES, required=True) - p.add_argument('--model-size', type=_parse_size, required=True, help='model input WxH') - p.add_argument('--camera-resolutions', type=_parse_size, nargs='+', required=True) - p.add_argument('--frame-skip', type=int, default=None, help='frame skip value (auto-derived if not provided)') - p.add_argument('--output', required=True) - - p.add_argument('--vision-onnx', help='vision ONNX (for split models)') - p.add_argument('--policy-onnx', help='policy ONNX (for vision_policy)') - p.add_argument('--off-policy-onnx', help='off-policy ONNX (for vision_multi_policy)') - p.add_argument('--on-policy-onnx', help='on-policy ONNX (for vision_multi_policy)') - p.add_argument('--supercombo-onnx', help='supercombo ONNX (for supercombo)') - - args = p.parse_args() - out = defaultdict(dict) - - if args.model_type == 'vision_policy': - assert args.vision_onnx and args.policy_onnx - vision_runner = OnnxRunner(args.vision_onnx) - policy_runner = OnnxRunner(args.policy_onnx) - out['metadata']['vision'] = make_metadata_dict(args.vision_onnx) - out['metadata']['policy'] = make_metadata_dict(args.policy_onnx) - - frame_skip = args.frame_skip if args.frame_skip is not None else derive_frame_skip(out['metadata']['vision']['input_shapes'], - out['metadata']['policy']['input_shapes']) - - for cam_w, cam_h in args.camera_resolutions: - nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - model_w, model_h = args.model_size - out[(cam_w, cam_h)] = { - name: compile_split_policy(nv12, model_w, model_h, prepare_only, frame_skip, - vision_runner, policy_runner, - out['metadata']['vision'], out['metadata']['policy']) - for name, prepare_only in [('warp_enqueue', True), ('run_policy', False)] - } - - elif args.model_type == 'supercombo': - assert args.supercombo_onnx - model_runner = OnnxRunner(args.supercombo_onnx) - out['metadata']['model'] = make_metadata_dict(args.supercombo_onnx) - - frame_skip = args.frame_skip if args.frame_skip is not None else derive_frame_skip({}, out['metadata']['model']['input_shapes']) - - for cam_w, cam_h in args.camera_resolutions: - nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - model_w, model_h = args.model_size - out[(cam_w, cam_h)] = { - name: compile_supercombo(nv12, model_w, model_h, prepare_only, frame_skip, - model_runner, out['metadata']['model']) - for name, prepare_only in [('warp_enqueue', True), ('run_policy', False)] - } - - elif args.model_type == 'vision_multi_policy': - assert args.vision_onnx - vision_runner = OnnxRunner(args.vision_onnx) - out['metadata']['vision'] = make_metadata_dict(args.vision_onnx) - - policy_runners = [] - policy_onnxes = [] - if args.policy_onnx: - policy_onnxes.append(('policy', args.policy_onnx)) - if args.off_policy_onnx: - policy_onnxes.append(('off_policy', args.off_policy_onnx)) - if args.on_policy_onnx: - policy_onnxes.append(('on_policy', args.on_policy_onnx)) - - for name, onnx_path in policy_onnxes: - runner = OnnxRunner(onnx_path) - policy_runners.append(runner) - out['metadata'][name] = make_metadata_dict(onnx_path) - - first_policy_key = policy_onnxes[0][0] - frame_skip = args.frame_skip if args.frame_skip is not None else derive_frame_skip(out['metadata']['vision']['input_shapes'], - out['metadata'][first_policy_key]['input_shapes']) - - for cam_w, cam_h in args.camera_resolutions: - nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - model_w, model_h = args.model_size - out[(cam_w, cam_h)] = { - name: compile_multi_policy(nv12, model_w, model_h, prepare_only, frame_skip, - vision_runner, policy_runners, - out['metadata']['vision'], out['metadata'][first_policy_key]) - for name, prepare_only in [('warp_enqueue', True), ('run_policy', False)] - } - - with open(args.output, "wb") as f: - pickle.dump(out, f) - pkl_size = os.path.getsize(args.output) - print(f"Saved combined JIT to {args.output} ({pkl_size / 1e6:.2f} MB)") + 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(): + break + time.sleep(1) + else: + raise RuntimeError("Chestnut not ready, skipping big model build") from openpilot.common.file_chunker import chunk_file, get_chunk_targets + from openpilot.selfdrive.modeld.get_model_metadata import make_metadata_dict + from openpilot.system.camerad.cameras.nv12_info import get_nv12_info + from tinygrad.nn.onnx import OnnxRunner + + parser = argparse.ArgumentParser(description="Compile combined JIT pkl for sunnypilot modeld_v2") + parser.add_argument('--model-type', choices=MODEL_TYPES, required=True) + parser.add_argument('--model-size', type=_parse_size, required=True, help='model input WxH') + parser.add_argument('--camera-resolutions', type=_parse_size, nargs='+', required=True) + parser.add_argument('--frame-skip', type=int, default=None, help='frame skip value (auto-derived if not provided)') + parser.add_argument('--output', required=True) + + parser.add_argument('--vision-onnx', help='vision ONNX (for split models)') + parser.add_argument('--policy-onnx', help='policy ONNX (for vision_policy)') + parser.add_argument('--off-policy-onnx', help='off-policy ONNX (for vision_multi_policy)') + parser.add_argument('--on-policy-onnx', help='on-policy ONNX (for vision_multi_policy)') + parser.add_argument('--supercombo-onnx', help='supercombo ONNX (for supercombo)') + + args = parser.parse_args() + model_w, model_h = args.model_size + output_data = {} + + args.vision_onnx = read_file_chunked_to_disk(args.vision_onnx) + args.policy_onnx = read_file_chunked_to_disk(args.policy_onnx) + args.off_policy_onnx = read_file_chunked_to_disk(args.off_policy_onnx) + args.on_policy_onnx = read_file_chunked_to_disk(args.on_policy_onnx) + args.supercombo_onnx = read_file_chunked_to_disk(args.supercombo_onnx) + + vision_runner = OnnxRunner(args.vision_onnx) if args.vision_onnx else None + + if args.model_type == 'vision_policy': + assert vision_runner and args.policy_onnx + policy_runners = [OnnxRunner(args.policy_onnx)] + output_data['metadata'] = {'vision': make_metadata_dict(args.vision_onnx), 'policy': make_metadata_dict(args.policy_onnx)} + elif args.model_type == 'supercombo': + assert args.supercombo_onnx + policy_runners = [OnnxRunner(args.supercombo_onnx)] + output_data['metadata'] = {'model': make_metadata_dict(args.supercombo_onnx)} + elif args.model_type == 'vision_multi_policy': + assert vision_runner + policy_runners, policy_names = _load_policy_runners(args) + output_data['metadata'] = {'vision': make_metadata_dict(args.vision_onnx)} + for name in policy_names: + runner_arg = getattr(args, f"{name}_onnx") + output_data['metadata'][name] = make_metadata_dict(runner_arg) + + policy_keys = [key for key in output_data['metadata'].keys() if key != 'vision'] + first_policy_meta = output_data['metadata'][policy_keys[0]] if policy_keys else {} + vision_meta = output_data['metadata'].get('vision', {}) + + derived_frame_skip = args.frame_skip or derive_frame_skip(vision_meta.get('input_shapes', {}), first_policy_meta.get('input_shapes', {})) + all_shapes = {key: value for meta in output_data['metadata'].values() for key, value in meta['input_shapes'].items()} + feat_meta = output_data['metadata'].get('vision') or output_data['metadata'].get('model') or output_data['metadata'].get('policy') + assert feat_meta is not None + features_slice = feat_meta['output_slices']['hidden_state'] + is_supercombo = vision_runner is None + + print(f"Compiling run_policy JIT (model_size={model_w}x{model_h}, frame_skip={derived_frame_skip})...") + run_policy_func = make_run_policy(vision_runner, policy_runners, features_slice, derived_frame_skip, all_shapes) + run_policy_jit = TinyJit(run_policy_func, prune=True) + make_policy_queues = partial(generate_queues_and_npy, all_shapes, derived_frame_skip, is_supercombo=is_supercombo) + make_random_model_inputs = partial(make_random_images, keys=['warped'], shape=(2, 6, model_h // 2, model_w // 2), device=WARP_DEV) + output_data['run_policy'] = compile_jit(run_policy_jit, make_random_model_inputs, POLICY_INPUTS, make_policy_queues) + + for cam_w, cam_h in args.camera_resolutions: + print(f"Compiling warp JIT for {cam_w}x{cam_h}...") + 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), prune=True) + output_data[(cam_w, cam_h)] = compile_jit(warp, make_random_warp_inputs, WARP_INPUTS, make_warp_queues) + + with open(args.output, "wb") as file: + dump_oob(output_data, file) + + pkl_size = os.path.getsize(args.output) + print(f"Saved combined JIT to {args.output} ({pkl_size / 1e6:.2f} MB)") chunk_targets = get_chunk_targets(args.output, pkl_size) chunk_file(args.output, chunk_targets) - num_chunks = len(chunk_targets) - 1 - print(f"Chunked into {num_chunks} file(s)") + print(f"Chunked into {len(chunk_targets) - 1} file(s)") diff --git a/openpilot/sunnypilot/modeld_v2/install_models_pc.py b/openpilot/sunnypilot/modeld_v2/install_models_pc.py deleted file mode 100755 index 7bc2f4797c..0000000000 --- a/openpilot/sunnypilot/modeld_v2/install_models_pc.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 -import sys -import shutil -import pickle -import codecs -from pathlib import Path - -from openpilot.common.hardware.hw import Paths -from openpilot.sunnypilot.modeld_v2.get_model_metadata import MetadataOnnxPBParser, get_name_and_shape, get_metadata_value_by_name - - -def generate_metadata_pkl(model_path, output_path): - try: - model = MetadataOnnxPBParser(model_path).parse() - output_slices = get_metadata_value_by_name(model, 'output_slices') - if not output_slices: - return False - metadata = { - 'model_checkpoint': get_metadata_value_by_name(model, 'model_checkpoint'), - 'output_slices': pickle.loads(codecs.decode(output_slices.encode(), "base64")), - 'input_shapes': dict(get_name_and_shape(x) for x in model["graph"]["input"]), - 'output_shapes': dict(get_name_and_shape(x) for x in model["graph"]["output"]), - } - with open(output_path, 'wb') as f: - pickle.dump(metadata, f) - return True - except Exception: - return False - - -def install_models(model_dir): - model_dir = Path(model_dir) - models = ["driving_off_policy", "driving_on_policy", "driving_vision"] - found_models = [] - - for model in models: - if (model_dir / f"{model}.onnx").exists(): - found_models.append(model) - - if not found_models: - return - - try: - custom_name = input(f"Found models ({', '.join(found_models)}). Enter model short name (e.g. wmiv4): ").strip() - except EOFError: - return - - if not custom_name: - print("No name provided, skipping installation.") - return - - dest_dir = Path(Paths.model_root()) - dest_dir.mkdir(parents=True, exist_ok=True) - - for model in found_models: - onnx_path = model_dir / f"{model}.onnx" - tinygrad_pkl = model_dir / f"{model}_tinygrad.pkl" - metadata_pkl = model_dir / f"{model}_metadata.pkl" - - if not metadata_pkl.exists(): - generate_metadata_pkl(onnx_path, metadata_pkl) - - dest_tinygrad = dest_dir / f"{model}_{custom_name}_tinygrad.pkl" - dest_metadata = dest_dir / f"{model}_{custom_name}_metadata.pkl" - - if tinygrad_pkl.exists(): - shutil.move(str(tinygrad_pkl), str(dest_tinygrad)) - if metadata_pkl.exists(): - shutil.move(str(metadata_pkl), str(dest_metadata)) - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("Usage: install_models_pc.py ") - sys.exit(1) - install_models(sys.argv[1]) diff --git a/openpilot/sunnypilot/modeld_v2/modeld.py b/openpilot/sunnypilot/modeld_v2/modeld.py index 86d5b05868..d9c04d7824 100755 --- a/openpilot/sunnypilot/modeld_v2/modeld.py +++ b/openpilot/sunnypilot/modeld_v2/modeld.py @@ -7,22 +7,24 @@ See the LICENSE.md file in the root directory for more details. """ import os -from openpilot.common.hardware import TICI -os.environ['DEV'] = 'QCOM' if TICI else 'CPU' -USBGPU = "USBGPU" in os.environ -if USBGPU: - os.environ['DEV'] = 'AMD' - os.environ['AMD_IFACE'] = 'USB' -import pickle +os.environ['GMMU'] = '0' +from openpilot.common.hardware import COMMA_HARDWARE +from openpilot.selfdrive.modeld.helpers import chestnut_present, load_oob import time import numpy as np import openpilot.cereal.messaging as messaging from openpilot.cereal import log from opendbc.car.structs import car +from openpilot.cereal.services import SERVICE_LIST from setproctitle import setproctitle from openpilot.cereal.messaging import PubMaster, SubMaster -from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf +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 from openpilot.common.filter_simple import FirstOrderFilter @@ -30,17 +32,21 @@ from openpilot.common.realtime import config_realtime_process, DT_MDL from openpilot.common.transformations.camera import DEVICE_CAMERAS from openpilot.common.transformations.model import get_warp_matrix from openpilot.system import sentry +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, smooth_value +from openpilot.selfdrive.modeld.modeld import ChestnutState from openpilot.sunnypilot.modeld_v2.fill_model_msg import fill_model_msg, fill_pose_msg, PublishState, get_curvature_from_output 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" @@ -78,14 +84,14 @@ class ModelState(ModelStateBase): inputs: dict[str, np.ndarray] prev_desire: np.ndarray - def __init__(self, cam_w: int, cam_h: int): + 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 {} @@ -93,35 +99,39 @@ 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.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" self._init_combined(pkl_path, cam_w, cam_h, model_bundle) def _init_combined(self, pkl_path, cam_w, cam_h, bundle): - from tinygrad.tensor import Tensor - from openpilot.system.camerad.cameras.nv12_info import get_nv12_info - from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, make_split_input_queues - from tinygrad.device import Device - - from openpilot.common.file_chunker import open_file_chunked - cloudlog.warning(f"loading combined pkl: {pkl_path}") - jits = pickle.load(open_file_chunked(pkl_path)) - - self.DEV = Device.DEFAULT + jits = load_oob(open_file_chunked(pkl_path)) + self.WARP_DEV = 'QCOM' if COMMA_HARDWARE else 'CPU' + self.DEV = 'AMD' if self.chestnut else self.WARP_DEV + self.QUEUE_DEV = self.DEV metadata = jits['metadata'] + + self.is_legacy_model = 'run_policy' not in jits # remove after next recompile + if self.is_legacy_model: + self.warp = jits[(cam_w, cam_h)]['warp_enqueue'] + self.run_policy = jits[(cam_w, cam_h)]['run_policy'] + else: + self.run_policy = jits['run_policy'] + self.warp = jits[(cam_w, cam_h)] + if 'model' in metadata: model_metadata = metadata['model'] self.vision_output_slices = model_metadata['output_slices'] self.policy_output_slices = {} self._policy_slices_list = [] self._combined_model_type = 'supercombo' - self._vision_input_names = [k for k in model_metadata['input_shapes'] if 'img' in k] - from openpilot.sunnypilot.modeld_v2.compile_modeld import make_supercombo_input_queues + self._vision_input_names = [key for key in model_metadata['input_shapes'] if 'img' in key] frame_skip = derive_frame_skip({}, model_metadata['input_shapes']) - self.input_queues, self.numpy_inputs = make_supercombo_input_queues(model_metadata['input_shapes'], frame_skip, device=self.DEV) + self.input_queues, self.numpy_inputs = make_supercombo_input_queues(model_metadata['input_shapes'], + frame_skip, device=self.QUEUE_DEV) else: vision_metadata = metadata['vision'] policy_keys = [k for k in metadata if k != 'vision'] @@ -134,16 +144,16 @@ class ModelState(ModelStateBase): self._policy_slices_list = [metadata[k]['output_slices'] for k in policy_keys] self.policy_output_slices = self._policy_slices_list[0] self._has_on_policy = any('on' in k.lower() for k in policy_keys) - first_policy_metadata = metadata[policy_keys[0]] - vision_input_shapes = vision_metadata['input_shapes'] - policy_input_shapes = first_policy_metadata['input_shapes'] - self._vision_input_names = [k for k in vision_input_shapes if 'img' in k] - frame_skip = derive_frame_skip(vision_input_shapes, policy_input_shapes) - self.input_queues, self.numpy_inputs = make_split_input_queues(vision_input_shapes, policy_input_shapes, frame_skip, device=self.DEV) + self._vision_input_names = [key for key in vision_metadata['input_shapes'] if 'img' in key] + first_policy_meta = metadata[policy_keys[0]] + frame_skip = derive_frame_skip(vision_metadata['input_shapes'], first_policy_meta['input_shapes']) + self.input_queues, self.numpy_inputs = make_split_input_queues(vision_metadata['input_shapes'], + first_policy_meta['input_shapes'], + frame_skip, device=self.QUEUE_DEV) - from openpilot.sunnypilot.modeld_v2.parse_model_outputs_split import Parser as SplitParser - from openpilot.sunnypilot.modeld_v2.parse_model_outputs import Parser as CombinedParser - self.parser = SplitParser() if self._combined_model_type != 'supercombo' else CombinedParser() + self._desire_key = next(key for key in self.numpy_inputs if key.startswith('desire')) + self._road_key = next(key for key in self._vision_input_names if 'big' not in key) + self._wide_key = next(key for key in self._vision_input_names if 'big' in key) is_20hz = bundle.is20hz if bundle else self._combined_model_type in ('split', 'multi_policy') if is_20hz: @@ -153,20 +163,47 @@ class ModelState(ModelStateBase): from openpilot.sunnypilot.modeld_v2.constants import ModelConstants self.constants = ModelConstants() + if self._combined_model_type != 'supercombo': + from openpilot.sunnypilot.modeld_v2.parse_model_outputs_split import Parser as SplitParser + self.parser = SplitParser() + else: + from openpilot.sunnypilot.modeld_v2.parse_model_outputs import Parser as CombinedParser + self.parser = CombinedParser() + self.prev_desire = np.zeros(self.constants.DESIRE_LEN, dtype=np.float32) self.full_frames: dict = {} self._blob_cache: dict = {} nv12_info = get_nv12_info(cam_w, cam_h) self.frame_buf_params = dict.fromkeys(self._vision_input_names, nv12_info) - self._run_policy = jits[(cam_w, cam_h)]['run_policy'] - self._warp_enqueue = jits[(cam_w, cam_h)]['warp_enqueue'] - road_name = next(k for k in self._vision_input_names if 'big' not in k) - yuv_size = self.frame_buf_params[road_name][3] - self._warp_enqueue( - **self.input_queues, - frame=Tensor(np.zeros(yuv_size, dtype=np.uint8), device=self.DEV).contiguous().realize(), - big_frame=Tensor(np.zeros(yuv_size, dtype=np.uint8), device=self.DEV).contiguous().realize()) + yuv_size = self.frame_buf_params[self._road_key][3] + frame_tensor = Tensor(np.zeros(yuv_size, dtype=np.uint8), device=self.WARP_DEV).contiguous().realize() + big_frame_tensor = Tensor(np.zeros(yuv_size, dtype=np.uint8), device=self.WARP_DEV).contiguous().realize() + + if self.is_legacy_model: # Remove this conditional hack after recompile + self.warp(**self.input_queues, frame=frame_tensor, big_frame=big_frame_tensor) + else: + self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=frame_tensor, big_frame=big_frame_tensor) + + if self.chestnut: + self.warmup() + + def warmup(self) -> None: + dummy_frames = {k: np.zeros(self.frame_buf_params[k][3], dtype=np.uint8) for k in self._vision_input_names} + transforms = {k: np.eye(3, dtype=np.float32) for k in [self._road_key, self._wide_key] if k} + + dummy_inputs = {} + for k, v in self.numpy_inputs.items(): + if k not in ['tfm', 'big_tfm', 'prev_feat']: + dummy_inputs[k] = np.zeros(v.shape, dtype=v.dtype) + + self.run(dummy_frames, transforms, dummy_inputs, prepare_only=False) + + for v in self.numpy_inputs.values(): + v[:] = 0 + self.prev_desire[:] = 0 + self.full_frames.clear() + self._blob_cache.clear() @property @@ -179,54 +216,67 @@ class ModelState(ModelStateBase): @property def desire_key(self) -> str: - return next(k for k in self.numpy_inputs if k.startswith('desire')) + return self._desire_key def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray], inputs: dict[str, np.ndarray], prepare_only: bool) -> dict[str, np.ndarray] | None: - from tinygrad.tensor import Tensor - for key in bufs.keys(): ptr = np.frombuffer(bufs[key].data, dtype=np.uint8).ctypes.data yuv_size = self.frame_buf_params[key][3] 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.DEV) + 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] desire_key = self.desire_key inputs[desire_key][0] = 0 self.numpy_inputs[desire_key][:] = np.where(inputs[desire_key] - self.prev_desire > .99, inputs[desire_key], 0) self.prev_desire[:] = inputs[desire_key] - for key in ('traffic_convention', 'lateral_control_params'): + for key in ('traffic_convention', 'lateral_control_params', 'action_t'): if key in self.numpy_inputs and key in inputs: self.numpy_inputs[key][:] = inputs[key] - road_key = next(n for n in bufs if 'big' not in n) - wide_key = next(n for n in bufs if 'big' in n) + road_key = self._road_key + wide_key = self._wide_key self.numpy_inputs['tfm'][:, :] = transforms[road_key].reshape(3, 3) self.numpy_inputs['big_tfm'][:, :] = transforms[wide_key].reshape(3, 3) - if prepare_only: - self._warp_enqueue(**self.input_queues, frame=self.full_frames[road_key], big_frame=self.full_frames[wide_key]) - return None - - raw_outputs = self._run_policy(**self.input_queues, frame=self.full_frames[road_key], big_frame=self.full_frames[wide_key]) + if self.is_legacy_model: # remove after next recompile + if prepare_only: + self.warp(**self.input_queues, frame=self.full_frames[road_key], big_frame=self.full_frames[wide_key]) + return None + raw_outputs = self.run_policy(**self.input_queues, frame=self.full_frames[road_key], big_frame=self.full_frames[wide_key]) + else: + if prepare_only: + self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=self.full_frames[road_key], big_frame=self.full_frames[wide_key]) + return None + warped = self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=self.full_frames[road_key], big_frame=self.full_frames[wide_key]) + raw_outputs = self.run_policy(**{k: self.input_queues[k] for k in POLICY_INPUTS if k in self.input_queues}, warped=warped) if self._combined_model_type == 'supercombo': model_output = raw_outputs.numpy().flatten() sliced = {k: model_output[np.newaxis, v] for k, v in self.vision_output_slices.items()} outputs = self.parser.parse_outputs(sliced) + if 'prev_feat' in self.numpy_inputs: + self.numpy_inputs['prev_feat'][:] = model_output[self.vision_output_slices['hidden_state']] else: vision_output = raw_outputs[0].numpy().flatten() vision_sliced = {k: vision_output[np.newaxis, v] for k, v in self.vision_output_slices.items()} outputs = self.parser.parse_vision_outputs(vision_sliced) + if 'prev_feat' in self.numpy_inputs and 'hidden_state' in self.vision_output_slices: + self.numpy_inputs['prev_feat'][:] = vision_output[self.vision_output_slices['hidden_state']] + for i, policy_slices in enumerate(self._policy_slices_list): policy_output = raw_outputs[i + 1].numpy().flatten() policy_sliced = {k: policy_output[np.newaxis, v] for k, v in policy_slices.items()} parsed = self.parser.parse_policy_outputs(policy_sliced) - if 'off' in self._policy_keys[i] and self._has_on_policy: + if ('off' in self._policy_keys[i] + and self._has_on_policy + and any('plan' in self._policy_slices_list[j] for j, k in enumerate(self._policy_keys) if 'on' in k.lower())): + parsed.pop('plan', None) + outputs.update(parsed) if 'planplus' in outputs and 'plan' in outputs: @@ -237,24 +287,36 @@ class ModelState(ModelStateBase): buf[0, :-1] = buf[0, 1:] buf[0, -1, :] = outputs['desired_curvature'][0, :] if not self.mlsim else 0 + if self.chestnut and not np.all(np.isfinite(outputs.get('plan', np.array([0.])))): + cloudlog.error("model output not finite, dropping frame") + return None + return outputs def get_action_from_model(self, model_output: dict[str, np.ndarray], prev_action: log.ModelDataV2.Action, lat_action_t: float, long_action_t: float, v_ego: float) -> log.ModelDataV2.Action: - plan = model_output['plan'][0] - desired_accel, should_stop = get_accel_from_plan(plan[:, Plan.VELOCITY][:, 0], plan[:, Plan.ACCELERATION][:, 0], self.constants.T_IDXS, - action_t=long_action_t) + if 'action' not in model_output: + plan = model_output['plan'][0] + desired_accel = get_accel_from_plan(plan[:, Plan.VELOCITY][:, 0], plan[:, Plan.ACCELERATION][:, 0], self.constants.T_IDXS, + action_t=long_action_t) + + curvature_plan = (plan + (self.PLANPLUS_CONTROL - 1.0) * model_output['planplus'][0] + if 'planplus' in model_output and self.PLANPLUS_CONTROL != 1.0 else plan) + desired_curvature = get_curvature_from_output(model_output, curvature_plan, v_ego, lat_action_t, self.mlsim) + else: + desired_accel = model_output['action'][0, 1] + desired_curvature = model_output['action'][0, 0] / (max(1.0, v_ego))**2 + + stop = v_ego < 0.3 and desired_accel < 0.1 desired_accel = smooth_value(desired_accel, prev_action.desiredAcceleration, self.LONG_SMOOTH_SECONDS) - curvature_plan = plan + (self.PLANPLUS_CONTROL - 1.0) * model_output['planplus'][0] if 'planplus' in model_output and self.PLANPLUS_CONTROL != 1.0 else plan - desired_curvature = get_curvature_from_output(model_output, curvature_plan, v_ego, lat_action_t, self.mlsim) if self.generation is not None and self.generation >= 10: # smooth curvature for post FOF models if v_ego > self.MIN_LAT_CONTROL_SPEED: desired_curvature = smooth_value(desired_curvature, prev_action.desiredCurvature, self.LAT_SMOOTH_SECONDS) else: desired_curvature = prev_action.desiredCurvature - return log.ModelDataV2.Action(desiredCurvature=float(desired_curvature),desiredAcceleration=float(desired_accel), shouldStop=bool(should_stop)) + return log.ModelDataV2.Action(desiredCurvature=float(desired_curvature), desiredAcceleration=float(desired_accel), shouldStop=bool(stop)) def main(demo=False): @@ -265,16 +327,24 @@ def main(demo=False): setproctitle(PROCESS_NAME) config_realtime_process(7, 54) + CHESTNUT = chestnut_present() + if CHESTNUT: + os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' + + params = Params() + params.put_bool("ChestnutLoading", CHESTNUT) + params.remove("ChestnutActive") + # visionipc clients while True: available_streams = VisionIpcClient.available_streams("camerad", block=False) if available_streams: - use_extra_client = VisionStreamType.VISION_STREAM_WIDE_ROAD in available_streams and VisionStreamType.VISION_STREAM_ROAD in available_streams - main_wide_camera = VisionStreamType.VISION_STREAM_ROAD not in available_streams + use_extra_client = VisionStreamType.VISION_STREAM_WIDE_ROAD in available_streams and VisionStreamType.VISION_STREAM_NARROW_ROAD in available_streams + main_wide_camera = VisionStreamType.VISION_STREAM_NARROW_ROAD not in available_streams break time.sleep(.1) - vipc_client_main_stream = VisionStreamType.VISION_STREAM_WIDE_ROAD if main_wide_camera else VisionStreamType.VISION_STREAM_ROAD + vipc_client_main_stream = VisionStreamType.VISION_STREAM_WIDE_ROAD if main_wide_camera else VisionStreamType.VISION_STREAM_NARROW_ROAD vipc_client_main = VisionIpcClient("camerad", vipc_client_main_stream, True) vipc_client_extra = VisionIpcClient("camerad", VisionStreamType.VISION_STREAM_WIDE_ROAD, False) cloudlog.warning(f"vision stream set up, main_wide_camera: {main_wide_camera}, use_extra_client: {use_extra_client}") @@ -289,15 +359,34 @@ def main(demo=False): cloudlog.warning(f"connected extra cam with buffer size: {vipc_client_extra.buffer_len} ({vipc_client_extra.width} x {vipc_client_extra.height})") cloudlog.warning("loading model") - model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height) - cloudlog.warning("models loaded, modeld starting") + st = time.monotonic() + + model = None + if CHESTNUT: + import threading + def load(): + nonlocal model + model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, chestnut=True) + t = threading.Thread(target=load, daemon=True) + t.start() + t.join(60) + if model is None: + params.put_bool("ChestnutActive", False) + raise RuntimeError("chestnut model load failed or timed out (60s)") + params.put_bool("ChestnutActive", True) + else: + model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, chestnut=False) + + params.put_bool("ChestnutLoading", False) + cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") # messaging - pm = PubMaster(["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"]) - sm = SubMaster(["deviceState", "carState", "roadCameraState", "liveCalibration", "driverMonitoringState", "carControl", "liveDelay"]) + 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, CHESTNUT) if CHESTNUT else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / model.constants.MODEL_FREQ) @@ -326,6 +415,7 @@ def main(demo=False): DH = DesireHelper() meta_constants = load_meta_constants() + RELC = RoadEdgeLaneChangeController() while True: # Keep receiving frames until we are at least 1 frame ahead of previous extra frame @@ -363,18 +453,19 @@ def main(demo=False): sm.update(0) desire = DH.desire is_rhd = sm["driverMonitoringState"].isRHD - frame_id = sm["roadCameraState"].frameId + frame_id = sm["narrowRoadCameraState"].frameId v_ego = max(sm["carState"].vEgo, 0.) if sm.frame % 60 == 0: - model.lat_delay = get_lat_delay(params, sm["liveDelay"].lateralDelay) + model.lat_delay = get_lat_delay(params, sm["lateralDelay"].lateralDelay) model.PLANPLUS_CONTROL = params.get("PlanplusControl", return_default=True) camera_offset_helper.set_offset(params.get("CameraOffset", return_default=True)) lat_delay = model.lat_delay + model.LAT_SMOOTH_SECONDS - if sm.updated["liveCalibration"] and sm.seen['roadCameraState'] and sm.seen['deviceState']: - device_from_calib_euler = np.array(sm["liveCalibration"].rpyCalib, dtype=np.float32) - dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] - model_transform_main = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics if main_wide_camera else dc.fcam.intrinsics, False).astype(np.float32) - model_transform_extra = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics, True).astype(np.float32) + if sm.updated["extrinsicsCalibration"] and sm.seen['narrowRoadCameraState'] and sm.seen['deviceState']: + device_from_calib_euler = np.array(sm["extrinsicsCalibration"].rpyCalib, dtype=np.float32) + dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['narrowRoadCameraState'].sensor))] + main_intrinsics = dc.wide_road.intrinsics if main_wide_camera else dc.narrow_road.intrinsics + model_transform_main = get_warp_matrix(device_from_calib_euler, main_intrinsics, False).astype(np.float32) + model_transform_extra = get_warp_matrix(device_from_calib_euler, dc.wide_road.intrinsics, True).astype(np.float32) model_transform_main, model_transform_extra = camera_offset_helper.update(model_transform_main, model_transform_extra, sm, main_wide_camera) live_calib_seen = True @@ -400,6 +491,12 @@ def main(demo=False): bufs = {name: buf_extra if 'big' in name else buf_main for name in model.vision_input_names} transforms = {name: model_transform_extra if 'big' in name else model_transform_main for name in model.vision_input_names} + + frame_delay = DT_MDL # compensate for time passed since the frame was captured: current_time - timestamp_eof is 50ms on average + action_delay = DT_MDL / 2 # middle of the interval between model output (current state) and next frame (expected state) + lat_action_t = lat_delay + frame_delay + action_delay + long_action_t = long_delay + frame_delay + action_delay + inputs:dict[str, np.ndarray] = { model.desire_key: vec_desire, 'traffic_convention': traffic_convention, @@ -408,6 +505,9 @@ def main(demo=False): if 'lateral_control_params' in model.numpy_inputs: inputs['lateral_control_params'] = np.array([v_ego, lat_delay], dtype=np.float32) + if 'action_t' in model.numpy_inputs: + 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) mt2 = time.perf_counter() @@ -419,17 +519,19 @@ def main(demo=False): posenet_send = messaging.new_message('cameraOdometry') mdv2sp_send = messaging.new_message('modelDataV2SP') - action = model.get_action_from_model(model_output, prev_action, lat_delay + DT_MDL, long_delay + DT_MDL, v_ego) + action = model.get_action_from_model(model_output, prev_action, lat_action_t, long_action_t, v_ego) prev_action = action 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.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) + 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 @@ -443,6 +545,8 @@ 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(model.constants.MODEL_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0: + chestnut_state.send() if __name__ == "__main__": try: diff --git a/openpilot/sunnypilot/modeld_v2/parse_model_outputs.py b/openpilot/sunnypilot/modeld_v2/parse_model_outputs.py index c71a146454..7a3adcc1fa 100644 --- a/openpilot/sunnypilot/modeld_v2/parse_model_outputs.py +++ b/openpilot/sunnypilot/modeld_v2/parse_model_outputs.py @@ -1,13 +1,16 @@ import numpy as np from openpilot.sunnypilot.modeld_v2.constants import ModelConstants + def safe_exp(x, out=None): # -11 is around 10**14, more causes float16 overflow return np.exp(np.clip(x, -np.inf, 11), out=out) + def sigmoid(x): return 1. / (1. + safe_exp(-x)) + def softmax(x, axis=-1): x -= np.max(x, axis=axis, keepdims=True) if x.dtype == np.float32 or x.dtype == np.float64: @@ -17,6 +20,19 @@ def softmax(x, axis=-1): x /= np.sum(x, axis=axis, keepdims=True) return x + +def _infer_mhp(slice_size: int, prod_out_shape: int, max_in_n: int = 16, max_out_n: int = 6) -> tuple[int, int]: + for out_n in range(max_out_n + 1): + per = 2 * prod_out_shape + out_n + if per <= 0: + continue + if slice_size % per == 0: + in_n = slice_size // per + if 1 <= in_n <= max_in_n: + return in_n, out_n + return 1, 0 # single hypothesis, no weights — matches a non-MDN output + + class Parser: def __init__(self, ignore_missing=False): self.ignore_missing = ignore_missing @@ -40,17 +56,22 @@ class Parser: raw = outs[name] outs[name] = sigmoid(raw) - def parse_mdn(self, name, outs, in_N=0, out_N=1, out_shape=None): + def parse_mdn(self, name, outs, out_shape, in_N=0, out_N=0): if self.check_missing(outs, name): return raw = outs[name] - raw = raw.reshape((raw.shape[0], max(in_N, 1), -1)) + + if in_N == 0 and out_N == 0: + prod = int(np.prod(out_shape)) + in_N, out_N = _infer_mhp(raw.shape[1], prod) + + raw = raw.reshape((raw.shape[0], in_N, -1)) n_values = (raw.shape[2] - out_N)//2 pred_mu = raw[:,:,:n_values] pred_std = safe_exp(raw[:,:,n_values: 2*n_values]) - if in_N > 1: + if in_N > 1 and out_N > 0: weights = np.zeros((raw.shape[0], in_N, out_N), dtype=raw.dtype) for i in range(out_N): weights[:,:,i - out_N] = softmax(raw[:,:,i - out_N], axis=-1) @@ -73,35 +94,43 @@ class Parser: idxs = np.argsort(weights[fidx,:,hidx])[::-1] pred_mu_final[fidx, hidx] = pred_mu[fidx, idxs[0]] pred_std_final[fidx, hidx] = pred_std[fidx, idxs[0]] + elif in_N > 1 and out_N == 0: + # MHP without weights: keep every hypothesis intact, surface them as + # ``*_hypotheses`` and propagate the full multi-hypothesis tensor. + full_shape = tuple([raw.shape[0], in_N] + list(out_shape)) + outs[name + '_hypotheses'] = pred_mu.reshape(full_shape) + outs[name + '_stds_hypotheses'] = pred_std.reshape(full_shape) + pred_mu_final = pred_mu + pred_std_final = pred_std else: pred_mu_final = pred_mu pred_std_final = pred_std - if out_N > 1: - final_shape = tuple([raw.shape[0], out_N] + list(out_shape)) + if out_N > 1 or (in_N > 1 and out_N == 0): + n_selections = out_N if out_N > 1 else in_N + final_shape = tuple([raw.shape[0], n_selections] + list(out_shape)) else: final_shape = tuple([raw.shape[0],] + list(out_shape)) outs[name] = pred_mu_final.reshape(final_shape) outs[name + '_stds'] = pred_std_final.reshape(final_shape) def parse_outputs(self, outs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: - self.parse_mdn('plan', outs, in_N=ModelConstants.PLAN_MHP_N, out_N=ModelConstants.PLAN_MHP_SELECTION, - out_shape=(ModelConstants.IDX_N,ModelConstants.PLAN_WIDTH)) - self.parse_mdn('lane_lines', outs, in_N=0, out_N=0, out_shape=(ModelConstants.NUM_LANE_LINES,ModelConstants.IDX_N,ModelConstants.LANE_LINES_WIDTH)) - self.parse_mdn('road_edges', outs, in_N=0, out_N=0, out_shape=(ModelConstants.NUM_ROAD_EDGES,ModelConstants.IDX_N,ModelConstants.LANE_LINES_WIDTH)) - self.parse_mdn('pose', outs, in_N=0, out_N=0, out_shape=(ModelConstants.POSE_WIDTH,)) - self.parse_mdn('road_transform', outs, in_N=0, out_N=0, out_shape=(ModelConstants.POSE_WIDTH,)) + # supercombo (4955 / 102) and newer variants (e.g. 990 / 144). + self.parse_mdn('plan', outs, out_shape=(ModelConstants.IDX_N, ModelConstants.PLAN_WIDTH)) + self.parse_mdn('lane_lines', outs, out_shape=(ModelConstants.NUM_LANE_LINES, ModelConstants.IDX_N, ModelConstants.LANE_LINES_WIDTH)) + self.parse_mdn('road_edges', outs, out_shape=(ModelConstants.NUM_ROAD_EDGES, ModelConstants.IDX_N, ModelConstants.LANE_LINES_WIDTH)) + self.parse_mdn('pose', outs, out_shape=(ModelConstants.POSE_WIDTH,)) + self.parse_mdn('road_transform', outs, out_shape=(ModelConstants.POSE_WIDTH,)) if 'sim_pose' in outs: - self.parse_mdn('sim_pose', outs, in_N=0, out_N=0, out_shape=(ModelConstants.POSE_WIDTH,)) - self.parse_mdn('wide_from_device_euler', outs, in_N=0, out_N=0, out_shape=(ModelConstants.WIDE_FROM_DEVICE_WIDTH,)) - self.parse_mdn('lead', outs, in_N=ModelConstants.LEAD_MHP_N, out_N=ModelConstants.LEAD_MHP_SELECTION, - out_shape=(ModelConstants.LEAD_TRAJ_LEN,ModelConstants.LEAD_WIDTH)) + self.parse_mdn('sim_pose', outs, out_shape=(ModelConstants.POSE_WIDTH,)) + self.parse_mdn('wide_from_device_euler', outs, out_shape=(ModelConstants.WIDE_FROM_DEVICE_WIDTH,)) + self.parse_mdn('lead', outs, out_shape=(ModelConstants.LEAD_TRAJ_LEN, ModelConstants.LEAD_WIDTH)) if 'lat_planner_solution' in outs: - self.parse_mdn('lat_planner_solution', outs, in_N=0, out_N=0, out_shape=(ModelConstants.IDX_N,ModelConstants.LAT_PLANNER_SOLUTION_WIDTH)) + self.parse_mdn('lat_planner_solution', outs, out_shape=(ModelConstants.IDX_N, ModelConstants.LAT_PLANNER_SOLUTION_WIDTH)) if 'desired_curvature' in outs: - self.parse_mdn('desired_curvature', outs, in_N=0, out_N=0, out_shape=(ModelConstants.DESIRED_CURV_WIDTH,)) + self.parse_mdn('desired_curvature', outs, out_shape=(ModelConstants.DESIRED_CURV_WIDTH,)) for k in ['lead_prob', 'lane_lines_prob', 'meta']: self.parse_binary_crossentropy(k, outs) self.parse_categorical_crossentropy('desire_state', outs, out_shape=(ModelConstants.DESIRE_PRED_WIDTH,)) - self.parse_categorical_crossentropy('desire_pred', outs, out_shape=(ModelConstants.DESIRE_PRED_LEN,ModelConstants.DESIRE_PRED_WIDTH)) + self.parse_categorical_crossentropy('desire_pred', outs, out_shape=(ModelConstants.DESIRE_PRED_LEN, ModelConstants.DESIRE_PRED_WIDTH)) return outs diff --git a/openpilot/sunnypilot/modeld_v2/parse_model_outputs_split.py b/openpilot/sunnypilot/modeld_v2/parse_model_outputs_split.py index 831649e3c1..3db47aee42 100644 --- a/openpilot/sunnypilot/modeld_v2/parse_model_outputs_split.py +++ b/openpilot/sunnypilot/modeld_v2/parse_model_outputs_split.py @@ -65,6 +65,7 @@ class Parser: weights[fidx] = weights[fidx][idxs] pred_mu[fidx] = pred_mu[fidx][idxs] pred_std[fidx] = pred_std[fidx][idxs] + assert out_shape is not None full_shape = tuple([raw.shape[0], in_N] + list(out_shape)) outs[name + '_weights'] = weights outs[name + '_hypotheses'] = pred_mu.reshape(full_shape) @@ -82,8 +83,10 @@ class Parser: pred_std_final = pred_std if out_N > 1: + assert out_shape is not None final_shape = tuple([raw.shape[0], out_N] + list(out_shape)) else: + assert out_shape is not None final_shape = tuple([raw.shape[0],] + list(out_shape)) outs[name] = pred_mu_final.reshape(final_shape) outs[name + '_stds'] = pred_std_final.reshape(final_shape) @@ -120,7 +123,7 @@ class Parser: self.parse_categorical_crossentropy('desire_state', outs, out_shape=(SplitModelConstants.DESIRE_PRED_WIDTH,)) if 'lane_lines' in outs: self.parse_mdn('lane_lines', outs, in_N=0, out_N=0, - out_shape=(SplitModelConstants.NUM_LANE_LINES,SplitModelConstants.IDX_N,SplitModelConstants.LANE_LINES_WIDTH)) + out_shape=(SplitModelConstants.NUM_LANE_LINES,SplitModelConstants.IDX_N,SplitModelConstants.LANE_LINES_WIDTH)) if 'lane_lines_prob' in outs: self.parse_binary_crossentropy('lane_lines_prob', outs) if 'lead_prob' in outs: @@ -131,9 +134,11 @@ class Parser: self.parse_binary_crossentropy('meta', outs) if 'road_edges' in outs: self.parse_mdn('road_edges', outs, in_N=0, out_N=0, - out_shape=(SplitModelConstants.NUM_ROAD_EDGES,SplitModelConstants.IDX_N,SplitModelConstants.LANE_LINES_WIDTH)) + out_shape=(SplitModelConstants.NUM_ROAD_EDGES,SplitModelConstants.IDX_N,SplitModelConstants.LANE_LINES_WIDTH)) if 'sim_pose' in outs: self.parse_mdn('sim_pose', outs, in_N=0, out_N=0, out_shape=(SplitModelConstants.POSE_WIDTH,)) + if 'action' in outs: + self.parse_mdn('action', outs, in_N=0, out_N=0, out_shape=(SplitModelConstants.ACTION_WIDTH,)) def parse_vision_outputs(self, outs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: self.parse_mdn('pose', outs, in_N=0, out_N=0, out_shape=(SplitModelConstants.POSE_WIDTH,)) diff --git a/openpilot/sunnypilot/modeld_v2/tests/conftest.py b/openpilot/sunnypilot/modeld_v2/tests/helpers.py similarity index 95% rename from openpilot/sunnypilot/modeld_v2/tests/conftest.py rename to openpilot/sunnypilot/modeld_v2/tests/helpers.py index f79cbe10b2..82e159a305 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/conftest.py +++ b/openpilot/sunnypilot/modeld_v2/tests/helpers.py @@ -5,8 +5,8 @@ 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 pickle -import pytest +import pathlib +import tempfile import openpilot.sunnypilot.models.helpers as helpers import openpilot.sunnypilot.modeld_v2.modeld as modeld_module @@ -163,14 +163,16 @@ ARCHETYPES = { def make_pkl_data(archetype): return { 'metadata': archetype.metadata_structure, - (CAM_W, CAM_H): {'run_policy': _noop_jit, 'warp_enqueue': _noop_jit}, + 'run_policy': _noop_jit, + (CAM_W, CAM_H): _noop_jit, } def write_pkl(tmp_path, archetype): + from openpilot.selfdrive.modeld.helpers import dump_oob pkl_path = tmp_path / 'driving_test_tinygrad.pkl' with open(pkl_path, 'wb') as f: - pickle.dump(make_pkl_data(archetype), f) + dump_oob(make_pkl_data(archetype), f) return pkl_path @@ -181,16 +183,19 @@ def make_bundle(archetype): ) -@pytest.fixture +def tmp_path(): + with tempfile.TemporaryDirectory() as d: + yield pathlib.Path(d) + + def patch_modeld(monkeypatch): def _patch(bundle): - monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None: bundle, raising=False) - monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None: bundle, raising=False) + 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 -@pytest.fixture def model_state_factory(tmp_path, monkeypatch, patch_modeld): from openpilot.common.hardware import hw diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py b/openpilot/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py index f25bcd0a35..5398ac0ff1 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py @@ -9,6 +9,7 @@ import numpy as np from openpilot.common.transformations.camera import DEVICE_CAMERAS from openpilot.common.transformations.model import get_warp_matrix from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper +from openpilot.common.test import OpenpilotTestCase class MockStruct: @@ -20,7 +21,7 @@ class MockStruct: return getattr(self, item) -class TestCameraOffset: +class TestCameraOffset(OpenpilotTestCase): def setup_method(self): self.camera_offset = CameraOffsetHelper() self.dc = DEVICE_CAMERAS[('mici', 'os04c10')] @@ -30,12 +31,12 @@ class TestCameraOffset: sm = MockStruct( deviceState=MockStruct(deviceType='mici'), - roadCameraState=MockStruct(sensor='os04c10'), - liveCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22]) + narrowRoadCameraState=MockStruct(sensor='os04c10'), + extrinsicsCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22]) ) - intrinsics_main = self.dc.fcam.intrinsics - intrinsics_extra = self.dc.ecam.intrinsics + intrinsics_main = self.dc.narrow_road.intrinsics + intrinsics_extra = self.dc.wide_road.intrinsics device_from_calib_euler = np.array([0.0, 0.0, 0.0], dtype=np.float32) main_transform = get_warp_matrix(device_from_calib_euler, intrinsics_main, False).astype(np.float32) extra_transform = get_warp_matrix(device_from_calib_euler, intrinsics_extra, True).astype(np.float32) @@ -46,7 +47,7 @@ class TestCameraOffset: np.testing.assert_almost_equal(self.camera_offset.actual_camera_offset, 0.038) def test_camera_offset_(self): - intrinsics = self.dc.fcam.intrinsics + intrinsics = self.dc.narrow_road.intrinsics transform = np.eye(3, dtype=np.float32) height = 1.22 offset = 0.1 @@ -62,11 +63,11 @@ class TestCameraOffset: def test_update(self): sm = MockStruct( deviceState=MockStruct(deviceType='mici'), - roadCameraState=MockStruct(sensor='os04c10'), - liveCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22]) + narrowRoadCameraState=MockStruct(sensor='os04c10'), + extrinsicsCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22]) ) - intrinsics_main = self.dc.fcam.intrinsics - intrinsics_extra = self.dc.ecam.intrinsics + intrinsics_main = self.dc.narrow_road.intrinsics + intrinsics_extra = self.dc.wide_road.intrinsics device_from_calib_euler = np.array([0.0, 0.0, 0.0], dtype=np.float32) main_transform = get_warp_matrix(device_from_calib_euler, intrinsics_main, False).astype(np.float32) extra_transform = get_warp_matrix(device_from_calib_euler, intrinsics_extra, True).astype(np.float32) 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 b13a7abecf..7f40c19eb3 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py @@ -5,20 +5,27 @@ 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 pytest +from openpilot.common.parameterized import parameterized import openpilot.sunnypilot.models.helpers as helpers import openpilot.sunnypilot.modeld_v2.modeld as modeld_module from openpilot.sunnypilot.modeld_v2.modeld import _find_driving_pkl -from openpilot.sunnypilot.modeld_v2.tests.conftest import DummyModel, DummyBundle, ARCHETYPES, CAM_W, CAM_H, \ +from openpilot.sunnypilot.modeld_v2.tests import helpers as tests_helpers +from openpilot.sunnypilot.modeld_v2.tests.helpers import DummyModel, DummyBundle, ARCHETYPES, CAM_W, CAM_H, \ SPLIT_VISION_INPUT_SHAPES, SPLIT_POLICY_INPUT_SHAPES +from openpilot.common.test import OpenpilotTestCase + +# resolved by name from this module when a test asks for them +tmp_path = tests_helpers.tmp_path +patch_modeld = tests_helpers.patch_modeld +model_state_factory = tests_helpers.model_state_factory ModelState = modeld_module.ModelState # Pkl discovery -class TestFindDrivingPkl: +class TestFindDrivingPkl(OpenpilotTestCase): def test_returns_none_when_no_bundle(self): assert _find_driving_pkl(None) is None @@ -49,16 +56,16 @@ class TestFindDrivingPkl: # Init — assertion guard -class TestModelStateCombinedInit: +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, raising=False) - monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None: bundle, raising=False) - with pytest.raises(AssertionError, match="No driving pkl found"): + 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) -class TestStockEquivalence: +class TestStockEquivalence(OpenpilotTestCase): def test_split_queue_keys_match_stock(self, model_state_factory): from openpilot.selfdrive.modeld.compile_modeld import make_input_queues @@ -67,22 +74,12 @@ class TestStockEquivalence: state = model_state_factory(ARCHETYPES['vision_policy_split']) frame_skip = derive_frame_skip(SPLIT_VISION_INPUT_SHAPES, SPLIT_POLICY_INPUT_SHAPES) - # action_t is a deep-model prerequisite the SP loader doesn't provide yet; see skip_keys below 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') - # TODO-SP: remove action_t skip once SP adds prerequisite for deep models (action_t input queue) - # prev_feat is a stock QCOM corruption workaround handled inside the SP loader's JIT path - skip_keys = {'action_t', 'prev_feat'} - # stock packs the per-key policy inputs into packed_npy_inputs; the npy views carry the individual keys - stock_queue_keys = set(stock_queues.keys()) - if 'packed_npy_inputs' in stock_queue_keys: - stock_queue_keys.remove('packed_npy_inputs') - stock_queue_keys |= set(stock_npy.keys()) - assert set(state.input_queues.keys()) == stock_queue_keys - skip_keys, \ - f"Queue keys differ: v2={set(state.input_queues.keys())}, stock={stock_queue_keys}" - assert set(state.numpy_inputs.keys()) == set(stock_npy.keys()) - skip_keys, \ - f"Npy keys differ: v2={set(state.numpy_inputs.keys())}, stock={set(stock_npy.keys())}" + assert set(state.input_queues.keys()) == set(stock_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 @@ -110,8 +107,8 @@ class TestStockEquivalence: ARCHETYPE_NAMES = list(ARCHETYPES.keys()) -class TestModelTypeDetection: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestModelTypeDetection(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_combined_model_type(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -119,8 +116,8 @@ class TestModelTypeDetection: f"{arch.name}: got {state._combined_model_type}, expected {arch.expected_model_type}" -class TestConstantsSelection: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestConstantsSelection(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_constants_class(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -128,8 +125,8 @@ class TestConstantsSelection: f"{arch.name}: got {type(state.constants).__name__}, expected {arch.expected_constants_class.__name__}" -class TestParserSelection: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestParserSelection(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_parser_module(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -138,8 +135,8 @@ class TestParserSelection: f"{arch.name}: parser from {parser_module}, expected module ending with {arch.expected_parser_module}" -class TestDesireKeyDetection: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestDesireKeyDetection(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_desire_key(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -147,8 +144,8 @@ class TestDesireKeyDetection: f"{arch.name}: got {state.desire_key}, expected {arch.expected_desire_key}" -class TestVisionInputNames: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestVisionInputNames(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_vision_names_contain_img(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -157,14 +154,14 @@ class TestVisionInputNames: assert 'img' in name, f"{arch.name}: vision input name '{name}' missing 'img'" -class TestOutputSlices: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestOutputSlices(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_vision_slices_populated(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) assert len(state.vision_output_slices) > 0, f"{arch.name}: vision_output_slices empty" - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_policy_slices_match_type(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -174,14 +171,14 @@ class TestOutputSlices: assert len(state.policy_output_slices) > 0, f"{arch.name}: split/multi should have policy slices" -class TestInputQueueCreation: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestInputQueueCreation(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_queues_not_empty(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) assert len(state.input_queues) > 0, f"{arch.name}: input_queues empty" - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_npy_contains_transforms(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -190,7 +187,7 @@ class TestInputQueueCreation: assert state.numpy_inputs['tfm'].shape == (3, 3) assert state.numpy_inputs['big_tfm'].shape == (3, 3) - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_npy_contains_desire(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -198,8 +195,8 @@ class TestInputQueueCreation: f"{arch.name}: '{arch.expected_desire_key}' missing from npy" -class TestFrameBufferParams: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestFrameBufferParams(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_frame_buf_params_per_vision_input(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -209,29 +206,29 @@ class TestFrameBufferParams: assert len(nv12_info) >= 4, f"{arch.name}: nv12_info for '{name}' too short" -class TestBundleOverrides: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestBundleOverrides(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_smoothing_params_from_overrides(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) assert state.LAT_SMOOTH_SECONDS == 0.1 assert state.LONG_SMOOTH_SECONDS == 0.3 - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_generation_from_bundle(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) assert state.generation == 10 -class TestMlsimProperty: +class TestMlsimProperty(OpenpilotTestCase): def test_mlsim_false_for_gen10(self, model_state_factory): state = model_state_factory(ARCHETYPES['supercombo_non20hz']) assert state.mlsim is False def test_mlsim_true_for_gen11(self, tmp_path, monkeypatch, patch_modeld): from openpilot.common.hardware import hw - from openpilot.sunnypilot.modeld_v2.tests.conftest import write_pkl, ARCHETYPES as A + from openpilot.sunnypilot.modeld_v2.tests.helpers import write_pkl, ARCHETYPES as A arch = A['supercombo_non20hz'] write_pkl(tmp_path, arch) @@ -243,10 +240,10 @@ class TestMlsimProperty: assert state.mlsim is True -class TestCrossArchetypeMismatch: +class TestCrossArchetypeMismatch(OpenpilotTestCase): def test_wrong_is_20hz_changes_constants(self, tmp_path, monkeypatch, patch_modeld): from openpilot.common.hardware import hw - from openpilot.sunnypilot.modeld_v2.tests.conftest import write_pkl + from openpilot.sunnypilot.modeld_v2.tests.helpers import write_pkl from openpilot.sunnypilot.modeld_v2.constants import ModelConstants arch = ARCHETYPES['vision_policy_split'] diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py index f404678516..86974b14f1 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py @@ -5,13 +5,19 @@ 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 tempfile +from pathlib import Path + import numpy as np -import pytest +from openpilot.common.parameterized import parameterized -from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, _detect_desire_key +from openpilot.common.file_chunker import chunk_file, get_chunk_targets +from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, _detect_desire_key, read_file_chunked_to_disk +from openpilot.common.test import OpenpilotTestCase -class TestDeriveFrameSkip: +class TestDeriveFrameSkip(OpenpilotTestCase): def test_non20hz_supercombo(self): vision = {} policy = {'features_buffer': (1, 99, 512), 'desire': (1, 100, 8)} @@ -31,21 +37,21 @@ class TestDeriveFrameSkip: assert derive_frame_skip({}, {}) == 1 -class TestFrameSkipBufferLengthEquivalence: - @pytest.mark.parametrize("frame_skip,expected_buffer_length", [ +class TestFrameSkipBufferLengthEquivalence(OpenpilotTestCase): + @parameterized.expand([ (1, 2), (4, 5), - ]) + ], names=["frame_skip", "expected_buffer_length"]) def test_img_buffer_size_matches_warp_buffer_length(self, frame_skip, expected_buffer_length): n_frames = 2 img_buf_dim0 = frame_skip * (n_frames - 1) + 1 assert img_buf_dim0 == expected_buffer_length, \ f"frame_skip={frame_skip}: img_buf[0]={img_buf_dim0}, expected {expected_buffer_length}" - @pytest.mark.parametrize("is_20hz,expected_frame_skip,expected_buffer_length", [ + @parameterized.expand([ (False, 1, 2), (True, 4, 5), - ]) + ], names=["is_20hz", "expected_frame_skip", "expected_buffer_length"]) def test_is_20hz_to_frame_skip_to_buffer_length(self, is_20hz, expected_frame_skip, expected_buffer_length): if is_20hz: policy_shapes = {'features_buffer': (1, 24, 512)} @@ -59,9 +65,9 @@ class TestFrameSkipBufferLengthEquivalence: assert img_buf_dim0 == expected_buffer_length -class TestTemporalSamplingEquivalence: +class TestTemporalSamplingEquivalence(OpenpilotTestCase): def test_non20hz_desire_sampling_identity(self): - buf = np.random.randn(100, 1, 8).astype(np.float32) + buf = np.random.default_rng(0).standard_normal((100, 1, 8)).astype(np.float32) frame_skip = 1 sampled = buf[::frame_skip].reshape(-1, 8) assert sampled.shape == (100, 8) @@ -94,12 +100,12 @@ class TestTemporalSamplingEquivalence: np.testing.assert_array_equal(sampled, buf[:, 0, :]) -class TestTemporalIdxEquivalence: - @pytest.mark.parametrize("mode,desire_shape,fb_shape,frame_skip", [ +class TestTemporalIdxEquivalence(OpenpilotTestCase): + @parameterized.expand([ ('non20hz', (1, 100, 8), (1, 99, 512), 1), ('20hz', (1, 25, 8), (1, 24, 512), 4), ('split', (1, 25, 8), (1, 25, 512), 4), - ]) + ], names=["mode", "desire_shape", "fb_shape", "frame_skip"]) def test_features_buffer_idx_equivalence(self, mode, desire_shape, fb_shape, frame_skip): history = fb_shape[1] @@ -118,11 +124,11 @@ class TestTemporalIdxEquivalence: assert len(modelstate_idxs) == fb_shape[1], \ f"{mode}: ModelState idx count {len(modelstate_idxs)} != input shape {fb_shape[1]}" - @pytest.mark.parametrize("mode,desire_shape,fb_shape,frame_skip", [ + @parameterized.expand([ ('non20hz', (1, 100, 8), (1, 99, 512), 1), ('20hz', (1, 25, 8), (1, 24, 512), 4), ('split', (1, 25, 8), (1, 25, 512), 4), - ]) + ], names=["mode", "desire_shape", "fb_shape", "frame_skip"]) def test_desire_idx_equivalence(self, mode, desire_shape, fb_shape, frame_skip): history = desire_shape[1] @@ -132,7 +138,7 @@ class TestTemporalIdxEquivalence: f"{mode}: compile desire samples {compile_sampled_count} != model input {history}" -class TestDetectDesireKey: +class TestDetectDesireKey(OpenpilotTestCase): def test_finds_desire(self): shapes = {'features_buffer': (1, 99, 512), 'desire': (1, 100, 8), 'traffic_convention': (1, 2)} assert _detect_desire_key(shapes) == 'desire' @@ -146,11 +152,11 @@ class TestDetectDesireKey: assert _detect_desire_key(shapes) is None -class TestOutputSlicePreservation: +class TestOutputSlicePreservation(OpenpilotTestCase): def test_vision_hidden_state_slice_used_for_features(self): mock_slices = {'hidden_state': slice(0, 512), 'plan': slice(512, 1024)} features_slice = mock_slices['hidden_state'] - fake_output = np.random.randn(1, 1024).astype(np.float32) + fake_output = np.random.default_rng(0).standard_normal((1, 1024)).astype(np.float32) features = fake_output[:, features_slice] assert features.shape == (1, 512) @@ -159,3 +165,115 @@ class TestOutputSlicePreservation: policy_slices = {'plan': slice(0, 495), 'meta': slice(495, 550)} assert set(vision_slices.keys()) & set(policy_slices.keys()) == set(), \ "vision and policy slices should not overlap in keys" + + +class TestReadFileChunkedToDisk(OpenpilotTestCase): + def test_none_passthrough(self): + assert read_file_chunked_to_disk(None) is None + + def test_unchunked_source_staged_on_disk(self): + with tempfile.TemporaryDirectory() as d: + src = Path(d) / "driving_supercombo.onnx" + payload = os.urandom(1024) + src.write_bytes(payload) + + out = Path(read_file_chunked_to_disk(str(src))) + + assert out.parent == Path(d) + assert out.name == "driving_supercombo.onnx.unchunked" + assert out.read_bytes() == payload + + def test_chunked_source_reassembled_on_disk(self): + with tempfile.TemporaryDirectory() as d: + src = Path(d) / "driving_supercombo.onnx" + payload = os.urandom(4096) + src.write_bytes(payload) + chunk_file(str(src), get_chunk_targets(str(src), len(payload))) + assert not src.exists() + + out = Path(read_file_chunked_to_disk(str(src))) + + assert out.parent == Path(d) + assert out.read_bytes() == payload + + +class Test4DFeaturesBuffer(OpenpilotTestCase): + def test_get_policy_npy_shapes_4d(self): + from openpilot.sunnypilot.modeld_v2.compile_modeld import get_policy_npy_shapes + input_shapes = { + 'desire_pulse': (1, 25, 8), + 'features_buffer': (1, 24, 32, 512), # compare 4d to 3d for regression + 'traffic_convention': (1, 2), + 'action_t': (1, 2) + } + shapes, sizes = get_policy_npy_shapes(input_shapes, is_supercombo=True) + assert shapes['prev_feat'] == (1, 16384) + assert sizes == [8, 2, 2, 16384] + + def test_get_policy_npy_shapes_3d(self): + from openpilot.sunnypilot.modeld_v2.compile_modeld import get_policy_npy_shapes + input_shapes = { + 'desire_pulse': (1, 25, 8), + 'features_buffer': (1, 24, 512), + 'traffic_convention': (1, 2), + 'action_t': (1, 2) + } + shapes, sizes = get_policy_npy_shapes(input_shapes, is_supercombo=True) + assert shapes['prev_feat'] == (1, 512) + assert sizes == [8, 2, 2, 512] + + +class TestStockCompileModeldEquivalence(OpenpilotTestCase): + def test_get_policy_npy_shapes_matches_stock(self): + from openpilot.selfdrive.modeld.compile_modeld import get_policy_npy_shapes as stock_get_policy_npy_shapes + from openpilot.sunnypilot.modeld_v2.compile_modeld import get_policy_npy_shapes as sunny_get_policy_npy_shapes + + stock_input_shapes = { + 'desire_pulse': (1, 25, 8), + 'features_buffer': (1, 24, 512), # see below comment + 'traffic_convention': (1, 2), + 'action_t': (1, 2), + } + + stock_shapes, stock_sizes = stock_get_policy_npy_shapes(stock_input_shapes) + sunny_shapes, sunny_sizes = sunny_get_policy_npy_shapes(stock_input_shapes, is_supercombo=True) + + assert sunny_shapes == stock_shapes + assert sunny_sizes == stock_sizes + assert sunny_shapes['prev_feat'] == (1, 512) + + def test_make_input_queues_full_stock_equivalence(self): + from openpilot.selfdrive.modeld.compile_modeld import make_input_queues as stock_make_input_queues + from openpilot.sunnypilot.modeld_v2.compile_modeld import make_supercombo_input_queues as sunny_make_supercombo_input_queues + input_shapes = { + 'img': (1, 12, 128, 256), + 'desire_pulse': (1, 25, 8), + 'features_buffer': (1, 24, 512), # when https://github.com/commaai/openpilot/pull/38681 merges, update to 1,24,32,512 + 'traffic_convention': (1, 2), + 'action_t': (1, 2), + } + frame_skip = 4 + + stock_queues, stock_npy = stock_make_input_queues(input_shapes, frame_skip, device='NPY') + sunny_queues, sunny_npy = sunny_make_supercombo_input_queues(input_shapes, frame_skip, device='NPY') + assert set(sunny_queues.keys()) == set(stock_queues.keys()) + for key in stock_queues: + assert sunny_queues[key].shape == stock_queues[key].shape, \ + f"Queue shape mismatch for {key}: sunny {sunny_queues[key].shape} != stock {stock_queues[key].shape}" + assert set(sunny_npy.keys()) == set(stock_npy.keys()) + for key in stock_npy: + assert sunny_npy[key].shape == stock_npy[key].shape, \ + f"Numpy array shape mismatch for {key}: sunny {sunny_npy[key].shape} != stock {stock_npy[key].shape}" + + def test_make_warp_queues_stock_equivalence(self): + from openpilot.selfdrive.modeld.compile_modeld import make_warp_input_queues as stock_make_warp_queues + from openpilot.sunnypilot.modeld_v2.compile_modeld import make_warp_queues as sunny_make_warp_queues + stock_vision_shapes = {'img': (1, 12, 128, 256)} # for now? + stock_queues, stock_npy = stock_make_warp_queues(stock_vision_shapes, frame_skip=4, device='NPY') + sunny_queues, sunny_npy = sunny_make_warp_queues(device='NPY') + + assert set(sunny_npy.keys()) == set(stock_npy.keys()) == {'tfm', 'big_tfm'} + for key in sunny_npy: + assert sunny_npy[key].shape == stock_npy[key].shape == (3, 3) + + diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_recovery_power.py b/openpilot/sunnypilot/modeld_v2/tests/test_recovery_power.py index ac716a5981..fb72022fa5 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_recovery_power.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_recovery_power.py @@ -1,3 +1,5 @@ +from typing import Any + import numpy as np from openpilot.cereal import log @@ -5,6 +7,7 @@ from openpilot.cereal import log from openpilot.sunnypilot.modeld_v2.constants import Plan from openpilot.sunnypilot.modeld_v2.modeld import ModelState import openpilot.sunnypilot.modeld_v2.modeld as modeld +from openpilot.common.test import OpenpilotTestCase class MockStruct: @@ -13,58 +16,59 @@ class MockStruct: setattr(self, k, v) -def test_recovery_power_scaling(): - state = MockStruct( - PLANPLUS_CONTROL=0.75, - LONG_SMOOTH_SECONDS=0.3, - LAT_SMOOTH_SECONDS=0.1, - MIN_LAT_CONTROL_SPEED=0.3, - mlsim=True, - generation=12, - constants=MockStruct(T_IDXS=np.arange(100), DESIRE_LEN=8) - ) - prev_action = log.ModelDataV2.Action() - recorded_vel: list = [] - recorded_curv_plans: list = [] +class TestRecoveryPower(OpenpilotTestCase): + def test_recovery_power_scaling(self): + state: Any = MockStruct( + PLANPLUS_CONTROL=0.75, + LONG_SMOOTH_SECONDS=0.3, + LAT_SMOOTH_SECONDS=0.1, + MIN_LAT_CONTROL_SPEED=0.3, + mlsim=True, + generation=12, + constants=MockStruct(T_IDXS=np.arange(100), DESIRE_LEN=8) + ) + prev_action = log.ModelDataV2.Action() + recorded_vel: list = [] + recorded_curv_plans: list = [] - def mock_accel(plan_vel, plan_accel, t_idxs, action_t=0.0): - recorded_vel.append(plan_vel.copy()) - return 0.0, False + def mock_accel(plan_vel, plan_accel, t_idxs, action_t=0.0): + recorded_vel.append(plan_vel.copy()) + return 0.0 - def mock_curvature(output, plan, vego, lat_action_t, mlsim): - recorded_curv_plans.append(plan.copy()) - return 0.0 + def mock_curvature(output, plan, vego, lat_action_t, mlsim): + recorded_curv_plans.append(plan.copy()) + return 0.0 - modeld.get_accel_from_plan = mock_accel - modeld.get_curvature_from_output = mock_curvature - plan = np.random.rand(1, 100, 15).astype(np.float32) - planplus = np.random.rand(1, 100, 15).astype(np.float32) - merged_plan = plan + planplus + modeld.get_accel_from_plan = mock_accel # ty: ignore[invalid-assignment] + modeld.get_curvature_from_output = mock_curvature # ty: ignore[invalid-assignment] + plan = np.random.default_rng(0).random((1, 100, 15)).astype(np.float32) + planplus = np.random.default_rng(1).random((1, 100, 15)).astype(np.float32) + merged_plan = plan + planplus - model_output: dict = { - 'plan': merged_plan.copy(), - 'planplus': planplus.copy() - } + model_output: dict = { + 'plan': merged_plan.copy(), + 'planplus': planplus.copy() + } - test_cases: list = [ - # (control, v_ego) - (0.55, 20.0), - (1.0, 25.0), - (1.5, 25.1), - (2.0, 20.0), - (0.75, 19.0), - (0.8, 25.1), - ] + test_cases: list = [ + # (control, v_ego) + (0.55, 20.0), + (1.0, 25.0), + (1.5, 25.1), + (2.0, 20.0), + (0.75, 19.0), + (0.8, 25.1), + ] - for control, v_ego in test_cases: - state.PLANPLUS_CONTROL = control - recorded_vel.clear() - recorded_curv_plans.clear() - ModelState.get_action_from_model(state, model_output, prev_action, 0.0, 0.0, v_ego) + for control, v_ego in test_cases: + state.PLANPLUS_CONTROL = control + recorded_vel.clear() + recorded_curv_plans.clear() + ModelState.get_action_from_model(state, model_output, prev_action, 0.0, 0.0, v_ego) # type: ignore[arg-type] - expected_accel_plan_vel = plan[0, :, Plan.VELOCITY][:, 0] + planplus[0, :, Plan.VELOCITY][:, 0] - np.testing.assert_allclose(recorded_vel[0], expected_accel_plan_vel, rtol=1e-5, atol=1e-6) + expected_accel_plan_vel = plan[0, :, Plan.VELOCITY][:, 0] + planplus[0, :, Plan.VELOCITY][:, 0] + np.testing.assert_allclose(recorded_vel[0], expected_accel_plan_vel, rtol=1e-5, atol=1e-6) - # For the below, yes, I know this isn't the same slicing as fillmodlmsg. This is to show that the values are only scaled on curv - expected_curv_plan_vel = plan[0, :, Plan.VELOCITY][:, 0] + control * planplus[0, :, Plan.VELOCITY][:, 0] - np.testing.assert_allclose(recorded_curv_plans[0][:, Plan.VELOCITY][:, 0], expected_curv_plan_vel, rtol=1e-5, atol=1e-6) + # For the below, yes, I know this isn't the same slicing as fillmodlmsg. This is to show that the values are only scaled on curv + expected_curv_plan_vel = plan[0, :, Plan.VELOCITY][:, 0] + control * planplus[0, :, Plan.VELOCITY][:, 0] + np.testing.assert_allclose(recorded_curv_plans[0][:, Plan.VELOCITY][:, 0], expected_curv_plan_vel, rtol=1e-5, atol=1e-6) diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_warp.py b/openpilot/sunnypilot/modeld_v2/tests/test_warp.py deleted file mode 100644 index 49dc634a4d..0000000000 --- a/openpilot/sunnypilot/modeld_v2/tests/test_warp.py +++ /dev/null @@ -1,103 +0,0 @@ -import os -os.environ['DEV'] = 'CPU' -import pytest -import numpy as np -from openpilot.system.camerad.cameras.nv12_info import get_nv12_info -from openpilot.sunnypilot.modeld_v2.warp import CAMERA_CONFIGS -from openpilot.sunnypilot.modeld_v2.warp import Warp, MODEL_W, MODEL_H - -VISION_NAME_PAIRS = [ # needed to account for supercombos input_imgs - ('img', 'big_img'), - ('input_imgs', 'big_input_imgs'), -] - - -class MockVisionBuf: - def __init__(self, w, h): - self.width = w - self.height = h - _, _, _, yuv_size = get_nv12_info(w, h) - self.data = np.zeros(yuv_size, dtype=np.uint8) - - -@pytest.mark.parametrize("buffer_length", [2, 5]) -def test_warp_initialization(buffer_length): - warp = Warp(buffer_length) - assert warp.buffer_length == buffer_length - assert warp.img_buffer_shape == (buffer_length * 6, MODEL_H // 2, MODEL_W // 2) - - -@pytest.mark.parametrize("buffer_length", [2, 5]) -@pytest.mark.parametrize("cam_w, cam_h", CAMERA_CONFIGS) -@pytest.mark.parametrize("road, wide", VISION_NAME_PAIRS) -def test_warp_process(buffer_length, cam_w, cam_h, road, wide): - warp = Warp(buffer_length) - mock_buf = MockVisionBuf(cam_w, cam_h) - transform = np.eye(3, dtype=np.float32).flatten() - bufs = {road: mock_buf, wide: mock_buf} - transforms = {road: transform, wide: transform} - - out = warp.process(bufs, transforms) - assert isinstance(out, dict) - assert road in out and wide in out - assert out[road].shape == (1, 12, MODEL_H // 2, MODEL_W // 2) - assert out[wide].shape == (1, 12, MODEL_H // 2, MODEL_W // 2) - - key = (cam_w, cam_h) - assert key in warp.jit_cache - - out2 = warp.process(bufs, transforms) - assert out2[road].shape == out[road].shape - - -@pytest.mark.parametrize("road, wide", VISION_NAME_PAIRS) -def test_warp_buffer_shift(road, wide): - warp = Warp(2) - cam_w, cam_h = CAMERA_CONFIGS[1] - transform = np.eye(3, dtype=np.float32).flatten() - - buf1 = MockVisionBuf(cam_w, cam_h) - buf1.data[0] = 255 - bufs1 = {road: buf1, wide: buf1} - transforms = {road: transform, wide: transform} - out1 = warp.process(bufs1, transforms) - road1 = out1[road].numpy().copy() - - buf2 = MockVisionBuf(cam_w, cam_h) - buf2.data[0] = 128 - bufs2 = {road: buf2, wide: buf2} - out2 = warp.process(bufs2, transforms) - assert not np.array_equal(road1, out2[road].numpy()) - - -@pytest.mark.parametrize("buffer_length", [2, 5]) -@pytest.mark.parametrize("road, wide", VISION_NAME_PAIRS) -def test_warp_buffer_accumulation(buffer_length, road, wide): - warp = Warp(buffer_length) - cam_w, cam_h = CAMERA_CONFIGS[0] - transform = np.eye(3, dtype=np.float32).flatten() - transforms = {road: transform, wide: transform} - outputs = [] - - for i in range(buffer_length + 1): - buf = MockVisionBuf(cam_w, cam_h) - buf.data[:] = i * 10 - out = warp.process({road: buf, wide: buf}, transforms) - outputs.append(out[road].numpy().copy()) - - assert warp.full_buffers['img'].shape == (buffer_length * 6, MODEL_H // 2, MODEL_W // 2) - for i in range(1, len(outputs)): - assert not np.array_equal(outputs[i - 1], outputs[i]) - - -def test_warp_different_cameras_same_instance(): - warp = Warp(2) - transform = np.eye(3, dtype=np.float32).flatten() - - buf1 = MockVisionBuf(*CAMERA_CONFIGS[0]) - warp.process({'img': buf1, 'big_img': buf1}, {'img': transform, 'big_img': transform}) - assert len(warp.jit_cache) == 1 - - buf2 = MockVisionBuf(*CAMERA_CONFIGS[1]) - warp.process({'img': buf2, 'big_img': buf2}, {'img': transform, 'big_img': transform}) - assert len(warp.jit_cache) == 2 diff --git a/openpilot/sunnypilot/modeld_v2/warp.py b/openpilot/sunnypilot/modeld_v2/warp.py deleted file mode 100644 index f91e456c00..0000000000 --- a/openpilot/sunnypilot/modeld_v2/warp.py +++ /dev/null @@ -1,171 +0,0 @@ -import pickle -import time -import numpy as np -from pathlib import Path -from tinygrad.tensor import Tensor -from tinygrad.engine.jit import TinyJit -from tinygrad.device import Device - -from openpilot.system.camerad.cameras.nv12_info import get_nv12_info -from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE -from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye -from openpilot.selfdrive.modeld.compile_modeld import NV12Frame, make_frame_prepare as _make_frame_prepare - -CAMERA_CONFIGS = [ - (_ar_ox_fisheye.width, _ar_ox_fisheye.height), - (_os_fisheye.width, _os_fisheye.height), -] - - -def make_frame_prepare(cam_w, cam_h, model_w, model_h): - nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - return _make_frame_prepare(nv12, model_w, model_h) - - -def warp_pkl_path(w, h): - from openpilot.selfdrive.modeld.helpers import MODELS_DIR - return MODELS_DIR / f'warp_{w}x{h}_tinygrad.pkl' - - -def make_update_img_input(frame_prepare, model_w, model_h): - def update_img_input_tinygrad(tensor, frame, M_inv): - M_inv = M_inv.to(Device.DEFAULT) - new_img = frame_prepare(frame, M_inv) - tensor.assign(tensor[6:].cat(new_img, dim=0).contiguous()) - return Tensor.cat(tensor[:6], tensor[-6:], dim=0).contiguous().reshape(1, 12, model_h//2, model_w//2) - return update_img_input_tinygrad - - -def make_update_both_imgs(frame_prepare, model_w, model_h): - update_img = make_update_img_input(frame_prepare, model_w, model_h) - def update_both_imgs_tinygrad(calib_img_buffer, new_img, M_inv, - calib_big_img_buffer, new_big_img, M_inv_big): - calib_img_pair = update_img(calib_img_buffer, new_img, M_inv) - calib_big_img_pair = update_img(calib_big_img_buffer, new_big_img, M_inv_big) - return calib_img_pair, calib_big_img_pair - return update_both_imgs_tinygrad - -MODELS_DIR = Path(__file__).parent / 'models' -MODEL_W, MODEL_H = MEDMODEL_INPUT_SIZE -UPSTREAM_BUFFER_LENGTH = 5 - - -def v2_warp_pkl_path(cam_w, cam_h, buffer_length): - return MODELS_DIR / f'warp_{cam_w}x{cam_h}_b{buffer_length}_tinygrad.pkl' - - -def compile_v2_warp(cam_w, cam_h, buffer_length): - _, _, _, yuv_size = get_nv12_info(cam_w, cam_h) - img_buffer_shape = (buffer_length * 6, MODEL_H // 2, MODEL_W // 2) - - print(f"Compiling v2 warp for {cam_w}x{cam_h} buffer_length={buffer_length}...") - - frame_prepare = make_frame_prepare(cam_w, cam_h, MODEL_W, MODEL_H) - update_both_imgs = make_update_both_imgs(frame_prepare, MODEL_W, MODEL_H) - update_img_jit = TinyJit(update_both_imgs, prune=True) - - full_buffer = Tensor.zeros(img_buffer_shape, dtype='uint8').contiguous().realize() - big_full_buffer = Tensor.zeros(img_buffer_shape, dtype='uint8').contiguous().realize() - new_frame_np = np.random.randint(0, 256, yuv_size, dtype=np.uint8) - new_big_frame_np = np.random.randint(0, 256, yuv_size, dtype=np.uint8) - for i in range(10): - img_inputs = [full_buffer, - Tensor.from_blob(new_frame_np.ctypes.data, (yuv_size,), dtype='uint8').realize(), - Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY')] - big_img_inputs = [big_full_buffer, - Tensor.from_blob(new_big_frame_np.ctypes.data, (yuv_size,), dtype='uint8').realize(), - Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY')] - inputs = img_inputs + big_img_inputs - Device.default.synchronize() - - st = time.perf_counter() - _ = update_img_jit(*inputs) - mt = time.perf_counter() - Device.default.synchronize() - et = time.perf_counter() - print(f" [{i+1}/10] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") - - pkl_path = v2_warp_pkl_path(cam_w, cam_h, buffer_length) - with open(pkl_path, "wb") as f: - pickle.dump(update_img_jit, f) - print(f" Saved to {pkl_path}") - - jit = pickle.load(open(pkl_path, "rb")) - verify_frame = np.random.randint(0, 256, yuv_size, dtype=np.uint8) - verify_big_frame = np.random.randint(0, 256, yuv_size, dtype=np.uint8) - fresh_inputs = [ - Tensor.zeros(img_buffer_shape, dtype='uint8').contiguous().realize(), - Tensor.from_blob(verify_frame.ctypes.data, (yuv_size,), dtype='uint8').realize(), - Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY'), - Tensor.zeros(img_buffer_shape, dtype='uint8').contiguous().realize(), - Tensor.from_blob(verify_big_frame.ctypes.data, (yuv_size,), dtype='uint8').realize(), - Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY'), - ] - jit(*fresh_inputs) - - -class Warp: - def __init__(self, buffer_length=2): - self.buffer_length = buffer_length - self.img_buffer_shape = (buffer_length * 6, MODEL_H // 2, MODEL_W // 2) - - self.jit_cache = {} - self.full_buffers = {k: Tensor.zeros(self.img_buffer_shape, dtype='uint8').contiguous().realize() for k in ['img', 'big_img']} - self._blob_cache: dict[int, Tensor] = {} - self._nv12_cache: dict[tuple[int, int], int] = {} - self.transforms_np = {k: np.zeros((3, 3), dtype=np.float32) for k in ['img', 'big_img']} - self.transforms = {k: Tensor(v, device='NPY').realize() for k, v in self.transforms_np.items()} - - def process(self, bufs, transforms): - if not bufs: - return {} - road = next(n for n in bufs if 'big' not in n) - wide = next(n for n in bufs if 'big' in n) - cam_w, cam_h = bufs[road].width, bufs[road].height - key = (cam_w, cam_h) - - if key not in self.jit_cache: - v2_pkl = v2_warp_pkl_path(cam_w, cam_h, self.buffer_length) - if v2_pkl.exists(): - with open(v2_pkl, 'rb') as f: - self.jit_cache[key] = pickle.load(f) - elif self.buffer_length == UPSTREAM_BUFFER_LENGTH: - upstream_pkl = warp_pkl_path(cam_w, cam_h) - if upstream_pkl.exists(): - with open(upstream_pkl, 'rb') as f: - self.jit_cache[key] = pickle.load(f) - if key not in self.jit_cache: - frame_prepare = make_frame_prepare(cam_w, cam_h, MODEL_W, MODEL_H) - update_both_imgs = make_update_both_imgs(frame_prepare, MODEL_W, MODEL_H) - self.jit_cache[key] = TinyJit(update_both_imgs, prune=True) - - if key not in self._nv12_cache: - self._nv12_cache[key] = get_nv12_info(cam_w, cam_h)[3] - yuv_size = self._nv12_cache[key] - - road_ptr = bufs[road].data.ctypes.data - wide_ptr = bufs[wide].data.ctypes.data - if road_ptr not in self._blob_cache: - self._blob_cache[road_ptr] = Tensor.from_blob(road_ptr, (yuv_size,), dtype='uint8') - if wide_ptr not in self._blob_cache: - self._blob_cache[wide_ptr] = Tensor.from_blob(wide_ptr, (yuv_size,), dtype='uint8') - road_blob = self._blob_cache[road_ptr] - wide_blob = self._blob_cache[wide_ptr] if wide_ptr != road_ptr else Tensor.from_blob(wide_ptr, (yuv_size,), dtype='uint8') - np.copyto(self.transforms_np['img'], transforms[road].reshape(3, 3)) - np.copyto(self.transforms_np['big_img'], transforms[wide].reshape(3, 3)) - - Device.default.synchronize() - res = self.jit_cache[key]( - self.full_buffers['img'], road_blob, self.transforms['img'], - self.full_buffers['big_img'], wide_blob, self.transforms['big_img'], - ) - out_road = res[0].realize() - out_wide = res[1].realize() - - return {road: out_road, wide: out_wide} - - -if __name__ == "__main__": - for cam_w, cam_h in CAMERA_CONFIGS: - for bl in [2, 5]: - compile_v2_warp(cam_w, cam_h, bl) diff --git a/openpilot/sunnypilot/models/default_model.py b/openpilot/sunnypilot/models/default_model.py index 62b6831402..69f08c2cd3 100755 --- a/openpilot/sunnypilot/models/default_model.py +++ b/openpilot/sunnypilot/models/default_model.py @@ -3,8 +3,17 @@ import os import hashlib 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 +from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL, DEFAULT_BIG_MODEL + + +def get_default_model() -> str: + 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") @@ -13,7 +22,6 @@ SUPERCOMBO_ONNX_PATH = os.path.join(BASEDIR, "openpilot", "selfdrive", "modeld", def update_model_hash(): supercombo_hash = get_file_hash(SUPERCOMBO_ONNX_PATH) - combined_hash = hashlib.sha256(supercombo_hash.encode()).hexdigest() with open(MODEL_HASH_PATH, "w") as f: @@ -22,40 +30,28 @@ def update_model_hash(): print(f"Generated and updated new combined model hash to {MODEL_HASH_PATH}") -def get_current_default_model_name(): - print("[GET DEFAULT MODEL NAME]") - name = DEFAULT_MODEL - print(f'Current default model name: "{name}"') - - return name - - -def update_default_model_name(name: str): - print("[CHANGE DEFAULT MODEL NAME]") +def update_default_model_names(default_model_name: str, default_big_model_name: str): + print("[CHANGE DEFAULT MODEL NAMES]") with open(DEFAULT_MODEL_NAME_PATH, "w") as f: - f.write(f'DEFAULT_MODEL = "{name}"\n') - print(f'New default model name: "{name}"') + f.write(f'DEFAULT_MODEL = "{default_model_name}"\n') + f.write(f'DEFAULT_BIG_MODEL = "{default_big_model_name}"\n') + + print(f'New default small model name: "{default_model_name}"') + print(f'New default big model name: "{default_big_model_name}"') print("[DONE]") if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Update default model name and hash") - parser.add_argument("--new_name", type=str, help="New default model name") + parser = argparse.ArgumentParser(description="Update default model names and hash") + parser.add_argument("--new_small_model_name", type=str, help="New default small model name") + parser.add_argument("--new_big_model_name", type=str, help="New default big model name") args = parser.parse_args() - if not args.new_name: - print("Warning: No new default model name provided. Use --new_name to specify") - print("Default model name and hash will not be updated! (aborted)") - exit(0) + if args.new_small_model_name is None and args.new_big_model_name is None: + new_name = input(f'Enter new default small model name (current: "{DEFAULT_MODEL}", leave empty to keep): ').strip() + new_big_model_name = input(f'Enter new default big model name (current: "{DEFAULT_BIG_MODEL}", leave empty to keep): ').strip() + else: + new_name, new_big_model_name = args.new_small_model_name, args.new_big_model_name - current_name = get_current_default_model_name() - new_name = args.new_name - if current_name == new_name: - print(f'Proposed default model name: "{new_name}"') - confirm = input("Proposed default model name is the same as the current default model name. Confirm? (y/n): ").upper().strip() - if confirm != "Y": - print("Default model name and hash will not be updated! (aborted)") - exit(0) - - update_default_model_name(new_name) + update_default_model_names(new_name or DEFAULT_MODEL, new_big_model_name or DEFAULT_BIG_MODEL) update_model_hash() diff --git a/openpilot/sunnypilot/models/fetcher.py b/openpilot/sunnypilot/models/fetcher.py index b5197988bb..d0115be045 100644 --- a/openpilot/sunnypilot/models/fetcher.py +++ b/openpilot/sunnypilot/models/fetcher.py @@ -6,13 +6,13 @@ See the LICENSE.md file in the root directory for more details. """ import time - +import os import requests from requests.exceptions import (SSLError, RequestException, HTTPError) 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.cereal import custom @@ -26,11 +26,35 @@ class ModelParser: download_uri.sha256 = download_uri_data.get("sha256") return download_uri + @staticmethod + def _parse_chunk(chunk_data) -> custom.ModelManagerSP.Chunk: + chunk = custom.ModelManagerSP.Chunk() + chunk.fileName = chunk_data.get("file_name") + chunk.sha256 = chunk_data.get("sha256") + return chunk + @staticmethod def _parse_artifact(artifact_data) -> custom.ModelManagerSP.Artifact: artifact = custom.ModelManagerSP.Artifact() artifact.fileName = artifact_data.get("file_name") artifact.downloadUri = ModelParser._parse_download_uri(artifact_data.get("download_uri", {})) + + if "chunks" in artifact_data: + artifact.chunks = [ModelParser._parse_chunk(chunk_data) for chunk_data in artifact_data["chunks"]] + + try: + model_dir = Paths.model_root() + os.makedirs(model_dir, exist_ok=True) + manifest_path = os.path.join(model_dir, f"{artifact.fileName}.chunkmanifest") + num_chunks = str(len(artifact.chunks)) + + if not os.path.exists(manifest_path) or open(manifest_path).read().strip() != num_chunks: + with open(manifest_path, "w") as f: + f.write(num_chunks) + cloudlog.info(f"Wrote chunk manifest for {artifact.fileName}: {num_chunks} chunks") + except Exception as e: + cloudlog.warning(f"Failed to write chunk manifest for {artifact.fileName}: {e}") + return artifact @staticmethod @@ -39,8 +63,6 @@ class ModelParser: model.type = model_data.get("type") model.artifact = ModelParser._parse_artifact(model_data.get("artifact", {})) - if metadata := model_data.get("metadata"): - model.metadata = ModelParser._parse_artifact(metadata) return model @staticmethod @@ -80,11 +102,11 @@ class ModelParser: class ModelCache: """Handles caching of model data to avoid frequent remote fetches""" - def __init__(self, params: Params, cache_timeout: int = int(3600 * 1e9)): + def __init__(self, params: Params, cache_timeout: int = int(3600 * 1e9), suffix: str = ""): self.params = params self.cache_timeout = cache_timeout - self._LAST_SYNC_KEY = "ModelManager_LastSyncTime" - self._CACHE_KEY = "ModelManager_ModelsCache" + self._LAST_SYNC_KEY = f"ModelManager_LastSyncTime{suffix}" + self._CACHE_KEY = f"ModelManager_ModelsCache{suffix}" def _is_expired(self) -> bool: """Checks if the cache has expired""" @@ -116,32 +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_v17.json" + MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v21.json" + MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v22.json" + + MODEL_SOURCES = { + "qcom": (MODEL_URL, ""), + "chestnut": (MODEL_URL_CHESTNUT, "_Chestnut"), + } def __init__(self, params: Params): self.params = params - self.model_cache = ModelCache(params) self.model_parser = ModelParser() + 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 _fetch_and_cache_models(self) -> list[custom.ModelManagerSP.ModelBundle] | None: + @staticmethod + def active_source(chestnut_present: bool) -> str: + return "chestnut" if chestnut_present else "qcom" + + 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}") @@ -154,15 +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""" - 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 @@ -170,18 +238,37 @@ 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} - # Print model details print(f"Bundle: {bundle.internalName}, Type: {model.type}, Status: {bundle.status}, Overrides: {model_overrides}") - # Print artifact details print(f"Artifact: {model.artifact.fileName}, Download URI: {model.artifact.downloadUri.uri}") - # Print metadata details - print(f"Metadata: {model.metadata.fileName}, Download URI: {model.metadata.downloadUri.uri}") + if model.artifact.chunks: + print(f"Contains {len(model.artifact.chunks)} chunks.") diff --git a/openpilot/sunnypilot/models/helpers.py b/openpilot/sunnypilot/models/helpers.py index a9156ac62e..3c3cc1107b 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 = 15 +REQUIRED_JSON_VERSION = 18 CUSTOM_MODEL_PATH = Paths.model_root() METADATA_PATH = Path(__file__).parent / '../models/supercombo_metadata.pkl' ModelManager = custom.ModelManagerSP -_LAST_VALIDATED_RAW = None + +ACTIVE_BUNDLE_KEYS = { + "qcom": "ModelManager_ActiveBundle", + "chestnut": "ModelManager_ActiveBundleChestnut", +} +_LAST_VALIDATED_RAW: dict[str, dict | None] = {} def _compute_hash(file_path: str) -> str | None: @@ -56,12 +62,20 @@ def is_bundle_version_compatible(bundle: dict) -> bool: def _bundle_artifacts(bundle: custom.ModelManagerSP.ModelBundle) -> list[tuple[str, str]]: artifacts = [] + from openpilot.common.file_chunker import get_chunk_name for model in getattr(bundle, 'models', []) or []: - for artifact in (getattr(model, 'artifact', None), getattr(model, 'metadata', None)): - if artifact and getattr(artifact, 'fileName', None) and getattr(artifact, 'downloadUri', None): - sha256 = getattr(artifact.downloadUri, 'sha256', None) - if sha256: - artifacts.append((artifact.fileName, sha256)) + for artifact in (getattr(model, 'artifact', None),): + if artifact and getattr(artifact, 'fileName', None): + if len(artifact.chunks) > 0: + for i, chunk in enumerate(artifact.chunks): + chunk_name = get_chunk_name(artifact.fileName, i, len(artifact.chunks)) + if getattr(chunk, 'sha256', None): + artifacts.append((chunk_name, chunk.sha256)) + else: + if getattr(artifact, 'downloadUri', None): + sha256 = getattr(artifact.downloadUri, 'sha256', None) + if sha256: + artifacts.append((artifact.fileName, sha256)) return artifacts @@ -78,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 @@ -90,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 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") @@ -156,8 +202,7 @@ def _get_model(): def load_metadata(): - model = _get_model() - metadata_path = f"{CUSTOM_MODEL_PATH}/{model.metadata.fileName}" if model else METADATA_PATH + metadata_path = METADATA_PATH with open(metadata_path, 'rb') as f: return pickle.load(f) diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index 405220d2e4..16253db0ff 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -9,7 +9,7 @@ import asyncio import os import time -import aiohttp +import requests from openpilot.common.params import Params from openpilot.common.realtime import Ratekeeper from openpilot.common.swaglog import cloudlog @@ -17,7 +17,15 @@ 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: @@ -27,22 +35,36 @@ 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.""" if not self.selected_bundle: return for model in self.selected_bundle.models: - for artifact in (model.artifact, model.metadata): - if artifact is not source_artifact and artifact.fileName == source_artifact.fileName: - artifact.downloadProgress.status = source_artifact.downloadProgress.status - artifact.downloadProgress.progress = source_artifact.downloadProgress.progress - artifact.downloadProgress.eta = source_artifact.downloadProgress.eta + artifact = model.artifact + if artifact is not source_artifact and artifact.fileName == source_artifact.fileName: + artifact.downloadProgress.status = source_artifact.downloadProgress.status + artifact.downloadProgress.progress = source_artifact.downloadProgress.progress + artifact.downloadProgress.eta = source_artifact.downloadProgress.eta def _calculate_eta(self, filename: str, progress: float) -> int: """Calculate ETA based on elapsed time and current progress""" @@ -63,76 +85,79 @@ class ModelManagerSP: """Downloads a file with progress tracking""" self._download_start_times[model.fileName] = time.monotonic() - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - response.raise_for_status() - total_size = int(response.headers.get("content-length", 0)) - bytes_downloaded = 0 + with requests.get(url, stream=True, timeout=DOWNLOAD_TIMEOUT) as response: # noqa: ASYNC210 + response.raise_for_status() + total_size = int(response.headers.get("content-length", 0)) + bytes_downloaded = 0 - with open(path, 'wb') as f: - async for chunk in response.content.iter_chunked(self._chunk_size): # type: bytes - f.write(chunk) - bytes_downloaded += len(chunk) + with open(path, 'wb') as f: # noqa: ASYNC230 + for chunk in response.iter_content(chunk_size=self._chunk_size): # type: bytes + 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 - model.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading - model.downloadProgress.progress = progress - model.downloadProgress.eta = self._calculate_eta(model.fileName, progress) - self._sync_artifact_progress(model) - self._report_status() + if total_size > 0: + progress = (bytes_downloaded / total_size) * 100 + model.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading + model.downloadProgress.progress = progress + model.downloadProgress.eta = self._calculate_eta(model.fileName, progress) + self._sync_artifact_progress(model) + self._report_status() - # Clean up start time after download completes - del self._download_start_times[model.fileName] + # 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, skip: frozenset[int] | set[int] = frozenset()) -> None: + from openpilot.common.file_chunker import get_chunk_name, get_manifest_path + + num_chunks = len(artifact.chunks) + if num_chunks == 0: + raise ValueError("No chunks defined in artifact") - async def _download_chunked(self, base_url: str, base_path: str, artifact) -> None: - from openpilot.common.file_chunker import get_manifest_path, get_chunk_name - manifest_url = get_manifest_path(base_url) manifest_path = get_manifest_path(base_path) - - async with aiohttp.ClientSession() as session: - async with session.get(manifest_url) as resp: - if resp.status == 404: - raise FileNotFoundError - resp.raise_for_status() - num_chunks = int((await resp.read()).strip()) - self._download_start_times[artifact.fileName] = time.monotonic() - for i in range(num_chunks): - chunk_url = get_chunk_name(base_url, i, num_chunks) - chunk_path = get_chunk_name(base_path, i, num_chunks) - chunk_downloaded = 0 - async with aiohttp.ClientSession() as session: - async with session.get(chunk_url) as response: + # 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 + with session.get(chunk_url, stream=True, timeout=DOWNLOAD_TIMEOUT) as response: response.raise_for_status() chunk_size = int(response.headers.get("content-length", 0)) - with open(chunk_path, 'wb') as f: - async for data in response.content.iter_chunked(self._chunk_size): + with open(chunk_path, 'wb') as f: # noqa: ASYNC230 + 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, (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: + with open(manifest_path, 'w') as f: # noqa: ASYNC230 f.write(str(num_chunks)) - if os.path.isfile(base_path): + if os.path.isfile(base_path): # noqa: ASYNC240 os.remove(base_path) del self._download_start_times[artifact.fileName] 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,7 +165,28 @@ class ModelManagerSP: full_path = os.path.join(destination_path, filename) try: - if await verify_file(full_path, expected_hash): + # 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) + for i, chunk in enumerate(artifact.chunks): + if self._download_interrupted(): + raise DownloadCancelled("Download cancelled") + if await verify_file(get_chunk_name(full_path, i, num_chunks), chunk.sha256): + valid_chunks.add(i) + artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.verifying + artifact.downloadProgress.progress = (len(valid_chunks) / num_chunks) * 100 + self._sync_artifact_progress(artifact) + self._report_status() + is_cached = len(valid_chunks) == num_chunks + else: + if await verify_file(full_path, expected_hash): + is_cached = True + + if is_cached: artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached artifact.downloadProgress.progress = 100 artifact.downloadProgress.eta = 0 @@ -148,13 +194,17 @@ class ModelManagerSP: self._report_status() return - try: - await self._download_chunked(url, full_path, artifact) - except (FileNotFoundError, aiohttp.ClientResponseError): + if len(artifact.chunks) > 0: + 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)) + if not await verify_file(chunk_path, chunk.sha256): + raise ValueError(f"Hash validation failed for chunk {i+1} of {filename}") + else: await self._download_file(url, full_path, artifact) - - if not await verify_file(full_path, expected_hash): - raise ValueError(f"Hash validation failed for {filename}") + if not await verify_file(full_path, expected_hash): + raise ValueError(f"Hash validation failed for {filename}") artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloaded artifact.downloadProgress.progress = 100 @@ -162,26 +212,34 @@ 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]: - if os.path.isfile(f): + if os.path.isfile(f): # noqa: ASYNC240 os.remove(f) artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed artifact.downloadProgress.eta = 0 self._sync_artifact_progress(artifact) - self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed + if self.selected_bundle: + self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed self._report_status() self._download_start_times.pop(artifact.fileName, None) raise async def _process_model(self, model, destination_path: str) -> None: """Processes a single model download including verification""" - model_artifact = model.artifact - metadata_artifact = model.metadata - - await self._process_artifact(metadata_artifact, destination_path) - await self._process_artifact(model_artifact, destination_path) + await self._process_artifact(model.artifact, destination_path) def _report_status(self) -> None: """Reports current status through messaging system""" @@ -196,41 +254,66 @@ 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: + model.artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading + self._report_status() os.makedirs(destination_path, exist_ok=True) try: seen_artifacts: set[str] = set() for model in self.selected_bundle.models: - for artifact in (model.metadata, model.artifact): - if not artifact.fileName: - continue - if artifact.fileName in seen_artifacts: - artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached - artifact.downloadProgress.progress = 100 - artifact.downloadProgress.eta = 0 - else: - seen_artifacts.add(artifact.fileName) - await self._process_artifact(artifact, destination_path) + artifact = model.artifact + if not artifact.fileName: + continue + if artifact.fileName in seen_artifacts: + artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached + artifact.downloadProgress.progress = 100 + artifact.downloadProgress.eta = 0 + else: + 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: - self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed + if self.selected_bundle is not None: + self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed raise 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""" @@ -238,19 +321,14 @@ 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 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 + self._process_download_requests() if self.params.get("ModelManager_ClearCache"): self.clear_model_cache() @@ -268,14 +346,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) - if hasattr(model, 'metadata') and model.metadata.fileName: - active_files.append(model.metadata.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 02a6c2bac2..374e8473df 100644 --- a/openpilot/sunnypilot/models/model_name.py +++ b/openpilot/sunnypilot/models/model_name.py @@ -1 +1,2 @@ DEFAULT_MODEL = "CD210" +DEFAULT_BIG_MODEL = "Lebowski" diff --git a/openpilot/sunnypilot/models/runners/helpers.py b/openpilot/sunnypilot/models/runners/helpers.py deleted file mode 100644 index b34a62132b..0000000000 --- a/openpilot/sunnypilot/models/runners/helpers.py +++ /dev/null @@ -1,28 +0,0 @@ -from openpilot.sunnypilot.models.helpers import get_active_bundle -from openpilot.sunnypilot.models.runners.model_runner import ModelRunner -from openpilot.sunnypilot.models.runners.tinygrad.tinygrad_runner import TinygradRunner, TinygradSplitRunner -from openpilot.sunnypilot.models.runners.constants import ModelType - - -def get_model_runner() -> ModelRunner: - """ - Factory function to create and return the appropriate ModelRunner instance. - - Selects TinygradRunner, choosing TinygradSplitRunner if separate vision/policy - models are detected in the active bundle. - - :return: An instance of a ModelRunner subclass (ONNXRunner, TinygradRunner, or TinygradSplitRunner). - """ - bundle = get_active_bundle() - if bundle and bundle.models: - model_types = {m.type.raw for m in bundle.models} - # Check if the bundle uses separate vision and policy models (legacy or new split format) - split_types = {ModelType.vision, ModelType.policy, ModelType.offPolicy, ModelType.onPolicy} - if model_types & split_types: - return TinygradSplitRunner() - # Otherwise, assume a single model (likely supercombo) - if bundle.models: - return TinygradRunner(bundle.models[0].type.raw) - - # Default fallback to TinygradRunner with the supercombo type if bundle info is missing/incomplete - return TinygradRunner(ModelType.supercombo) diff --git a/openpilot/sunnypilot/models/runners/model_runner.py b/openpilot/sunnypilot/models/runners/model_runner.py deleted file mode 100644 index cbf2fc5e20..0000000000 --- a/openpilot/sunnypilot/models/runners/model_runner.py +++ /dev/null @@ -1,174 +0,0 @@ -from abc import abstractmethod, ABC - -import numpy as np -from openpilot.sunnypilot.models.helpers import get_active_bundle -from openpilot.sunnypilot.models.runners.constants import NumpyDict, ShapeDict, Model, SliceDict, SEND_RAW_PRED -from openpilot.common.hardware.hw import Paths -import pickle - -CUSTOM_MODEL_PATH = Paths.model_root() - - -class ModelData: - """ - Stores metadata and configuration for a specific machine learning model. - - This class loads model metadata (like input shapes and output slices) - from a pickle file associated with a model instance. - - :param model: The machine learning model object containing metadata. - """ - def __init__(self, model: Model): - self.model = model - self.metadata = model.metadata - self.input_shapes: ShapeDict = {} - self.output_slices: SliceDict = {} - if self.metadata: - self._load_metadata() - - def _load_metadata(self) -> None: - """Loads input shapes and output slices from the model's metadata pickle file.""" - metadata_path = f"{CUSTOM_MODEL_PATH}/{self.metadata.fileName}" - with open(metadata_path, 'rb') as f: - model_metadata = pickle.load(f) - self.input_shapes = model_metadata.get('input_shapes', {}) - self.output_slices = model_metadata.get('output_slices', {}) - - -class ModularRunner(ABC): - """ - Represents a modular runner for handling and slicing model outputs. - - This abstract base class is designed to provide an interface for modular - parsing and processing of model outputs. Classes inheriting from it must - implement the specified abstract methods, defining how model outputs - should be handled and stored. The primary goal is to enable structured - parsing of outputs through a dictionary-based method mapping. - - :ivar parser_method_dict: Mapping dictionary containing parser methods - for handling specific types of outputs. - :type parser_method_dict: dict - """ - - @property - @abstractmethod - def parser_method_dict(self) -> dict: - pass - - @parser_method_dict.setter - @abstractmethod - def parser_method_dict(self, value: dict) -> None: - pass - - @abstractmethod - def _slice_outputs(self, model_outputs: np.ndarray) -> NumpyDict: - pass - - -class ModelRunner(ModularRunner): - """ - Abstract base class for managing and executing machine learning models. - - Provides a common interface for loading models, preparing inputs, running - inference, and slicing/parsing outputs based on model metadata. Derived - classes implement the specifics of input preparation and model execution - for different frameworks (e.g., Tinygrad, ONNX). - """ - - def __init__(self): - """Initializes the model runner, loading the active model bundle.""" - self.is_20hz: bool | None = None - self.is_20hz_3d: bool | None = None - self.models: dict[int, ModelData] = {} - self._model_data: ModelData | None = None # Active model data for current operation - self._parser_method_dict: dict = {} - self.inputs: dict = {} - self._parser = None - self._load_models() - self._constants = None - - @property - def constants(self): - return self._constants - - @property - def parser_method_dict(self) -> dict: - """Returns the dictionary mapping model types to their respective parsing methods.""" - return self._parser_method_dict - - @parser_method_dict.setter - def parser_method_dict(self, value: dict) -> None: - """Sets the dictionary mapping model types to their respective parsing methods.""" - self._parser_method_dict = value - - def _load_models(self) -> None: - """Loads the active model bundle configuration and sets up ModelData.""" - bundle = get_active_bundle() - if not bundle: - raise ValueError("No active model bundle found, why are we being executed?") - - self.models = {model.type.raw: ModelData(model) for model in bundle.models} - self.is_20hz = bundle.is20hz - self.is_20hz_3d = False - - @property - def input_shapes(self) -> ShapeDict: - """Returns the input shapes for the currently active model.""" - if self._model_data: - return self._model_data.input_shapes - raise ValueError("Model data is not available. Ensure the model is loaded correctly.") - - @property - def output_slices(self) -> SliceDict: - """Returns the output slices for the currently active model.""" - if self._model_data: - return self._model_data.output_slices - raise ValueError("Model data is not available. Ensure the model is loaded correctly.") - - @property - def vision_input_names(self) -> list[str]: - """Returns the list of vision input names from the input shapes.""" - if self._model_data: - return list(self._model_data.input_shapes.keys()) - raise ValueError("Model data is not available. Ensure the model is loaded correctly.") - - @abstractmethod - def prepare_inputs(self, numpy_inputs: NumpyDict) -> dict: - """ - Abstract method to prepare inputs for model inference. - - :param numpy_inputs: Dictionary of numpy arrays for non-image inputs. - :return: Dictionary of prepared inputs ready for the model. - """ - raise NotImplementedError - - @abstractmethod - def _run_model(self) -> NumpyDict: - """ - Abstract method to execute model inference with prepared inputs. - - :return: Dictionary containing the model's raw output arrays. - """ - raise NotImplementedError - - def _slice_outputs(self, model_outputs: np.ndarray) -> NumpyDict: - """ - Slices the raw model output array based on the output_slices metadata. - - :param model_outputs: The raw numpy array output from the model. - :return: A dictionary where keys are output names and values are sliced numpy arrays. - """ - if not self._model_data: - raise ValueError("Model data is not available. Ensure the model is loaded correctly.") - sliced_outputs = {k: model_outputs[np.newaxis, v] for k, v in self._model_data.output_slices.items()} - if SEND_RAW_PRED: - sliced_outputs['raw_pred'] = model_outputs.copy() # Optionally include the full raw output - return sliced_outputs - - def run_model(self) -> NumpyDict: - """ - Executes the model inference pipeline: runs the model and parses outputs. - - :return: Dictionary containing the final parsed model outputs. - """ - return self._run_model() # Parsing is handled within specific runner implementations diff --git a/openpilot/sunnypilot/models/runners/tinygrad/model_types.py b/openpilot/sunnypilot/models/runners/tinygrad/model_types.py deleted file mode 100644 index 295e75afb5..0000000000 --- a/openpilot/sunnypilot/models/runners/tinygrad/model_types.py +++ /dev/null @@ -1,91 +0,0 @@ -import os -from abc import ABC - -import numpy as np -from openpilot.sunnypilot.modeld_v2.parse_model_outputs import Parser as CombinedParser -from openpilot.sunnypilot.modeld_v2.parse_model_outputs_split import Parser as SplitParser -from openpilot.sunnypilot.models.runners.constants import ModelType, NumpyDict -from openpilot.sunnypilot.models.runners.model_runner import ModularRunner -from openpilot.common.hardware.hw import Paths - - -SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') -CUSTOM_MODEL_PATH = Paths.model_root() - - -class OffPolicyTinygrad(ModularRunner, ABC): - """ - A TinygradRunner specialized for off-policy models. - - Uses a SplitParser to handle outputs specific to the off-policy part of a split model setup. - """ - def __init__(self): - self._off_policy_parser = SplitParser() - self.parser_method_dict[ModelType.offPolicy] = self._parse_off_policy_outputs - - def _parse_off_policy_outputs(self, model_outputs: np.ndarray) -> NumpyDict: - """Parses off-policy model outputs using SplitParser.""" - result: NumpyDict = self._off_policy_parser.parse_policy_outputs(self._slice_outputs(model_outputs)) - return result - - -class OnPolicyTinygrad(ModularRunner, ABC): - """ - A TinygradRunner specialized for on-policy models. - - Uses a SplitParser to handle outputs specific to the on-policy part of a split model setup. - """ - def __init__(self): - self._on_policy_parser = SplitParser() - self.parser_method_dict[ModelType.onPolicy] = self._parse_on_policy_outputs - - def _parse_on_policy_outputs(self, model_outputs: np.ndarray) -> NumpyDict: - """Parses on-policy model outputs using SplitParser.""" - result: NumpyDict = self._on_policy_parser.parse_policy_outputs(self._slice_outputs(model_outputs)) - return result - - -class PolicyTinygrad(ModularRunner, ABC): - """ - A TinygradRunner specialized for policy-only models. - - Uses a SplitParser to handle outputs specific to the policy part of a split model setup. - """ - def __init__(self): - self._policy_parser = SplitParser() - self.parser_method_dict[ModelType.policy] = self._parse_policy_outputs - - def _parse_policy_outputs(self, model_outputs: np.ndarray) -> NumpyDict: - """Parses policy model outputs using SplitParser.""" - result: NumpyDict = self._policy_parser.parse_policy_outputs(self._slice_outputs(model_outputs)) - return result - -class VisionTinygrad(ModularRunner, ABC): - """ - A TinygradRunner specialized for vision-only models. - - Uses a SplitParser to handle outputs specific to the vision part of a split model setup. - """ - def __init__(self): - self._vision_parser = SplitParser() - self.parser_method_dict[ModelType.vision] = self._parse_vision_outputs - - def _parse_vision_outputs(self, model_outputs: np.ndarray) -> NumpyDict: - """Parses vision model outputs using SplitParser.""" - result: NumpyDict = self._vision_parser.parse_vision_outputs(self._slice_outputs(model_outputs)) - return result - -class SupercomboTinygrad(ModularRunner, ABC): - """ - A TinygradRunner specialized for vision-only models. - - Uses a SplitParser to handle outputs specific to the vision part of a split model setup. - """ - def __init__(self): - self._supercombo_parser = CombinedParser() - self.parser_method_dict[ModelType.supercombo] = self._parse_supercombo_outputs - - def _parse_supercombo_outputs(self, model_outputs: np.ndarray) -> NumpyDict: - """Parses vision model outputs using SplitParser.""" - result: NumpyDict = self._supercombo_parser.parse_outputs(self._slice_outputs(model_outputs)) - return result diff --git a/openpilot/sunnypilot/models/runners/tinygrad/tinygrad_runner.py b/openpilot/sunnypilot/models/runners/tinygrad/tinygrad_runner.py deleted file mode 100644 index 4e17bd5ead..0000000000 --- a/openpilot/sunnypilot/models/runners/tinygrad/tinygrad_runner.py +++ /dev/null @@ -1,179 +0,0 @@ -import pickle - -import numpy as np -from openpilot.sunnypilot.models.runners.constants import NumpyDict, ModelType, ShapeDict, CUSTOM_MODEL_PATH, SliceDict -from openpilot.sunnypilot.models.runners.model_runner import ModelRunner -from openpilot.sunnypilot.models.runners.tinygrad.model_types import PolicyTinygrad, VisionTinygrad, SupercomboTinygrad, OffPolicyTinygrad, OnPolicyTinygrad -from openpilot.sunnypilot.models.split_model_constants import SplitModelConstants -from openpilot.sunnypilot.modeld_v2.constants import ModelConstants - -from tinygrad.tensor import Tensor - - -class TinygradRunner(ModelRunner, SupercomboTinygrad, PolicyTinygrad, VisionTinygrad, OffPolicyTinygrad, OnPolicyTinygrad): - """ - A ModelRunner implementation for executing Tinygrad models. - - Handles loading Tinygrad model artifacts (.pkl), preparing inputs as Tinygrad - Tensors (potentially using QCOM extensions on TICI), running inference, - and parsing the outputs. - - :param model_type: The type of model (e.g., supercombo) to load and run. - """ - def __init__(self, model_type: int = ModelType.supercombo): - ModelRunner.__init__(self) - SupercomboTinygrad.__init__(self) - PolicyTinygrad.__init__(self) - VisionTinygrad.__init__(self) - OffPolicyTinygrad.__init__(self) - OnPolicyTinygrad.__init__(self) - self._constants = ModelConstants - self._model_data = self.models.get(model_type) - if not self._model_data or not self._model_data.model: - raise ValueError(f"Model data for type {model_type} not available.") - - artifact_filename = self._model_data.model.artifact.fileName - assert artifact_filename.endswith('_tinygrad.pkl'), \ - f"Invalid model file {artifact_filename} for TinygradRunner" - - model_pkl_path = f"{CUSTOM_MODEL_PATH}/{artifact_filename}" - with open(model_pkl_path, "rb") as f: - try: - # Load the compiled Tinygrad model runner function - self.model_run = pickle.load(f) - except FileNotFoundError as e: - # Provide a helpful error message if the model was built for a different platform - assert "/dev/kgsl-3d0" not in str(e), "Model was built on C3 or C3X, but is being loaded on PC" - raise - - # Map input names to their required dtype and device from the loaded model - self.input_to_dtype = {} - self.input_to_device = {} - for idx, name in enumerate(self.model_run.captured.expected_names): - info = self.model_run.captured.expected_input_info[idx] - self.input_to_dtype[name] = info[2] # dtype - self.input_to_device[name] = info[3] # device - self._policy_cached = False - - @property - def vision_input_names(self) -> list[str]: - """Returns the list of vision input names from the input shapes.""" - return [name for name in self.input_shapes.keys() if 'img' in name] - - - def prepare_policy_inputs(self, numpy_inputs: NumpyDict): - if not self._policy_cached: - for key, value in numpy_inputs.items(): - self.inputs[key] = Tensor(value, device='NPY').realize() - self._policy_cached = True - - def prepare_inputs(self, numpy_inputs: NumpyDict) -> dict: - """Prepares all vision and policy inputs for the model.""" - self.prepare_policy_inputs(numpy_inputs) - for key in self.vision_input_names: - if key in self.inputs: - self.inputs[key] = self.inputs[key].cast(self.input_to_dtype[key]) - return self.inputs - - def _run_model(self) -> NumpyDict: - """Runs the Tinygrad model inference and parses the outputs.""" - outputs = self.model_run(**self.inputs).contiguous().realize().uop.base.buffer.numpy().flatten() - return self._parse_outputs(outputs) - - def _parse_outputs(self, model_outputs: np.ndarray) -> NumpyDict: - """Parses the raw model outputs using the standard Parser.""" - if self._model_data is None: - raise ValueError("Model data is not available. Ensure the model is loaded correctly.") - - result: NumpyDict = self.parser_method_dict[self._model_data.model.type.raw](model_outputs) - return result - - -class TinygradSplitRunner(ModelRunner): - """ - A ModelRunner that coordinates separate TinygradVisionRunner and TinygradPolicyRunner instances. - - Manages the execution of split vision and policy models, combining their inputs and outputs. - """ - def __init__(self): - super().__init__() - self.is_20hz_3d = True - self.vision_runner = TinygradRunner(ModelType.vision) - self.policy_runner = TinygradRunner(ModelType.policy) if self.models.get(ModelType.policy) else None - self.off_policy_runner = TinygradRunner(ModelType.offPolicy) if self.models.get(ModelType.offPolicy) else None - self.on_policy_runner = TinygradRunner(ModelType.onPolicy) if self.models.get(ModelType.onPolicy) else None - self._constants = SplitModelConstants - - def _run_model(self) -> NumpyDict: - """Runs both vision and policy models and merges their parsed outputs.""" - vision_output = self.vision_runner.run_model() - outputs = {**vision_output} - - if self.policy_runner: - policy_output = self.policy_runner.run_model() - outputs.update(policy_output) - - if self.off_policy_runner: - off_policy_output = self.off_policy_runner.run_model() - if self.on_policy_runner: - off_policy_output.pop('plan', None) - outputs.update(off_policy_output) - - if self.on_policy_runner: - on_policy_output = self.on_policy_runner.run_model() - outputs.update(on_policy_output) - - if 'planplus' in outputs and 'plan' in outputs: - outputs['plan'] = outputs['plan'] + outputs['planplus'] - - return outputs - - @property - def vision_input_names(self) -> list[str]: - """Returns the list of vision input names from the vision runner.""" - return list(self.vision_runner.vision_input_names) - - @property - def input_shapes(self) -> ShapeDict: - """Returns the combined input shapes from both vision and policy models.""" - shapes = {**self.vision_runner.input_shapes} - if self.policy_runner: - shapes.update(self.policy_runner.input_shapes) - if self.off_policy_runner: - shapes.update(self.off_policy_runner.input_shapes) - if self.on_policy_runner: - shapes.update(self.on_policy_runner.input_shapes) - return shapes - - @property - def output_slices(self) -> SliceDict: - """Returns the combined output slices from both vision and policy models.""" - slices = {**self.vision_runner.output_slices} - if self.policy_runner: - slices.update(self.policy_runner.output_slices) - if self.off_policy_runner: - slices.update(self.off_policy_runner.output_slices) - if self.on_policy_runner: - slices.update(self.on_policy_runner.output_slices) - return slices - - def prepare_inputs(self, numpy_inputs: NumpyDict) -> dict: - """Prepares inputs for both vision and policy models.""" - if self.policy_runner: - self.policy_runner.prepare_policy_inputs(numpy_inputs) - - for key in self.vision_input_names: - if key in self.inputs: - self.vision_runner.inputs[key] = self.inputs[key].cast(self.vision_runner.input_to_dtype[key]) - - inputs = {**self.vision_runner.inputs} - if self.policy_runner: - inputs.update(self.policy_runner.inputs) - - if self.off_policy_runner: - self.off_policy_runner.prepare_policy_inputs(numpy_inputs) - inputs.update(self.off_policy_runner.inputs) - if self.on_policy_runner: - self.on_policy_runner.prepare_policy_inputs(numpy_inputs) - inputs.update(self.on_policy_runner.inputs) - return inputs diff --git a/openpilot/sunnypilot/models/split_model_constants.py b/openpilot/sunnypilot/models/split_model_constants.py index a3e1dce8f6..a5f57e5453 100644 --- a/openpilot/sunnypilot/models/split_model_constants.py +++ b/openpilot/sunnypilot/models/split_model_constants.py @@ -43,6 +43,7 @@ class SplitModelConstants: LANE_LINES_WIDTH = 2 ROAD_EDGES_WIDTH = 2 PLAN_WIDTH = 15 + ACTION_WIDTH = 2 DESIRE_PRED_WIDTH = 8 LAT_PLANNER_SOLUTION_WIDTH = 4 DESIRED_CURV_WIDTH = 1 diff --git a/openpilot/sunnypilot/models/tests/test_default_model.py b/openpilot/sunnypilot/models/tests/test_default_model.py index ab51027442..b72c2b4c89 100644 --- a/openpilot/sunnypilot/models/tests/test_default_model.py +++ b/openpilot/sunnypilot/models/tests/test_default_model.py @@ -8,9 +8,10 @@ See the LICENSE.md file in the root directory for more details. from openpilot.sunnypilot import get_file_hash from openpilot.sunnypilot.models.default_model import MODEL_HASH_PATH, SUPERCOMBO_ONNX_PATH import hashlib +from openpilot.common.test import OpenpilotTestCase -class TestDefaultModel: +class TestDefaultModel(OpenpilotTestCase): def test_compare_onnx_hashes(self): supercombo_hash = get_file_hash(SUPERCOMBO_ONNX_PATH) @@ -19,4 +20,4 @@ class TestDefaultModel: with open(MODEL_HASH_PATH) as f: current_hash = f.read().strip() - assert combined_hash == current_hash, "Run sunnypilot/models/default_model.py to update the default model name and hash" + 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 new file mode 100644 index 0000000000..489e0bc096 --- /dev/null +++ b/openpilot/sunnypilot/models/tests/test_manager_download.py @@ -0,0 +1,813 @@ +""" +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 asyncio +import hashlib +import http.server +import os +import tempfile +import threading +import time +import unittest +from typing import Any +from unittest import mock + +import requests +from urllib3.connectionpool import HTTPConnectionPool + +from openpilot.cereal import custom +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] +WHOLE_BODY = b'Z' * 9000 + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +class DownloadHandler(http.server.BaseHTTPRequestHandler): + """Serves the fixture bodies. Class attributes are reset per test.""" + request_paths: list[str] = [] + fail_paths: dict[str, int] = {} + stall_paths: set[str] = set() + stall_event: threading.Event | None = None + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + pass + + def _body_for(self, path): + if path.endswith('.whole'): + return WHOLE_BODY + for i in range(len(CHUNK_BODIES)): + if path.endswith(get_chunk_name('', i, len(CHUNK_BODIES))): + return CHUNK_BODIES[i] + return None + + def do_GET(self): + type(self).request_paths.append(self.path) + + status = type(self).fail_paths.get(self.path) + if status: + self.send_response(status) + self.end_headers() + return + + body = self._body_for(self.path) + if body is None: + self.send_response(404) + self.end_headers() + return + + self.send_response(200) + self.send_header('Content-Length', str(len(body))) + self.end_headers() + + if self.path in type(self).stall_paths: + # write a little, then wait so the test can cancel mid-transfer + self.wfile.write(body[:100]) + self.wfile.flush() + if type(self).stall_event is not None: + type(self).stall_event.wait(timeout=5) + self.wfile.write(body[100:]) + else: + self.wfile.write(body) + + +class ManagerDownloadTestBase(OpenpilotTestCase): + def setUp(self): + super().setUp() + DownloadHandler.request_paths = [] + DownloadHandler.fail_paths = {} + DownloadHandler.stall_paths = set() + DownloadHandler.stall_event = None + + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.dest = self._tmp.name + + self.reported: list[float] = [] + + 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 = {} + + def _record_progress(self, *args) -> None: + """Runs on every real _report_status send.""" + artifact = getattr(self, 'artifact', None) + if artifact is not None: + self.reported.append(float(artifact.downloadProgress.progress)) + + def make_artifact(self, chunked: bool): + bundle = custom.ModelManagerSP.ModelBundle.new_message() + bundle.init('models', 1) + artifact = bundle.models[0].artifact + artifact.fileName = 'driving_test_tinygrad.pkl' + if chunked: + artifact.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl' + artifact.downloadUri.sha256 = sha256(b''.join(CHUNK_BODIES)) + artifact.init('chunks', len(CHUNK_BODIES)) + for i, body in enumerate(CHUNK_BODIES): + artifact.chunks[i].sha256 = sha256(body) + else: + artifact.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl.whole' + artifact.downloadUri.sha256 = sha256(WHOLE_BODY) + self._bundle = bundle + self.artifact = artifact + return artifact + + def chunk_paths(self, base_path): + return [get_chunk_name(base_path, i, len(CHUNK_BODIES)) for i in range(len(CHUNK_BODIES))] + + def assert_no_partials(self, base_path): + leftovers = [p for p in [base_path, get_manifest_path(base_path)] + self.chunk_paths(base_path) + if os.path.isfile(p)] + assert leftovers == [], f"partial files left behind: {leftovers}" + + +class TestManagerDownload(ManagerDownloadTestBase): + """Exercises the real _download_file / _download_chunked against a local server.""" + + def run_with_server(self, fn): + with http_server_context(handler=DownloadHandler) as (host, port): + self.base_url = f'http://{host}:{port}' + return fn() + + def test_download_file_writes_exact_bytes(self): + def body(): + artifact = self.make_artifact(chunked=False) + path = os.path.join(self.dest, artifact.fileName) + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, path, artifact)) + with open(path, 'rb') as f: + written = f.read() + assert written == WHOLE_BODY + assert sha256(written) == artifact.downloadUri.sha256 + assert artifact.fileName not in self.manager._download_start_times + self.run_with_server(body) + + def test_download_chunked_writes_all_chunks_and_manifest(self): + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + + 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, f"chunk {i} body mismatch" + + with open(get_manifest_path(base_path)) as f: + assert f.read() == str(len(CHUNK_BODIES)) + + assert not os.path.isfile(base_path), "base file should be removed after chunking" + assert artifact.fileName not in self.manager._download_start_times + self.run_with_server(body) + + def test_progress_is_monotonic_and_bounded(self): + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + + assert self.reported, "expected progress reports" + for a, b in zip(self.reported, self.reported[1:], strict=False): + assert b >= a, f"progress went backwards: {a} -> {b}" + assert max(self.reported) <= 99.0, f"chunked progress must stay <=99 until verify, got {max(self.reported)}" + self.run_with_server(body) + + def test_session_is_reused_across_chunks(self): + """One connection pool shared across every chunk.""" + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + + pools = [] + original = HTTPConnectionPool.urlopen + + def tracked(pool_self, *args, **kwargs): + pools.append(id(pool_self)) + return original(pool_self, *args, **kwargs) + + with mock.patch.object(HTTPConnectionPool, 'urlopen', tracked): + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + + assert len(pools) == len(CHUNK_BODIES), f"expected one request per chunk, got {len(pools)}" + assert len(set(pools)) == 1, f"connection pool not reused across chunks: {len(set(pools))} pools" + self.run_with_server(body) + + def test_http_error_propagates(self): + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + failing = '/' + os.path.basename(get_chunk_name(artifact.downloadUri.uri, 1, len(CHUNK_BODIES))) + DownloadHandler.fail_paths = {failing: 404} + + with self.assertRaises(requests.exceptions.HTTPError): + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + + # chunk 1 failed, so its file and the manifest must not exist + assert not os.path.isfile(get_chunk_name(base_path, 1, len(CHUNK_BODIES))) + assert not os.path.isfile(get_manifest_path(base_path)) + self.run_with_server(body) + + def test_cancellation_mid_transfer(self): + """Cancellation is checked inside the byte loop; it must still fire after the port.""" + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + self.manager.params.get.return_value = None # cancelled + + 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_repeat_downloads_are_stable(self): + """Back-to-back runs must produce identical bytes and leak no start-time state.""" + def body(): + for _ in range(2): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + 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 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 = 18 + params, store = self._make_params_with_store() + self.manager.params = params + asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "qcom")) + + assert "ModelManager_ActiveBundle" in store, "qcom download must write the qcom slot" + assert "ModelManager_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 = 18 + 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 + it and models_manager died on device while CI stayed green.""" + + def test_manager_imports(self): + assert manager_module.ModelManagerSP is not None + + def test_no_undeclared_http_client(self): + with open(manager_module.__file__) as f: + src = f.read() + assert 'import aiohttp' not in src, "aiohttp is not available on AGNOS 19.6; use requests" + + def test_download_timeout_is_explicit(self): + connect, read = manager_module.DOWNLOAD_TIMEOUT + 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": "18", + "ref": ref, + "models": [{ + "type": "supercombo", + "artifact": { + "file_name": f"{short_name}.pkl", + "download_uri": {"url": f"https://example.com/{short_name}.pkl", "sha256": "s"}, + }, + }], + } + + +def fresh_sync_time() -> int: + return int(time.monotonic() * 1e9) + + +class TestModelFetcherSources(OpenpilotTestCase): + """Both manifests are always maintained: get_bundles_for_source exposes either + source by name, and active_source picks which one matches the attached hardware.""" + + def _make_params(self, qcom_manifest, 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 = 18 + 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 = 18 + 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 = 18 + 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.""" + + def test_all_manifest_urls_available(self): + from openpilot.sunnypilot.models.fetcher import ModelFetcher + + manifest = requests.get(ModelFetcher.MODEL_URL, timeout=30).json() + session = requests.Session() + dead = [] + + for bundle in manifest.get('bundles', []): + for model in bundle.get('models', []): + artifact = model['artifact'] + url = artifact['download_uri']['url'] + chunks = artifact.get('chunks', []) + urls = ([url] if not chunks + else [get_chunk_name(url, i, len(chunks)) for i in range(len(chunks))]) + for u in urls: + try: + r = session.head(u, timeout=15, allow_redirects=True) + if r.status_code != 200: + dead.append(f"{bundle.get('short_name')}: HTTP {r.status_code} {u}") + except requests.RequestException as e: + dead.append(f"{bundle.get('short_name')}: {type(e).__name__} {u}") + + assert not dead, "unreachable model URLs:\n" + "\n".join(dead) + + +if __name__ == '__main__': + unittest.main() diff --git a/openpilot/sunnypilot/models/tests/test_tinygrad_ref.py b/openpilot/sunnypilot/models/tests/test_tinygrad_ref.py index 3e60ab5308..d6d82dfb32 100644 --- a/openpilot/sunnypilot/models/tests/test_tinygrad_ref.py +++ b/openpilot/sunnypilot/models/tests/test_tinygrad_ref.py @@ -2,7 +2,7 @@ import requests 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(): response = requests.get(ModelFetcher.MODEL_URL, timeout=10) @@ -11,13 +11,14 @@ def fetch_tinygrad_ref(): return json_data.get("tinygrad_ref") -def test_tinygrad_ref(): - current_ref = get_tinygrad_ref() - remote_ref = fetch_tinygrad_ref() - assert remote_ref == current_ref, ( - f"""tinygrad_repo ref does not match remote tinygrad_ref of current compiled driving models json. - Current: {current_ref} - Remote: {remote_ref} - Please run build-all workflow to update models.""" - ) - print("tinygrad_repo ref matches current compiled driving models json ref.") +class TestTinygradRef(OpenpilotTestCase): + def test_tinygrad_ref(self): + current_ref = get_tinygrad_ref() + remote_ref = fetch_tinygrad_ref() + assert remote_ref == current_ref, ( + f"""tinygrad_repo ref does not match remote tinygrad_ref of current compiled driving models json. + Current: {current_ref} + Remote: {remote_ref} + Please run build-all workflow to update models.""" + ) + print("tinygrad_repo ref matches current compiled driving models json ref.") diff --git a/openpilot/sunnypilot/navd/helpers.py b/openpilot/sunnypilot/navd/helpers.py index c57706d32a..63e7c8c7be 100644 --- a/openpilot/sunnypilot/navd/helpers.py +++ b/openpilot/sunnypilot/navd/helpers.py @@ -106,7 +106,7 @@ def distance_along_geometry(geometry: list[Coordinate], pos: Coordinate) -> floa return total_distance_closest -def coordinate_from_param(param: str, params: Params = None) -> Coordinate | None: +def coordinate_from_param(param: str, params: Params | None = None) -> Coordinate | None: if params is None: params = Params() 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/car/interfaces.py b/openpilot/sunnypilot/selfdrive/car/interfaces.py index 5be227c262..4988e863b1 100644 --- a/openpilot/sunnypilot/selfdrive/car/interfaces.py +++ b/openpilot/sunnypilot/selfdrive/car/interfaces.py @@ -25,7 +25,7 @@ def log_fingerprint(CP: structs.CarParams) -> None: sentry.capture_fingerprint(CP.carFingerprint, CP.brand) -def _enforce_torque_lateral_control(CP: structs.CarParams, params: Params = None, enabled: bool = False) -> bool: +def _enforce_torque_lateral_control(CP: structs.CarParams, params: Params | None = None, enabled: bool = False) -> bool: if params is None: params = Params() @@ -36,7 +36,7 @@ def _enforce_torque_lateral_control(CP: structs.CarParams, params: Params = None def _initialize_neural_network_lateral_control(CP: structs.CarParams, CP_SP: structs.CarParamsSP, - params: Params = None, enabled: bool = False) -> bool: + params: Params | None = None, enabled: bool = False) -> bool: if params is None: params = Params() @@ -55,7 +55,7 @@ def _initialize_neural_network_lateral_control(CP: structs.CarParams, CP_SP: str return enabled -def _initialize_intelligent_cruise_button_management(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params = None) -> None: +def _initialize_intelligent_cruise_button_management(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params | None = None) -> None: if params is None: params = Params() @@ -69,14 +69,20 @@ def _initialize_torque_lateral_control(CI: CarInterfaceBase, CP: structs.CarPara CI.configure_torque_tune(CP.carFingerprint, CP.lateralTuning) -def _cleanup_unsupported_params(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params = None) -> None: +def _cleanup_unsupported_params(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params | None = None) -> None: if params is None: params = Params() + if params.get_bool("LateralJerkTorqueController") and params.get_bool("NeuralNetworkLateralControl"): + cloudlog.warning("LateralJerkTorqueController and NeuralNetworkLateralControl both enabled, disabling both") + params.put_bool("LateralJerkTorqueController", False, block=True) + params.put_bool("NeuralNetworkLateralControl", False, block=True) + if CP.steerControlType == structs.CarParams.SteerControlType.angle: cloudlog.warning("SteerControlType is angle, cleaning up params") params.remove("NeuralNetworkLateralControl") params.remove("EnforceTorqueControl") + params.remove("LateralJerkTorqueController") if not CP_SP.intelligentCruiseButtonManagementAvailable or CP.openpilotLongitudinalControl: cloudlog.warning("ICBM not available or openpilot Longitudinal Control enabled, cleaning up params") @@ -92,7 +98,7 @@ def _cleanup_unsupported_params(CP: structs.CarParams, CP_SP: structs.CarParamsS set_speed_limit_assist_availability(CP, CP_SP, params) -def setup_interfaces(CI: CarInterfaceBase, params: Params = None) -> None: +def setup_interfaces(CI: CarInterfaceBase, params: Params | None = None) -> None: enforce_torque = _enforce_torque_lateral_control(CI.CP, params) nnlc_enabled = _initialize_neural_network_lateral_control(CI.CP, CI.CP_SP, params) _initialize_intelligent_cruise_button_management(CI.CP, CI.CP_SP, params) @@ -123,6 +129,7 @@ def initialize_params(params) -> list[dict[str, Any]]: # tesla keys.extend([ "TeslaCoopSteering", + "TeslaMadsScreenButton", ]) # toyota diff --git a/openpilot/sunnypilot/selfdrive/car/tests/test_cruise_mode.py b/openpilot/sunnypilot/selfdrive/car/tests/test_cruise_mode.py index 3f3701b3ae..b5cd9f6f87 100644 --- a/openpilot/sunnypilot/selfdrive/car/tests/test_cruise_mode.py +++ b/openpilot/sunnypilot/selfdrive/car/tests/test_cruise_mode.py @@ -1,5 +1,6 @@ from opendbc.car.structs import car from openpilot.common.parameterized import parameterized_class +from openpilot.common.test import OpenpilotTestCase from openpilot.selfdrive.selfdrived.events import Events from openpilot.sunnypilot.selfdrive.car.cruise_helpers import CruiseHelper, DISTANCE_LONG_PRESS @@ -8,7 +9,7 @@ ButtonType = car.CarState.ButtonEvent.Type @parameterized_class(('openpilot_longitudinal',), [(True,)]) -class TestCruiseHelper: +class TestCruiseHelper(OpenpilotTestCase): def setup_method(self): self.CP = car.CarParams(openpilotLongitudinalControl=self.openpilot_longitudinal) self.cruise_helper = CruiseHelper(self.CP) diff --git a/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py b/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py index ce5d7dc124..56491e3518 100644 --- a/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py +++ b/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py @@ -1,8 +1,6 @@ -import pytest - from opendbc.car.structs import car from openpilot.common.constants import CV -from openpilot.common.parameterized import parameterized_class +from openpilot.common.parameterized import parameterized, parameterized_class from openpilot.common.params import Params from openpilot.selfdrive.car.cruise import V_CRUISE_INITIAL from openpilot.selfdrive.car.tests.test_cruise_speed import TestVCruiseHelper @@ -15,7 +13,7 @@ ButtonType = car.CarState.ButtonEvent.Type @parameterized_class(('pcm_cruise', 'pcm_cruise_speed'), [(False, True)]) class TestCustomAccIncrements(TestVCruiseHelper): def setup_method(self): - TestVCruiseHelper.setup_method(self) + TestVCruiseHelper.openpilot_setup_method(self) self.params = Params() self.reset_custom_params() @@ -67,7 +65,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): self.press_button_short(ButtonType.accelCruise) assert self.v_cruise_helper.v_cruise_kph == initial_speed + 1 - @pytest.mark.parametrize("increment", (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)) + @parameterized.expand((1, 2, 3, 4, 5, 6, 7, 8, 9, 10)) def test_custom_short_press_increments(self, increment): """Test custom short press increments (1-10)""" self.set_custom_increments(enabled=True, short_inc=increment, long_inc=5) @@ -84,7 +82,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): assert self.v_cruise_helper.v_cruise_kph == expected_speed - @pytest.mark.parametrize("increment", (1, 5, 10)) + @parameterized.expand((1, 5, 10)) def test_custom_long_press_increments(self, increment): """Test custom long press increments (1, 5, 10)""" self.set_custom_increments(enabled=True, short_inc=1, long_inc=increment) @@ -101,7 +99,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): assert self.v_cruise_helper.v_cruise_kph == expected_speed - @pytest.mark.parametrize("button_type", [ButtonType.accelCruise, ButtonType.decelCruise]) + @parameterized.expand([ButtonType.accelCruise, ButtonType.decelCruise]) def test_accel_decel_symmetry(self, button_type): """Test that acceleration and deceleration work symmetrically""" self.set_custom_increments(enabled=True, short_inc=3, long_inc=5) diff --git a/openpilot/sunnypilot/selfdrive/controls/controlsd_ext.py b/openpilot/sunnypilot/selfdrive/controls/controlsd_ext.py index 33cc9e3ad8..344ae52a93 100644 --- a/openpilot/sunnypilot/selfdrive/controls/controlsd_ext.py +++ b/openpilot/sunnypilot/selfdrive/controls/controlsd_ext.py @@ -52,7 +52,7 @@ class ControlsExt(ModelStateBase): self.blinker_pause_lateral.get_params() if self.CP.lateralTuning.which() == 'torque': - self.lat_delay = get_lat_delay(self.params, sm["liveDelay"].lateralDelay) + self.lat_delay = get_lat_delay(self.params, sm["lateralDelay"].lateralDelay) self._param_update_time = time.monotonic() diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/dec.py b/openpilot/sunnypilot/selfdrive/controls/lib/dec/dec.py index 1ba5ab0618..fb854edae8 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/dec/dec.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/dec/dec.py @@ -1,5 +1,5 @@ """ -Copyright (c) 2021-, rav4kumar, Haibin Wen, sunnypilot, and a number of other contributors. +Copyright (c) 2021-, rav4kumar, 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. diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/pytest_dynamic_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/pytest_dynamic_controller.py deleted file mode 100644 index f9da39c03b..0000000000 --- a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/pytest_dynamic_controller.py +++ /dev/null @@ -1,94 +0,0 @@ -import pytest - -from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController - -class MockLeadOne: - def __init__(self, status=0.0): - self.status = status - -class MockRadarState: - def __init__(self, status=0.0): - self.leadOne = MockLeadOne(status=status) - -class MockCarState: - def __init__(self, vEgo=0.0, vCruise=0.0, standstill=False): - self.vEgo = vEgo - self.vCruise = vCruise - self.standstill = standstill - -class MockModelData: - def __init__(self, valid=True): - size = 33 if valid else 10 # incomplete if invalid - self.position = type("Pos", (), {"x": [0.0] * size})() - self.orientation = type("Ori", (), {"x": [0.0] * size})() - -class MockSelfDriveState: - def __init__(self, experimentalMode=False): - self.experimentalMode = experimentalMode - -class MockParams: - def get_bool(self, name): - return True - -@pytest.fixture -def default_sm(): - sm = { - 'carState': MockCarState(vEgo=10.0, vCruise=20.0), - 'radarState': MockRadarState(status=1.0), - 'modelV2': MockModelData(valid=True), - 'selfdriveState': MockSelfDriveState(experimentalMode=True), - } - return sm - -@pytest.fixture -def mock_cp(): - class CP: - radarUnavailable = False - return CP() - -@pytest.fixture -def mock_mpc(): - class MPC: - crash_cnt = 0 - return MPC() - -# Fake Kalman Filter that always returns a given value -class FakeKalman: - def __init__(self, value=1.0): - self.value = value - def add_data(self, v): pass - def get_value(self): return self.value - def get_confidence(self): return 1.0 - def reset_data(self): pass - -def test_initial_mode_is_acc(mock_cp, mock_mpc): - controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) - assert controller.mode() == "acc" - -def test_standstill_triggers_blended(mock_cp, mock_mpc, default_sm): - controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) - default_sm['carState'].standstill = True - for _ in range(10): - controller.update(default_sm) - assert controller.mode() == "blended" - -def test_emergency_blended_on_fcw(mock_cp, mock_mpc, default_sm): - controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) - mock_mpc.crash_cnt = 1 # simulate FCW - for _ in range(2): - controller.update(default_sm) - assert controller.mode() == "blended" - -def test_radarless_slowdown_triggers_blended(mock_cp, mock_mpc, default_sm): - mock_cp.radarUnavailable = True - controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) - - # Force conditions to simulate slowdown - controller._slow_down_filter = FakeKalman(value=1.0) # Ensure urgency triggers slowdown - controller._v_ego_kph = 35.0 - default_sm['modelV2'] = MockModelData(valid=False) # Incomplete trajectory - - for _ in range(3): - controller.update(default_sm) - - assert controller.mode() == "blended" diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dec_planner_gate.py b/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dec_planner_gate.py new file mode 100644 index 0000000000..1f5c577028 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dec_planner_gate.py @@ -0,0 +1,112 @@ +""" +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 typing import cast + +from openpilot.cereal import custom, messaging +from opendbc.car import structs +from openpilot.common.test import OpenpilotTestCase +from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner, LongitudinalPlanSource +from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController + +V_EGO = 20.0 +E2E_ACCEL = -3.0 # low enough that e2e wins the min() whenever it is a candidate + + +class MockDec: + def __init__(self, active: bool, mode: str): + self._active = active + self._mode = mode + + def update(self, sm): + pass + + def active(self) -> bool: + return self._active + + def mode(self) -> str: + return self._mode + + def enabled(self) -> bool: + return True + + +class MockSubMaster(dict): + def __init__(self, services: dict): + super().__init__(services) + self.valid = dict.fromkeys(services, True) + self.logMonoTime = dict.fromkeys(services, 0) + self.updated = dict.fromkeys(services, True) + self.recv_frame = dict.fromkeys(services, 1) + + def all_checks(self, service_list=None) -> bool: + return True + + +def build_sm(experimental_mode: bool) -> MockSubMaster: + services = {} + for service in ("radarState", "controlsState", "vehicleParameters", "carStateSP", + "liveMapDataSP", "gpsLocationExternal", "gpsLocation"): + services[service] = getattr(messaging.new_message(service), service) + + car_state = messaging.new_message('carState') + car_state.carState.vEgo = V_EGO + car_state.carState.vCruise = 100.0 + car_state.carState.vCruiseCluster = 100.0 + services['carState'] = car_state.carState.as_reader() + + selfdrive_state = messaging.new_message('selfdriveState') + selfdrive_state.selfdriveState.experimentalMode = experimental_mode + selfdrive_state.selfdriveState.enabled = True + services['selfdriveState'] = selfdrive_state.selfdriveState.as_reader() + + car_control = messaging.new_message('carControl') + car_control.carControl.enabled = True + services['carControl'] = car_control.carControl.as_reader() + + model = messaging.new_message('modelV2') + model.modelV2.orientationRate.z = [0.01] * 33 # nonzero: a straight path divides by zero in SCC vision + model.modelV2.velocity.x = [V_EGO] * 33 + model.modelV2.position.x = [float(i) for i in range(33)] + model.modelV2.action.desiredAcceleration = E2E_ACCEL + services['modelV2'] = model.modelV2.as_reader() + + return MockSubMaster(services) + + +def build_planner(dec_active: bool, dec_mode: str) -> LongitudinalPlanner: + CP = structs.CarParams() + CP.steerRatio = 15.0 + CP.wheelbase = 2.7 + CP.longitudinalActuatorDelay = 0.2 + CP_SP = custom.CarParamsSP.new_message().as_reader() + + planner = LongitudinalPlanner(CP, CP_SP, init_v=V_EGO) + planner.dec = cast(DynamicExperimentalController, MockDec(dec_active, dec_mode)) + return planner + + +class TestDecPlannerGate(OpenpilotTestCase): + """The e2e candidate must be gated on is_e2e(), not raw experimentalMode.""" + + def _source(self, experimental_mode: bool, dec_active: bool, dec_mode: str) -> LongitudinalPlanSource: + planner = build_planner(dec_active, dec_mode) + planner.update(build_sm(experimental_mode)) + return planner.mpc.source + + def test_no_e2e_when_experimental_mode_off(self): + assert self._source(False, False, 'acc') != LongitudinalPlanSource.e2e + + def test_e2e_when_dec_inactive(self): + # DEC off: behavior must match upstream + assert self._source(True, False, 'acc') == LongitudinalPlanSource.e2e + + def test_e2e_when_dec_blended(self): + assert self._source(True, True, 'blended') == LongitudinalPlanSource.e2e + + def test_no_e2e_when_dec_holds_acc(self): + # the regression + assert self._source(True, True, 'acc') != LongitudinalPlanSource.e2e diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dynamic_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dynamic_controller.py new file mode 100644 index 0000000000..4fec6eaa52 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dynamic_controller.py @@ -0,0 +1,91 @@ +from openpilot.common.test import OpenpilotTestCase +from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController + +class MockLeadOne: + def __init__(self, present=0.0): + self.present = present + +class MockRadarState: + def __init__(self, present=0.0): + self.leadOne = MockLeadOne(present=present) + +class MockCarState: + def __init__(self, vEgo=0.0, vCruise=0.0, standstill=False): + self.vEgo = vEgo + self.vCruise = vCruise + self.standstill = standstill + +class MockModelData: + def __init__(self, valid=True): + size = 33 if valid else 10 # incomplete if invalid + self.position = type("Pos", (), {"x": [0.0] * size})() + self.orientation = type("Ori", (), {"x": [0.0] * size})() + +class MockSelfDriveState: + def __init__(self, experimentalMode=False): + self.experimentalMode = experimentalMode + +class MockParams: + def get_bool(self, name): + return True + +def default_sm(): + sm = { + 'carState': MockCarState(vEgo=10.0, vCruise=20.0), + 'radarState': MockRadarState(present=1.0), + 'modelV2': MockModelData(valid=True), + 'selfdriveState': MockSelfDriveState(experimentalMode=True), + } + return sm + +def mock_cp(): + class CP: + radarUnavailable = False + return CP() + +def mock_mpc(): + class MPC: + crash_cnt = 0 + return MPC() + +# Fake Kalman Filter that always returns a given value +class FakeKalman: + def __init__(self, value=1.0): + self.value = value + def add_data(self, v): pass + def get_value(self): return self.value + def get_confidence(self): return 1.0 + def reset_data(self): pass + +class TestDynamicExperimentalController(OpenpilotTestCase): + def test_initial_mode_is_acc(self, mock_cp, mock_mpc): + controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) + assert controller.mode() == "acc" + + def test_standstill_triggers_blended(self, mock_cp, mock_mpc, default_sm): + controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) + default_sm['carState'].standstill = True + for _ in range(10): + controller.update(default_sm) + assert controller.mode() == "blended" + + def test_emergency_blended_on_fcw(self, mock_cp, mock_mpc, default_sm): + controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) + mock_mpc.crash_cnt = 1 # simulate FCW + for _ in range(2): + controller.update(default_sm) + assert controller.mode() == "blended" + + def test_radarless_slowdown_triggers_blended(self, mock_cp, mock_mpc, default_sm): + mock_cp.radarUnavailable = True + controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) + + # Force conditions to simulate slowdown + controller._slow_down_filter = FakeKalman(value=1.0) # ty: ignore[invalid-assignment] + controller._v_ego_kph = 35.0 + default_sm['modelV2'] = MockModelData(valid=False) # Incomplete trajectory + + for _ in range(3): + controller.update(default_sm) + + assert controller.mode() == "blended" diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext.py b/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext.py index 39525b3b8e..50add19cd2 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext.py @@ -33,6 +33,7 @@ class LatControlTorqueExt(NeuralNetworkLateralControl, LatControlTorqueExtOverri self._output_torque = output_torque self.update_calculations(CS, VM, desired_lateral_accel) + self.update_jerk_aware_torque_control(CS, roll_compensation, gravity_adjusted_lateral_accel) self.update_neural_network_feedforward(CS, params, calibrated_pose) return self._pid_log, self._output_torque diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py b/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py index df773889a9..31ac615db8 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py @@ -132,3 +132,10 @@ class LatControlTorqueExtBase: self.lat_accel_friction_factor = 1.0 self.lateral_jerk_setpoint = self.lat_jerk_friction_factor * self.lookahead_lateral_jerk self.lateral_jerk_measurement = self.lat_jerk_friction_factor * self.actual_lateral_jerk + + def update_output_torque(self, CS): + freeze_integrator = self._steer_limited_by_safety or CS.steeringPressed or CS.vEgo < 5 + self._output_torque = self._pid.update(self._pid_log.error, + feedforward=self._ff, + speed=CS.vEgo, + freeze_integrator=freeze_integrator) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_jerk_aware.py b/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_jerk_aware.py new file mode 100644 index 0000000000..8d780ed4cc --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_jerk_aware.py @@ -0,0 +1,45 @@ +""" +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 opendbc.car.lateral import FRICTION_THRESHOLD +from opendbc.sunnypilot.car.interfaces import LatControlInputs +from opendbc.sunnypilot.car.lateral_ext import get_friction as get_friction_in_torque_space +from openpilot.common.params import Params + +from openpilot.sunnypilot.selfdrive.controls.lib.latcontrol_torque_ext_base import LatControlTorqueExtBase + + +class LatControlTorqueJerkAware(LatControlTorqueExtBase): + def __init__(self, lac_torque, CP, CP_SP, CI): + super().__init__(lac_torque, CP, CP_SP, CI) + self.params = Params() + self._jerk_aware_enabled = self.params.get_bool("LateralJerkTorqueController") + + def update_limits(self): + if not self._jerk_aware_enabled: + return + self._pid.set_limits(self.lac_torque.steer_max, -self.lac_torque.steer_max) + + def update_jerk_aware_torque_control(self, CS, roll_compensation, gravity_adjusted_lateral_accel): + if not self._jerk_aware_enabled: + return + + torque_from_setpoint = self.torque_from_lateral_accel_in_torque_space( + LatControlInputs(self._setpoint, roll_compensation, CS.vEgo, CS.aEgo), self.torque_params, gravity_adjusted=False + ) + torque_from_measurement = self.torque_from_lateral_accel_in_torque_space( + LatControlInputs(self._measurement, roll_compensation, CS.vEgo, CS.aEgo), self.torque_params, gravity_adjusted=False + ) + + self._pid_log.error = float(torque_from_setpoint - torque_from_measurement) # ty: ignore[invalid-assignment] + self._ff = self.torque_from_lateral_accel_in_torque_space( + LatControlInputs(gravity_adjusted_lateral_accel, roll_compensation, CS.vEgo, CS.aEgo), self.torque_params, gravity_adjusted=True + ) + + friction_input = self.update_friction_input(self._desired_lateral_accel, self._actual_lateral_accel) + self._ff += get_friction_in_torque_space(friction_input, self._lateral_accel_deadzone, FRICTION_THRESHOLD, self.torque_params) + + self.update_output_torque(CS) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py b/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py index 4d9e4492f9..b6ee7baabe 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py @@ -50,7 +50,7 @@ class LatControlTorque(LatControl): self.extension = LatControlTorqueExt(self, CP, CP_SP, CI) - def update_live_torque_params(self, latAccelFactor, latAccelOffset, friction): + def update_torque_parameters(self, latAccelFactor, latAccelOffset, friction): self.torque_params.latAccelFactor = latAccelFactor self.torque_params.latAccelOffset = latAccelOffset self.torque_params.friction = friction @@ -82,7 +82,7 @@ class LatControlTorque(LatControl): future_desired_lateral_accel = desired_curvature * CS.vEgo ** 2 self.lat_accel_request_buffer.append(future_desired_lateral_accel) gravity_adjusted_future_lateral_accel = future_desired_lateral_accel - roll_compensation - desired_lateral_jerk = (future_desired_lateral_accel - expected_lateral_accel) / lat_delay + desired_lateral_jerk = (future_desired_lateral_accel - expected_lateral_accel) / max(lat_delay, self.dt) measurement = measured_curvature * CS.vEgo ** 2 measurement_rate = self.measurement_rate_filter.update((measurement - self.previous_measurement) / self.dt) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py b/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py index 1738a11e49..9684c86688 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py @@ -14,7 +14,8 @@ from opendbc.sunnypilot.car.lateral_ext import get_friction as get_friction_in_t from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params from openpilot.selfdrive.modeld.constants import ModelConstants -from openpilot.sunnypilot.selfdrive.controls.lib.latcontrol_torque_ext_base import LatControlTorqueExtBase, sign +from openpilot.sunnypilot.selfdrive.controls.lib.latcontrol_torque_ext_base import sign +from openpilot.sunnypilot.selfdrive.controls.lib.latcontrol_torque_jerk_aware import LatControlTorqueJerkAware from openpilot.sunnypilot.selfdrive.controls.lib.nnlc.helpers import MOCK_MODEL_PATH from openpilot.sunnypilot.selfdrive.controls.lib.nnlc.model import NNTorqueModel @@ -31,17 +32,18 @@ def roll_pitch_adjust(roll, pitch): return roll * math.cos(pitch) -class NeuralNetworkLateralControl(LatControlTorqueExtBase): +class NeuralNetworkLateralControl(LatControlTorqueJerkAware): def __init__(self, lac_torque, CP, CP_SP, CI): super().__init__(lac_torque, CP, CP_SP, CI) self.params = Params() self.enabled = self.params.get_bool("NeuralNetworkLateralControl") - self.has_nn_model = CP_SP.neuralNetworkLateralControl.model.path != MOCK_MODEL_PATH + model_path = CP_SP.neuralNetworkLateralControl.model.path + self.has_nn_model = model_path not in (MOCK_MODEL_PATH, '') # NN model takes current v_ego, lateral_accel, lat accel/jerk error, roll, and past/future/planned data # of lat accel and roll # Past value is computed using previous desired lat accel and observed roll - self.model = NNTorqueModel(CP_SP.neuralNetworkLateralControl.model.path) + self.model = NNTorqueModel(model_path) if self.has_nn_model else None self.pitch = FirstOrderFilter(0.0, 0.5, 0.01) self.pitch_last = 0.0 @@ -64,6 +66,7 @@ class NeuralNetworkLateralControl(LatControlTorqueExtBase): return self.enabled and self.model_valid and self.has_nn_model def update_limits(self): + super().update_limits() if not self._nnlc_enabled: return @@ -78,19 +81,12 @@ class NeuralNetworkLateralControl(LatControlTorqueExtBase): self.torque_params, gravity_adjusted=False) torque_from_measurement = self.torque_from_lateral_accel_in_torque_space(LatControlInputs(self._measurement, self._roll_compensation, CS.vEgo, CS.aEgo), self.torque_params, gravity_adjusted=False) - self._pid_log.error = float(torque_from_setpoint - torque_from_measurement) + self._pid_log.error = float(torque_from_setpoint - torque_from_measurement) # ty: ignore[invalid-assignment] self._ff = self.torque_from_lateral_accel_in_torque_space(LatControlInputs(self._gravity_adjusted_lateral_accel, self._roll_compensation, CS.vEgo, CS.aEgo), self.torque_params, gravity_adjusted=True) self._ff += get_friction_in_torque_space(self._desired_lateral_accel - self._actual_lateral_accel, self._lateral_accel_deadzone, FRICTION_THRESHOLD, self.torque_params) - def update_output_torque(self, CS): - freeze_integrator = self._steer_limited_by_safety or CS.steeringPressed or CS.vEgo < 5 - self._output_torque = self._pid.update(self._pid_log.error, - feedforward=self._ff, - speed=CS.vEgo, - freeze_integrator=freeze_integrator) - def update_neural_network_feedforward(self, CS, params, calibrated_pose) -> None: if not self._nnlc_enabled: return @@ -132,7 +128,7 @@ class NeuralNetworkLateralControl(LatControlTorqueExtBase): + past_rolls + future_rolls torque_from_setpoint = self.model.evaluate(nnff_setpoint_input) torque_from_measurement = self.model.evaluate(nnff_measurement_input) - self._pid_log.error = torque_from_setpoint - torque_from_measurement + self._pid_log.error = torque_from_setpoint - torque_from_measurement # ty: ignore[invalid-assignment] # The "pure" NNLC error response can be too weak for cars whose models were trained # with a lack of high-magnitude lateral acceleration data, for which the NNLC model @@ -148,7 +144,7 @@ class NeuralNetworkLateralControl(LatControlTorqueExtBase): nnff_error_input = [CS.vEgo, self._setpoint - self._measurement, self.lateral_jerk_setpoint - self.lateral_jerk_measurement, 0.0] torque_from_error = self.model.evaluate(nnff_error_input) if sign(self._pid_log.error) == sign(torque_from_error) and abs(self._pid_log.error) < abs(torque_from_error): - self._pid_log.error = self._pid_log.error * (1.0 - error_blend_factor) + torque_from_error * error_blend_factor + self._pid_log.error = self._pid_log.error * (1.0 - error_blend_factor) + torque_from_error * error_blend_factor # ty: ignore[invalid-assignment] # compute feedforward (same as nn setpoint output) friction_input = self.update_friction_input(self._setpoint, self._measurement) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_fingerprint.py b/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_fingerprint.py index 07e7d2852d..8eaa90babd 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_fingerprint.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_fingerprint.py @@ -7,6 +7,7 @@ from opendbc.car.tesla.values import CAR as TESLA from openpilot.common.parameterized import parameterized from openpilot.common.params import Params from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces +from openpilot.common.test import OpenpilotTestCase FINGERPRINT_EXACT_MATCH = [HONDA.HONDA_CIVIC_BOSCH, TOYOTA.TOYOTA_RAV4_TSS2_2022, HYUNDAI.HYUNDAI_IONIQ_5] @@ -14,7 +15,7 @@ FINGERPRINT_FUZZY_MATCH = [HONDA.HONDA_CIVIC_BOSCH_DIESEL, HYUNDAI.GENESIS_G70_2 FINGERPRINT_ANGLE_NO_MATCH = [TOYOTA.TOYOTA_RAV4_TSS2_2023, NISSAN.NISSAN_LEAF, TESLA.TESLA_MODEL_3] -class TestNNLCFingerprintBase: +class TestNNLCFingerprintBase(OpenpilotTestCase): @staticmethod def _setup_platform(car_name): diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_load_model.py b/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_load_model.py index 2f4e0d6993..4e0da4eeed 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_load_model.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_load_model.py @@ -8,9 +8,10 @@ from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.car.helpers import convert_to_capnp from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces +from openpilot.common.test import OpenpilotTestCase -class TestNNTorqueModel: +class TestNNTorqueModel(OpenpilotTestCase): @parameterized.expand([HONDA.HONDA_CIVIC, TOYOTA.TOYOTA_RAV4, HYUNDAI.HYUNDAI_SANTA_CRUZ_1ST_GEN]) def test_load_model(self, car_name): diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py b/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py index 56fd2f9ce7..4782b0c4f3 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py @@ -14,9 +14,10 @@ from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.car.helpers import convert_to_capnp from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.selfdrive.locationd.helpers import Pose -from openpilot.common.mock.generators import generate_livePose +from openpilot.common.mock.generators import generate_deviceMotion from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces from openpilot.selfdrive.modeld.constants import ModelConstants +from openpilot.common.test import OpenpilotTestCase def generate_modelV2(): @@ -42,7 +43,7 @@ def generate_modelV2(): return model -class TestNeuralNetworkLateralControl: +class TestNeuralNetworkLateralControl(OpenpilotTestCase): @parameterized.expand([HONDA.HONDA_CIVIC, TOYOTA.TOYOTA_RAV4, HYUNDAI.HYUNDAI_SANTA_CRUZ_1ST_GEN, GM.CHEVROLET_BOLT_EUV]) def test_saturation(self, car_name): @@ -66,10 +67,10 @@ class TestNeuralNetworkLateralControl: CS.vEgo = 30 CS.steeringPressed = False - params = log.LiveParametersData.new_message() + params = log.VehicleParameters.new_message() - lp = generate_livePose() - pose = Pose.from_live_pose(lp.livePose) + lp = generate_deviceMotion() + pose = Pose.from_device_motion(lp.deviceMotion) mdl = generate_modelV2() sm = {'modelV2': mdl.modelV2} @@ -81,7 +82,7 @@ class TestNeuralNetworkLateralControl: for _ in range(1000): controller.extension.update_model_v2(model_v2) controller.extension.update_lateral_lag(test_lag) - controller.update_live_torque_params(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) + controller.update_torque_parameters(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) controller.extension.update_limits() _, _, lac_log = controller.update(True, CS, VM, params, False, 0, pose, True, 0.2) assert lac_log.saturated @@ -89,7 +90,7 @@ class TestNeuralNetworkLateralControl: for _ in range(1000): controller.extension.update_model_v2(model_v2) controller.extension.update_lateral_lag(test_lag) - controller.update_live_torque_params(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) + controller.update_torque_parameters(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) controller.extension.update_limits() _, _, lac_log = controller.update(True, CS, VM, params, False, 0, pose, False, 0.2) assert not lac_log.saturated @@ -97,7 +98,7 @@ class TestNeuralNetworkLateralControl: for _ in range(1000): controller.extension.update_model_v2(model_v2) controller.extension.update_lateral_lag(test_lag) - controller.update_live_torque_params(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) + controller.update_torque_parameters(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) controller.extension.update_limits() _, _, lac_log = controller.update(True, CS, VM, params, False, 1, pose, False, 0.2) assert lac_log.saturated diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/relc.py b/openpilot/sunnypilot/selfdrive/controls/lib/relc.py new file mode 100644 index 0000000000..031e751b43 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/controls/lib/relc.py @@ -0,0 +1,98 @@ +""" +Copyright (c) 2021-, rav4kumar, 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 numpy as np + +from openpilot.common.constants import CV +from openpilot.common.realtime import DT_MDL +from openpilot.common.params import Params + +NEARSIDE_PROB = 0.2 +EDGE_PROB = 0.35 +EDGE_REACTION_TIME = 1.0 +EDGE_CLEAR_TIME = 0.3 +MIN_SPEED = 20 * CV.MPH_TO_MS +VEHICLE_EDGE_MARGIN = 1.08 +EDGE_CLEARANCE = 3.7 + + +class RoadEdgeLaneChangeController: + def __init__(self): + self.params = Params() + self.enabled = self.params.get_bool("RoadEdgeLaneChangeEnabled") + self.param_read_counter = 0 + self.left_edge_detected = False + self.right_edge_detected = False + self.left_edge_timer = 0.0 + self.right_edge_timer = 0.0 + self.left_clear_timer = 0.0 + self.right_clear_timer = 0.0 + + def read_params(self) -> None: + self.enabled = self.params.get_bool("RoadEdgeLaneChangeEnabled") + + def update_params(self) -> None: + if self.param_read_counter % 50 == 0: + self.read_params() + self.param_read_counter += 1 + + def reset(self) -> None: + self.left_edge_detected = False + self.right_edge_detected = False + self.left_edge_timer = 0.0 + self.right_edge_timer = 0.0 + self.left_clear_timer = 0.0 + self.right_clear_timer = 0.0 + + def update(self, road_edge_stds, lane_line_probs, v_ego: float, road_edges=None) -> None: + self.update_params() + + if not self.enabled or v_ego < MIN_SPEED: + self.reset() + return + + left_edge_prob = np.clip(1.0 - road_edge_stds[0], 0.0, 1.0) + right_edge_prob = np.clip(1.0 - road_edge_stds[1], 0.0, 1.0) + left_lane_prob = lane_line_probs[0] + right_lane_prob = lane_line_probs[3] + + if road_edges is not None and len(road_edges) == 2 and len(road_edges[0].y) > 0 and len(road_edges[1].y) > 0: + left_clearance = abs(road_edges[0].y[0]) - VEHICLE_EDGE_MARGIN + right_clearance = abs(road_edges[1].y[0]) - VEHICLE_EDGE_MARGIN + else: + left_clearance = 0.0 + right_clearance = 0.0 + + left_cond = left_edge_prob > EDGE_PROB and left_lane_prob < NEARSIDE_PROB and left_clearance < EDGE_CLEARANCE + right_cond = right_edge_prob > EDGE_PROB and right_lane_prob < NEARSIDE_PROB and right_clearance < EDGE_CLEARANCE + + if left_cond: + self.left_edge_timer = min(self.left_edge_timer + DT_MDL, EDGE_REACTION_TIME + EDGE_CLEAR_TIME) + self.left_clear_timer = 0.0 + if self.left_edge_timer > EDGE_REACTION_TIME: + self.left_edge_detected = True + else: + self.left_clear_timer += DT_MDL + if self.left_clear_timer > EDGE_CLEAR_TIME: + self.left_edge_timer = 0.0 + self.left_edge_detected = False + + if right_cond: + self.right_edge_timer = min(self.right_edge_timer + DT_MDL, EDGE_REACTION_TIME + EDGE_CLEAR_TIME) + self.right_clear_timer = 0.0 + if self.right_edge_timer > EDGE_REACTION_TIME: + self.right_edge_detected = True + else: + self.right_clear_timer += DT_MDL + if self.right_clear_timer > EDGE_CLEAR_TIME: + self.right_edge_timer = 0.0 + self.right_edge_detected = False + + def update_and_fill(self, modelv2, mdv2sp, v_ego): + self.update(modelv2.roadEdgeStds, modelv2.laneLineProbs, v_ego, modelv2.roadEdges) + mdv2sp.leftLaneChangeEdgeBlock = self.left_edge_detected + mdv2sp.rightLaneChangeEdgeBlock = self.right_edge_detected + return self.left_edge_detected, self.right_edge_detected diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py index 65e157bdbc..f3ed0fc07b 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py @@ -151,8 +151,8 @@ class SmartCruiseControlMap: a = 0.5 * TARGET_JERK b = self.a_ego c = self.v_ego - tv - t_a = -1 * ((b**2 - 4 * a * c) ** 0.5 + b) / 2 * a - t_b = ((b**2 - 4 * a * c) ** 0.5 - b) / 2 * a + t_a = -1 * ((b**2 - 4 * a * c) ** 0.5 + b) / (2 * a) + t_b = ((b**2 - 4 * a * c) ** 0.5 - b) / (2 * a) if not isinstance(t_a, complex) and t_a > 0: t = t_a else: diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py index 3b16c59bb5..a9e5cdd992 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py @@ -4,18 +4,22 @@ 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 json +import math import platform + from openpilot.cereal import custom from openpilot.common.params import Params from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET -from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.map_controller import SmartCruiseControlMap +from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.map_controller import R, SmartCruiseControlMap +from openpilot.common.test import OpenpilotTestCase MapState = VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.MapState -class TestSmartCruiseControlMap: +class TestSmartCruiseControlMap(OpenpilotTestCase): def setup_method(self): self.params = Params() @@ -55,4 +59,17 @@ class TestSmartCruiseControlMap: self.scc_m.update(True, False, 0., 0., 0.) assert self.scc_m.state == VisionState.enabled + def test_moderate_curve(self): + # Regression: `... / 2 * a` parsed as `(.../2)*a` instead of `.../(2*a)`, + # making max_d ~11x too small so the moderate-curve branch never tripped. + # v_ego=25, a_ego=0, tv=24: fixed max_d≈45m vs buggy ≈4m at a 40m waypoint. + waypoint_lon_deg = (40.0 / R) * (180.0 / math.pi) + self.mem_params.put("LastGPSPosition", json.dumps({"latitude": 0.0, "longitude": 0.0}), block=True) + self.mem_params.put("MapTargetVelocities", + json.dumps([{"latitude": 0.0, "longitude": waypoint_lon_deg, "velocity": 24.0}]), block=True) + + self.scc_m.update(True, False, 25.0, 0.0, 30.0) + + self.assertAlmostEqual(self.scc_m.v_target, 24.0, delta=24.0 * 1e-6) + # TODO-SP: mock data from modelV2 to test other states diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_vision_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_vision_controller.py index de54a0fdca..610acd47df 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_vision_controller.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_vision_controller.py @@ -4,8 +4,10 @@ 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 typing import Any + import numpy as np -import pytest +from openpilot.common.parameterized import parameterized import openpilot.cereal.messaging as messaging from openpilot.cereal import custom, log @@ -15,6 +17,7 @@ from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET from openpilot.selfdrive.modeld.constants import ModelConstants from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control import MIN_V from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import SmartCruiseControlVision, _ENTERING_PRED_LAT_ACC_TH +from openpilot.common.test import OpenpilotTestCase VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.VisionState @@ -103,7 +106,7 @@ def generate_controlsState(): return controls_state -class TestSmartCruiseControlVision: +class TestSmartCruiseControlVision(OpenpilotTestCase): def setup_method(self): self.params = Params() @@ -113,7 +116,7 @@ class TestSmartCruiseControlVision: mdl = generate_modelV2() cs = generate_carState() controls_state = generate_controlsState() - self.sm = {'modelV2': mdl.modelV2, 'carState': cs.carState, 'controlsState': controls_state.controlsState} + self.sm: Any = {'modelV2': mdl.modelV2, 'carState': cs.carState, 'controlsState': controls_state.controlsState} def reset_params(self): self.params.put_bool("SmartCruiseControlVision", True, block=True) @@ -143,19 +146,11 @@ class TestSmartCruiseControlVision: self.scc_v.update(self.sm, True, False, 0., 0., 0.) assert self.scc_v.state == VisionState.enabled - @pytest.mark.parametrize( - "case, should_enter", - [ + @parameterized.expand([ ("p97_just_above_threshold", True), ("single_spike_filtered", False), ("persistent_high_values", True), - ], - ids=[ - "p97>threshold_enters", - "single_spike_max_large_but_p97_below_threshold", - "high_values_persist_trigger_entering", - ], - ) + ], names=["case", "should_enter"]) def test_max_pred_lat_acc_uses_p97_and_threshold(self, case, should_enter): n = len(ModelConstants.T_IDXS) th = float(_ENTERING_PRED_LAT_ACC_TH) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/helpers.py b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/helpers.py index 77d89251e2..848f99edc7 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/helpers.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/helpers.py @@ -23,7 +23,7 @@ def compare_cluster_target(v_cruise_cluster: float, target_set_speed: float, is_ return req_plus, req_minus -def set_speed_limit_assist_availability(CP: car.CarParams, CP_SP: custom.CarParamsSP, params: Params = None) -> bool: +def set_speed_limit_assist_availability(CP: car.CarParams, CP_SP: custom.CarParamsSP, params: Params | None = None) -> bool: if params is None: params = Params() diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_assist.py b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_assist.py index 1ff541167f..40c8c6fdf4 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_assist.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_assist.py @@ -91,7 +91,7 @@ class SpeedLimitAssist: self._plus_hold = 0. self._minus_hold = 0. - self._last_carstate_ts = 0. + self._release_toggle_prev = 0 # TODO-SP: SLA's own output_a_target for planner # Solution functions mapped to respective states @@ -146,16 +146,16 @@ class SpeedLimitAssist: set_speed_limit_assist_availability(self.CP, self.CP_SP, self.params) self.enabled = self.params.get("SpeedLimitMode", return_default=True) == Mode.assist - def update_car_state(self, CS: car.CarState) -> None: + def update_buttons(self, release_toggle: int) -> None: + released = self._release_toggle_prev ^ release_toggle + self._release_toggle_prev = release_toggle + if not released: + return now = time.monotonic() - self._last_carstate_ts = now - - for b in CS.buttonEvents: - if not b.pressed: - if b.type in CRUISE_BUTTONS_PLUS: - self._plus_hold = max(self._plus_hold, now + CRUISE_BUTTON_CONFIRM_HOLD) - elif b.type in CRUISE_BUTTONS_MINUS: - self._minus_hold = max(self._minus_hold, now + CRUISE_BUTTON_CONFIRM_HOLD) + if any((released >> b) & 1 for b in CRUISE_BUTTONS_PLUS): + self._plus_hold = max(self._plus_hold, now + CRUISE_BUTTON_CONFIRM_HOLD) + if any((released >> b) & 1 for b in CRUISE_BUTTONS_MINUS): + self._minus_hold = max(self._minus_hold, now + CRUISE_BUTTON_CONFIRM_HOLD) def _get_button_release(self, req_plus: bool, req_minus: bool) -> bool: now = time.monotonic() diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py index e46196654c..a226e0d120 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py @@ -152,9 +152,9 @@ class SpeedLimitResolver: self.distance_solutions[SpeedLimitSource.map] = distance_to_speed_limit_ahead def _get_source_solution_according_to_policy(self) -> custom.LongitudinalPlanSP.SpeedLimit.Source: - sources_for_policy = self._policy_to_sources_map[self.policy] + sources_for_policy = self._policy_to_sources_map[Policy(self.policy)] - if self.policy != Policy.combined: + if Policy(self.policy) != Policy.combined: # They are ordered in the order of preference, so we pick the first that's non-zero for source in sources_for_policy: if self.limit_solutions[source] > 0.: diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py index 7cd6fef524..f3d32a4d5b 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py @@ -5,11 +5,14 @@ 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 pytest +import time + +from openpilot.common.parameterized import parameterized from openpilot.cereal import custom from opendbc.car.car_helpers import interfaces from opendbc.car.rivian.values import CAR as RIVIAN +from opendbc.car.structs import car from opendbc.car.tesla.values import CAR as TESLA from opendbc.car.toyota.values import CAR as TOYOTA from openpilot.common.constants import CV @@ -21,8 +24,13 @@ from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfac from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import PCM_LONG_REQUIRED_MAX_SET_SPEED from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Mode from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_assist import SpeedLimitAssist, \ - PRE_ACTIVE_GUARD_PERIOD, ACTIVE_STATES + PRE_ACTIVE_GUARD_PERIOD, ACTIVE_STATES, CRUISE_BUTTON_CONFIRM_HOLD +from openpilot.sunnypilot.selfdrive.selfdrived.button_state_tracker import ButtonStateTracker from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP +from openpilot.common.test import OpenpilotTestCase + +ButtonEvent = car.CarState.ButtonEvent +ButtonType = car.CarState.ButtonEvent.Type SpeedLimitAssistState = custom.LongitudinalPlanSP.SpeedLimit.AssistState @@ -38,21 +46,10 @@ SPEED_LIMITS = { DEFAULT_CAR = TOYOTA.TOYOTA_RAV4_TSS2 -@pytest.fixture -def car_name(request): - return getattr(request, "param", DEFAULT_CAR) +class TestSpeedLimitAssist(OpenpilotTestCase): + car_name = DEFAULT_CAR - -@pytest.fixture(autouse=True) -def set_car_name_on_instance(request, car_name): - instance = getattr(request, "instance", None) - if instance: - instance.car_name = car_name - - -class TestSpeedLimitAssist: - - def setup_method(self, method): + def setup_method(self): self.params = Params() self.reset_custom_params() self.events_sp = EventsSP() @@ -62,7 +59,7 @@ class TestSpeedLimitAssist: self.pcm_long_max_set_speed = PCM_LONG_REQUIRED_MAX_SET_SPEED[self.sla.is_metric][1] # use 80 MPH for now self.speed_conv = CV.MS_TO_KPH if self.sla.is_metric else CV.MS_TO_MPH - def teardown_method(self, method): + def teardown_method(self): self.reset_state() def _setup_platform(self, car_name): @@ -105,13 +102,16 @@ class TestSpeedLimitAssist: assert not self.sla.is_active assert V_CRUISE_UNSET == self.sla.get_v_target_from_control() - @pytest.mark.parametrize("car_name", [RIVIAN.RIVIAN_R1, TESLA.TESLA_MODEL_Y], indirect=True) + @parameterized.expand([RIVIAN.RIVIAN_R1, TESLA.TESLA_MODEL_Y], names=["car_name"]) def test_disallowed_brands(self, car_name): """ Speed Limit Assist is disabled for the following brands and conditions: - All Tesla and is a release branch; - All Rivian """ + self.car_name = car_name + self.openpilot_setup_method() # rebuild the platform for this brand + assert not self.sla.enabled # stay disallowed even when the param may have changed from somewhere else @@ -276,3 +276,87 @@ class TestSpeedLimitAssist: assert self.sla.state in [SpeedLimitAssistState.preActive, SpeedLimitAssistState.active] elif initial_state in ACTIVE_STATES: assert self.sla.state in ACTIVE_STATES + + +class TestButtonStateTrackerSLAIntegration(OpenpilotTestCase): + + def setup_method(self): + + self.tracker = ButtonStateTracker() + self.params = Params() + self.params.put("IsReleaseSpBranch", True, block=True) + self.params.put("SpeedLimitMode", int(Mode.assist), block=True) + self.params.put_bool("IsMetric", False, block=True) + self.params.put("SpeedLimitOffsetType", 0, block=True) + self.params.put("SpeedLimitValueOffset", 0, block=True) + + CarInterface = interfaces[DEFAULT_CAR] + CP = CarInterface.get_non_essential_params(DEFAULT_CAR) + CP.openpilotLongitudinalControl = True + CP_SP = CarInterface.get_non_essential_params_sp(CP, DEFAULT_CAR) + self.sla = SpeedLimitAssist(CP, CP_SP) + + def _make_cs(self, events=None) -> car.CarState: + CS = car.CarState() + CS.buttonEvents = events or [] + return CS + + def _run_ctrl_frames(self, frames: list[car.CarState]) -> None: + for cs in frames: + self.tracker.update(cs) + + def test_button_confirm_via_tracker(self) -> None: + self._run_ctrl_frames([ + self._make_cs([ButtonEvent(type=ButtonType.accelCruise, pressed=True)]), + self._make_cs(), + self._make_cs([ButtonEvent(type=ButtonType.accelCruise, pressed=False)]), + self._make_cs(), + self._make_cs(), + ]) + self.sla.update_buttons(self.tracker.release_toggle) + assert self.sla._get_button_release(req_plus=True, req_minus=False) + + def test_rapid_press_release_between_polls(self) -> None: + self.sla.update_buttons(self.tracker.release_toggle) + + self._run_ctrl_frames([ + self._make_cs([ButtonEvent(type=ButtonType.decelCruise, pressed=True)]), + self._make_cs([ButtonEvent(type=ButtonType.decelCruise, pressed=False)]), + self._make_cs(), + self._make_cs(), + self._make_cs(), + ]) + self.sla.update_buttons(self.tracker.release_toggle) + assert self.sla._get_button_release(req_plus=False, req_minus=True) + + def test_multiple_releases_between_polls(self) -> None: + self.sla.update_buttons(self.tracker.release_toggle) + + self._run_ctrl_frames([ + self._make_cs([ + ButtonEvent(type=ButtonType.accelCruise, pressed=True), + ButtonEvent(type=ButtonType.decelCruise, pressed=True), + ]), + self._make_cs([ + ButtonEvent(type=ButtonType.accelCruise, pressed=False), + ButtonEvent(type=ButtonType.decelCruise, pressed=False), + ]), + ]) + self.sla.update_buttons(self.tracker.release_toggle) + assert self.sla._get_button_release(req_plus=True, req_minus=False) + assert self.sla._get_button_release(req_plus=False, req_minus=True) + + def test_no_false_positive_same_toggle(self) -> None: + self.sla.update_buttons(self.tracker.release_toggle) + self.sla.update_buttons(self.tracker.release_toggle) + assert not self.sla._get_button_release(req_plus=True, req_minus=False) + assert not self.sla._get_button_release(req_plus=False, req_minus=True) + + def test_button_confirm_expires(self) -> None: + self._run_ctrl_frames([ + self._make_cs([ButtonEvent(type=ButtonType.accelCruise, pressed=True)]), + self._make_cs([ButtonEvent(type=ButtonType.accelCruise, pressed=False)]), + ]) + self.sla.update_buttons(self.tracker.release_toggle) + time.sleep(CRUISE_BUTTON_CONFIRM_HOLD + 0.1) + assert not self.sla._get_button_release(req_plus=True, req_minus=False) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_resolver.py b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_resolver.py index a18880c620..884749c72e 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_resolver.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_resolver.py @@ -7,26 +7,26 @@ See the LICENSE.md file in the root directory for more details. import random import time -import pytest -from pytest_mock import MockerFixture +from openpilot.common.parameterized import parameterized from openpilot.cereal import custom from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import LIMIT_MAX_MAP_DATA_AGE from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_resolver import SpeedLimitResolver, ALL_SOURCES from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Policy +from openpilot.common.test import OpenpilotTestCase SpeedLimitSource = custom.LongitudinalPlanSP.SpeedLimit.Source -def create_mock(properties, mocker: MockerFixture): +def create_mock(properties, mocker): mock = mocker.MagicMock() for _property, value in properties.items(): setattr(mock, _property, value) return mock -def setup_sm_mock(mocker: MockerFixture): +def setup_sm_mock(mocker): cruise_speed_limit = random.uniform(0, 120) live_map_data_limit = random.uniform(0, 120) @@ -58,21 +58,24 @@ def setup_sm_mock(mocker: MockerFixture): return sm_mock -parametrized_policies = pytest.mark.parametrize( - "policy, sm_key, function_key", [ +parametrized_policies = parameterized.expand( + [ (Policy.car_state_only, 'carStateSP', SpeedLimitSource.car), (Policy.car_state_priority, 'carStateSP', SpeedLimitSource.car), (Policy.map_data_only, 'liveMapDataSP', SpeedLimitSource.map), (Policy.map_data_priority, 'liveMapDataSP', SpeedLimitSource.map), ], - ids=lambda val: val.name if hasattr(val, 'name') else str(val) + names=["policy", "sm_key", "function_key"] ) -@pytest.mark.parametrize("resolver_class", [SpeedLimitResolver]) -class TestSpeedLimitResolverValidation: +def resolver_class(): + return SpeedLimitResolver - @pytest.mark.parametrize("policy", list(Policy), ids=lambda policy: policy.name) + +class TestSpeedLimitResolverValidation(OpenpilotTestCase): + + @parameterized.expand(list(Policy), names=["policy"]) def test_initial_state(self, resolver_class, policy): resolver = resolver_class() resolver.policy = policy @@ -82,7 +85,7 @@ class TestSpeedLimitResolverValidation: assert resolver.distance_solutions[source] == 0. @parametrized_policies - def test_resolver(self, resolver_class, policy, sm_key, function_key, mocker: MockerFixture): + def test_resolver(self, resolver_class, policy, sm_key, function_key, mocker): resolver = resolver_class() resolver.policy = policy sm_mock = setup_sm_mock(mocker) @@ -93,7 +96,7 @@ class TestSpeedLimitResolverValidation: assert resolver.speed_limit == source_speed_limit assert resolver.source == ALL_SOURCES[function_key] - def test_resolver_combined(self, resolver_class, mocker: MockerFixture): + def test_resolver_combined(self, resolver_class, mocker): resolver = resolver_class() resolver.policy = Policy.combined sm_mock = setup_sm_mock(mocker) @@ -108,7 +111,7 @@ class TestSpeedLimitResolverValidation: assert resolver.source == socket_to_source[minimum_key] @parametrized_policies - def test_parser(self, resolver_class, policy, sm_key, function_key, mocker: MockerFixture): + def test_parser(self, resolver_class, policy, sm_key, function_key, mocker): resolver = resolver_class() resolver.policy = policy sm_mock = setup_sm_mock(mocker) @@ -119,8 +122,8 @@ class TestSpeedLimitResolverValidation: assert resolver.limit_solutions[ALL_SOURCES[function_key]] == source_speed_limit assert resolver.distance_solutions[ALL_SOURCES[function_key]] == 0. - @pytest.mark.parametrize("policy", list(Policy), ids=lambda policy: policy.name) - def test_resolve_interaction_in_update(self, resolver_class, policy, mocker: MockerFixture): + @parameterized.expand(list(Policy), names=["policy"]) + def test_resolve_interaction_in_update(self, resolver_class, policy, mocker): v_ego = 50 resolver = resolver_class() resolver.policy = policy @@ -133,8 +136,8 @@ class TestSpeedLimitResolverValidation: assert resolver.distance is not None assert resolver.source is not None - @pytest.mark.parametrize("policy", list(Policy), ids=lambda policy: policy.name) - def test_old_map_data_ignored(self, resolver_class, policy, mocker: MockerFixture): + @parameterized.expand(list(Policy), names=["policy"]) + def test_old_map_data_ignored(self, resolver_class, policy, mocker): resolver = resolver_class() resolver.policy = policy sm_mock = mocker.MagicMock() diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_auto_lane_change.py b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_auto_lane_change.py index b4ec5041c8..0d95550412 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_auto_lane_change.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_auto_lane_change.py @@ -9,6 +9,7 @@ from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper, LaneChangeState, LaneChangeDirection from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeController, AutoLaneChangeMode, \ AUTO_LANE_CHANGE_TIMER, ONE_SECOND_DELAY +from openpilot.common.test import OpenpilotTestCase AUTO_LANE_CHANGE_TIMER_COMBOS = [ (AutoLaneChangeMode.NUDGELESS, AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.NUDGELESS]), @@ -19,7 +20,7 @@ AUTO_LANE_CHANGE_TIMER_COMBOS = [ ] -class TestAutoLaneChangeController: +class TestAutoLaneChangeController(OpenpilotTestCase): def setup_method(self): self.DH = DesireHelper() self.alc = AutoLaneChangeController(self.DH) @@ -85,24 +86,24 @@ class TestAutoLaneChangeController: @parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS) def test_timers(self, timer_state, timer_delay): - self._reset_states() - self.alc.lane_change_bsm_delay = False # BSM delay off - self.alc.lane_change_set_timer = timer_state + self._reset_states() + self.alc.lane_change_bsm_delay = False # BSM delay off + self.alc.lane_change_set_timer = timer_state - # Update controller once + # Update controller once + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + + # The timer should still be below the threshold after one update + assert not self.alc.auto_lane_change_allowed + + # Update enough times to exceed the threshold (seconds / DT_MDL) + num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold + for _ in range(num_updates): self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) - # The timer should still be below the threshold after one update - assert not self.alc.auto_lane_change_allowed - - # Update enough times to exceed the threshold (seconds / DT_MDL) - num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold - for _ in range(num_updates): - self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) - - # Now lane change should be allowed - assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay - assert self.alc.auto_lane_change_allowed + # Now lane change should be allowed + assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay + assert self.alc.auto_lane_change_allowed @parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS) def test_brake_pressed_disables_auto_lane_change(self, timer_state, timer_delay): @@ -169,32 +170,32 @@ class TestAutoLaneChangeController: @parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS) def test_disallow_continuous_auto_lane_change(self, timer_state, timer_delay): - self._reset_states() - self.alc.lane_change_bsm_delay = False # BSM delay off - self.alc.lane_change_set_timer = timer_state - num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold + self._reset_states() + self.alc.lane_change_bsm_delay = False # BSM delay off + self.alc.lane_change_set_timer = timer_state + num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold - # Update enough times to exceed the threshold (seconds / DT_MDL) - for _ in range(num_updates): - self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + # Update enough times to exceed the threshold (seconds / DT_MDL) + for _ in range(num_updates): + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) - # Now lane change should be allowed - assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay - assert self.alc.auto_lane_change_allowed + # Now lane change should be allowed + assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay + assert self.alc.auto_lane_change_allowed - # Simulate lane change is initiated - self.DH.lane_change_state = LaneChangeState.laneChangeStarting - self.alc.update_state() + # Simulate lane change is initiated + self.DH.lane_change_state = LaneChangeState.laneChangeStarting + self.alc.update_state() - # Simulate lane change is completed, and one_blinker stays on - self.DH.lane_change_state = LaneChangeState.preLaneChange - self.alc.update_state() + # Simulate lane change is completed, and one_blinker stays on + self.DH.lane_change_state = LaneChangeState.preLaneChange + self.alc.update_state() - # Update enough times to exceed the threshold (seconds / DT_MDL) - for _ in range(num_updates): - self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + # Update enough times to exceed the threshold (seconds / DT_MDL) + for _ in range(num_updates): + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) - assert not self.alc.auto_lane_change_allowed + assert not self.alc.auto_lane_change_allowed def test_auto_lane_change_mode_off_disallows_lane_change(self): """Test that OFF mode never allows auto lane change.""" diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_blinker_pause_lateral.py b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_blinker_pause_lateral.py index 7a72cfa1f2..4f0e6c03df 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_blinker_pause_lateral.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_blinker_pause_lateral.py @@ -8,9 +8,10 @@ from opendbc.car.structs import car from openpilot.common.constants import CV from openpilot.sunnypilot.selfdrive.controls.lib.blinker_pause_lateral import BlinkerPauseLateral +from openpilot.common.test import OpenpilotTestCase -class TestBlinkerPauseLateral: +class TestBlinkerPauseLateral(OpenpilotTestCase): def setup_method(self): self.blinker_pause_lateral = BlinkerPauseLateral() diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py index c3e96fd778..52c6cecbfc 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py @@ -1,73 +1,73 @@ -import pytest from openpilot.cereal import log, custom from openpilot.common.params import Params +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase -from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper +from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper, LaneChangeState, LaneChangeDirection from openpilot.sunnypilot.selfdrive.controls.lib.lane_turn_desire import LaneTurnController, LANE_CHANGE_SPEED_MIN from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeMode + TurnDirection = custom.ModelDataV2SP.TurnDirection -@pytest.mark.parametrize("left_blinker,right_blinker,v_ego,blindspot_left,blindspot_right,expected", [ - (True, False, 5, False, False, TurnDirection.turnLeft), - (False, True, 6, False, False, TurnDirection.turnRight), - (True, False, 9, False, False, TurnDirection.none), - (True, False, 7, True, False, TurnDirection.none), - (False, True, 6, False, True, TurnDirection.none), - (False, False, 5, False, False, TurnDirection.none), - (True, True, 5, False, False, TurnDirection.none), -]) -def test_lane_turn_desire_conditions(left_blinker, right_blinker, v_ego, blindspot_left, blindspot_right, expected): - dh = DesireHelper() - controller = LaneTurnController(dh) - controller.enabled = True - controller.lane_turn_value = LANE_CHANGE_SPEED_MIN - controller.turn_direction = TurnDirection.none - controller.update_lane_turn(blindspot_left, blindspot_right, left_blinker, right_blinker, v_ego) - assert controller.get_turn_direction() == expected +class TestLaneTurnDesire(OpenpilotTestCase): + @parameterized.expand([ + (True, False, 5, False, False, TurnDirection.turnLeft), + (False, True, 6, False, False, TurnDirection.turnRight), + (True, False, 9, False, False, TurnDirection.none), + (True, False, 7, True, False, TurnDirection.none), + (False, True, 6, False, True, TurnDirection.none), + (False, False, 5, False, False, TurnDirection.none), + (True, True, 5, False, False, TurnDirection.none), + ]) + def test_lane_turn_desire_conditions(self, left_blinker, right_blinker, v_ego, blindspot_left, blindspot_right, expected): + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = True + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + controller.update_lane_turn(blindspot_left, blindspot_right, left_blinker, right_blinker, v_ego) + assert controller.get_turn_direction() == expected + def test_lane_turn_desire_disabled(self): + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = False + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + controller.update_lane_turn(False, False, True, False, 7) + assert controller.get_turn_direction() == TurnDirection.none -def test_lane_turn_desire_disabled(): - dh = DesireHelper() - controller = LaneTurnController(dh) - controller.enabled = False - controller.lane_turn_value = LANE_CHANGE_SPEED_MIN - controller.turn_direction = TurnDirection.none - controller.update_lane_turn(False, False, True, False, 7) - assert controller.get_turn_direction() == TurnDirection.none + def test_lane_turn_overrides_lane_change(self): + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = True + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + # left turn desire + controller.update_lane_turn(False, False, True, False, 5) + assert controller.get_turn_direction() == TurnDirection.turnLeft + # right turn desire + controller.update_lane_turn(False, False, False, True, 6) + assert controller.get_turn_direction() == TurnDirection.turnRight + # no turn + controller.update_lane_turn(False, False, False, False, 7) + assert controller.get_turn_direction() == TurnDirection.none - -def test_lane_turn_overrides_lane_change(): - dh = DesireHelper() - controller = LaneTurnController(dh) - controller.enabled = True - controller.lane_turn_value = LANE_CHANGE_SPEED_MIN - controller.turn_direction = TurnDirection.none - # left turn desire - controller.update_lane_turn(False, False, True, False, 5) - assert controller.get_turn_direction() == TurnDirection.turnLeft - # right turn desire - controller.update_lane_turn(False, False, False, True, 6) - assert controller.get_turn_direction() == TurnDirection.turnRight - # no turn - controller.update_lane_turn(False, False, False, False, 7) - assert controller.get_turn_direction() == TurnDirection.none - - -@pytest.mark.parametrize("v_ego,expected", [ - (8.93, TurnDirection.turnLeft), # just below threshold - (8.96, TurnDirection.none), # above threshold - (8.95, TurnDirection.none), # just above threshold -]) -def test_lane_turn_desire_speed_boundary(v_ego, expected): - dh = DesireHelper() - controller = LaneTurnController(dh) - controller.enabled = True - controller.lane_turn_value = LANE_CHANGE_SPEED_MIN - controller.turn_direction = TurnDirection.none - controller.update_lane_turn(False, True, True, False, v_ego) - assert controller.get_turn_direction() == expected + @parameterized.expand([ + (8.93, TurnDirection.turnLeft), # just below threshold + (8.96, TurnDirection.none), # above threshold + (8.95, TurnDirection.none), # just above threshold + ]) + def test_lane_turn_desire_speed_boundary(self, v_ego, expected): + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = True + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + controller.update_lane_turn(False, True, True, False, v_ego) + assert controller.get_turn_direction() == expected class DummyCarState: @@ -83,31 +83,42 @@ class DummyCarState: self.brakePressed = brakePressed -@pytest.fixture def set_lane_turn_params(): params = Params() params.put("LaneTurnDesire", True) params.put("LaneTurnValue", 20.0) -@pytest.mark.parametrize("carstate, lateral_active, lane_change_prob, expected_desire", [ - # Lane turn desire overrides lane change desire - (DummyCarState(vEgo=5, leftBlinker=True, rightBlinker=False, leftBlindspot=False, rightBlindspot=False), True, 1.0, - log.Desire.turnLeft), - (DummyCarState(vEgo=7, leftBlinker=False, rightBlinker=True, leftBlindspot=False, rightBlindspot=False), True, 1.0, - log.Desire.turnRight), - # Lane change desire only (no turn desires) - (DummyCarState(vEgo=9, leftBlinker=True, rightBlinker=False, leftBlindspot=False, rightBlindspot=False, - steeringPressed=True, steeringTorque=1), True, 1.0, log.Desire.laneChangeLeft), - (DummyCarState(vEgo=9, leftBlinker=False, rightBlinker=True, leftBlindspot=False, rightBlindspot=False, - steeringPressed=True, steeringTorque=-1), True, 1.0, log.Desire.laneChangeRight), - # No desire (inactive) - (DummyCarState(vEgo=9, leftBlinker=False, rightBlinker=False), False, 1.0, log.Desire.none), - (DummyCarState(vEgo=4, leftBlinker=False, rightBlinker=False), True, 1.0, log.Desire.none), # No blinkers? no desire! -]) -def test_desire_helper_integration(carstate, lateral_active, lane_change_prob, expected_desire, set_lane_turn_params): - dh = DesireHelper() - dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE - for _ in range(10): - dh.update(carstate, lateral_active, lane_change_prob) - assert dh.desire == expected_desire # The first four tests were unit tests to test the controller, where this tests the integration in desire helpers +class TestDesireHelperIntegration(OpenpilotTestCase): + @parameterized.expand([ + # Lane turn desire overrides lane change desire + (DummyCarState(vEgo=5, leftBlinker=True, rightBlinker=False, leftBlindspot=False, rightBlindspot=False), True, 1.0, + log.Desire.turnLeft), + (DummyCarState(vEgo=7, leftBlinker=False, rightBlinker=True, leftBlindspot=False, rightBlindspot=False), True, 1.0, + log.Desire.turnRight), + # Lane change desire only (no turn desires) + (DummyCarState(vEgo=9, leftBlinker=True, rightBlinker=False, leftBlindspot=False, rightBlindspot=False, + steeringPressed=True, steeringTorque=1), True, 1.0, log.Desire.laneChangeLeft), + (DummyCarState(vEgo=9, leftBlinker=False, rightBlinker=True, leftBlindspot=False, rightBlindspot=False, + steeringPressed=True, steeringTorque=-1), True, 1.0, log.Desire.laneChangeRight), + # No desire (inactive) + (DummyCarState(vEgo=9, leftBlinker=False, rightBlinker=False), False, 1.0, log.Desire.none), + (DummyCarState(vEgo=4, leftBlinker=False, rightBlinker=False), True, 1.0, log.Desire.none), # No blinkers? no desire! + ], names=["carstate", "lateral_active", "lane_change_prob", "expected_desire"]) + def test_desire_helper_integration(self, carstate, lateral_active, lane_change_prob, expected_desire, set_lane_turn_params): + dh = DesireHelper() + dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE + for _ in range(10): + dh.update(carstate, lateral_active, lane_change_prob, + left_edge_detected=False, right_edge_detected=False) + assert dh.desire == expected_desire + + def test_edge_blocks_lane_change(self, set_lane_turn_params): + dh = DesireHelper() + dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE + carstate = DummyCarState(vEgo=15, leftBlinker=True, steeringPressed=True, steeringTorque=1) + for _ in range(10): + dh.update(carstate, True, 1.0, left_edge_detected=True, right_edge_detected=False) + assert dh.lane_change_state == LaneChangeState.preLaneChange + assert dh.lane_change_direction == LaneChangeDirection.left + assert dh.desire == log.Desire.none diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_latcontrol_torque_ext.py b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_latcontrol_torque_ext.py new file mode 100644 index 0000000000..b4f1137081 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_latcontrol_torque_ext.py @@ -0,0 +1,109 @@ +""" +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 numpy as np + +from openpilot.cereal import log, messaging +from opendbc.car.structs import car +from opendbc.car.car_helpers import interfaces +from opendbc.car.honda.values import CAR as HONDA +from opendbc.car.vehicle_model import VehicleModel +from openpilot.common.params import Params +from openpilot.common.realtime import DT_CTRL +from openpilot.selfdrive.car.helpers import convert_to_capnp +from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque +from openpilot.selfdrive.locationd.helpers import Pose +from openpilot.common.mock.generators import generate_deviceMotion +from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces +from openpilot.selfdrive.modeld.constants import ModelConstants +from openpilot.common.test import OpenpilotTestCase + + +def _make_controller(enhanced=False, nnlc=False): + params = Params() + params.put_bool("EnforceTorqueControl", True, block=True) + params.put_bool("LateralJerkTorqueController", enhanced, block=True) + params.put_bool("NeuralNetworkLateralControl", nnlc, block=True) + + car_name = HONDA.HONDA_CIVIC + CarInterface = interfaces[car_name] + CP = CarInterface.get_non_essential_params(car_name) + CP_SP = CarInterface.get_non_essential_params_sp(CP, car_name) + CI = CarInterface(CP, CP_SP) + sunnypilot_interfaces.setup_interfaces(CI, params) + CP_SP = convert_to_capnp(CP_SP) + VM = VehicleModel(CP) + controller = LatControlTorque(CP.as_reader(), CP_SP.as_reader(), CI, DT_CTRL) + return controller, VM, CP + + +def _make_model_v2(): + model = messaging.new_message('modelV2') + position = log.XYZTData.new_message() + position.x = [float(x) for x in 30.0 * np.array(ModelConstants.T_IDXS)] + model.modelV2.position = position + orientation = log.XYZTData.new_message() + orientation.x = [0.0 for _ in ModelConstants.T_IDXS] + orientation.y = [0.0 for _ in ModelConstants.T_IDXS] + model.modelV2.orientation = orientation + velocity = log.XYZTData.new_message() + velocity.x = [30.0 for _ in ModelConstants.T_IDXS] + model.modelV2.velocity = velocity + acceleration = log.XYZTData.new_message() + acceleration.x = [0.0 for _ in ModelConstants.T_IDXS] + acceleration.y = [0.0 for _ in ModelConstants.T_IDXS] + model.modelV2.acceleration = acceleration + return model + + +def _run_update(controller, VM): + CS = car.CarState.new_message() + CS.vEgo = 30 + CS.steeringPressed = False + lp = generate_deviceMotion() + pose = Pose.from_device_motion(lp.deviceMotion) + params = log.VehicleParameters.new_message() + model_v2 = _make_model_v2().modelV2 + controller.extension.update_model_v2(model_v2) + controller.extension.update_lateral_lag(0.2) + return controller.update(True, CS, VM, params, False, 0.5, pose, False, 0.2) + + +class TestLatControlTorqueExt(OpenpilotTestCase): + def test_init_enhanced_only(self): + controller, VM, _ = _make_controller(enhanced=True, nnlc=False) + assert controller.extension._jerk_aware_enabled + assert not controller.extension.enabled # NNLC disabled + + def test_init_nnlc_only(self): + controller, VM, _ = _make_controller(enhanced=False, nnlc=True) + assert not controller.extension._jerk_aware_enabled + assert controller.extension.enabled + + def test_init_neither(self): + controller, VM, _ = _make_controller(enhanced=False, nnlc=False) + assert not controller.extension._jerk_aware_enabled + assert not controller.extension.enabled + + def test_init_both_no_crash(self): + controller, VM, _ = _make_controller(enhanced=True, nnlc=True) + assert not controller.extension._jerk_aware_enabled + assert not controller.extension.enabled + + def test_update_enhanced_only(self): + controller, VM, _ = _make_controller(enhanced=True, nnlc=False) + output_torque, _, pid_log = _run_update(controller, VM) + assert pid_log.active + + def test_update_neither(self): + controller, VM, _ = _make_controller(enhanced=False, nnlc=False) + output_torque, _, pid_log = _run_update(controller, VM) + assert pid_log.active + + def test_update_both_no_crash(self): + controller, VM, _ = _make_controller(enhanced=True, nnlc=True) + output_torque, _, pid_log = _run_update(controller, VM) + assert pid_log.active diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_relc.py b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_relc.py new file mode 100644 index 0000000000..8fea65e1a1 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_relc.py @@ -0,0 +1,170 @@ +""" +Copyright (c) 2021-, rav4kumar, 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.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase + +from openpilot.common.realtime import DT_MDL +from openpilot.sunnypilot.selfdrive.controls.lib.relc import ( + RoadEdgeLaneChangeController, EDGE_REACTION_TIME, EDGE_CLEAR_TIME, MIN_SPEED, + VEHICLE_EDGE_MARGIN, EDGE_CLEARANCE, +) + +V_HIGH = MIN_SPEED + 2.0 +V_LOW = MIN_SPEED - 1.0 + + +class MockEdge: + def __init__(self, y_val): + self.y = [y_val] * 33 + + +def edges(left_y, right_y): + return [MockEdge(left_y), MockEdge(right_y)] + + +CLOSE_EDGES = edges(-2.0, 1.5) +FAR_EDGES = edges(-10.0, 10.0) + + +def relc(mocker): + mocker.patch("openpilot.sunnypilot.selfdrive.controls.lib.relc.Params") + controller = RoadEdgeLaneChangeController() + controller.enabled = True + return controller + + +def drive(controller, road_edge_stds, lane_line_probs, seconds, v_ego=V_HIGH, road_edges=CLOSE_EDGES): + for _ in range(int(seconds / DT_MDL) + 1): + controller.update(road_edge_stds, lane_line_probs, v_ego, road_edges) + + +class TestRELC(OpenpilotTestCase): + @parameterized.expand([ + ([0.0, 0.9], [0.0, 0.8, 0.8, 0.8], "left_edge_detected"), + ([0.9, 0.0], [0.8, 0.8, 0.8, 0.0], "right_edge_detected"), + ], names=["road_edge_stds", "lane_line_probs", "attr"]) + def test_edge_detection(self, relc, road_edge_stds, lane_line_probs, attr): + drive(relc, road_edge_stds, lane_line_probs, EDGE_REACTION_TIME + 0.1) + assert getattr(relc, attr) + + + def test_edge_detection_requires_time(self, relc): + drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME - 0.05) + assert not relc.left_edge_detected + + + def test_both_edges_detected(self, relc): + drive(relc, [0.0, 0.0], [0.0, 0.8, 0.8, 0.0], EDGE_REACTION_TIME + 0.1) + assert relc.left_edge_detected + assert relc.right_edge_detected + + + def test_noise_doesnt_clear(self, relc): + edge = ([0.0, 0.9], [0.0, 0.8, 0.8, 0.8]) + clear = ([0.9, 0.9], [0.8, 0.8, 0.8, 0.8]) + + drive(relc, *edge, EDGE_REACTION_TIME + 0.1) + assert relc.left_edge_detected + + relc.update(*clear, V_HIGH, CLOSE_EDGES) + relc.update(*edge, V_HIGH, CLOSE_EDGES) + assert relc.left_edge_detected + + + def test_clears_after_window(self, relc): + edge = ([0.0, 0.9], [0.0, 0.8, 0.8, 0.8]) + clear = ([0.9, 0.9], [0.8, 0.8, 0.8, 0.8]) + + drive(relc, *edge, EDGE_REACTION_TIME + 0.1) + assert relc.left_edge_detected + + drive(relc, *clear, EDGE_CLEAR_TIME + 0.05) + assert not relc.left_edge_detected + assert relc.left_edge_timer == 0.0 + + + def test_low_speed_skips(self, relc): + drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1, v_ego=V_LOW) + assert not relc.left_edge_detected + assert relc.left_edge_timer == 0.0 + + + def test_speed_drop_resets(self, relc): + drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1) + assert relc.left_edge_detected + + relc.update([0.0, 0.9], [0.0, 0.8, 0.8, 0.8], V_LOW, CLOSE_EDGES) + assert not relc.left_edge_detected + + + def test_param_off_resets(self, relc): + drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1) + assert relc.left_edge_detected + + relc.params.get_bool.return_value = False + relc.read_params() + relc.update([0.0, 0.9], [0.0, 0.8, 0.8, 0.8], V_HIGH, CLOSE_EDGES) + assert not relc.left_edge_detected + assert not relc.right_edge_detected + + + def test_lane_line_prevents_detection(self, relc): + drive(relc, [0.0, 0.9], [0.8, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1) + assert not relc.left_edge_detected + + + def test_one_side_blocks_other_allows(self, relc): + drive(relc, [0.9, 0.0], [0.8, 0.8, 0.8, 0.0], EDGE_REACTION_TIME + 0.1) + assert relc.right_edge_detected + assert not relc.left_edge_detected + + + def test_disabled_no_detection(self, relc): + relc.enabled = False + relc.params.get_bool.return_value = False + drive(relc, [0.0, 0.0], [0.0, 0.8, 0.8, 0.0], EDGE_REACTION_TIME + 0.1) + assert not relc.left_edge_detected + assert not relc.right_edge_detected + + + def test_far_edge_no_block(self, relc): + drive(relc, [0.0, 0.9], [0.05, 0.5, 0.5, 0.08], EDGE_REACTION_TIME + 0.1, road_edges=FAR_EDGES) + assert not relc.left_edge_detected + + + def test_close_edge_blocks(self, relc): + drive(relc, [0.9, 0.0], [0.05, 0.8, 0.8, 0.05], EDGE_REACTION_TIME + 0.1, + road_edges=edges(-8.0, 1.5)) + assert relc.right_edge_detected + assert not relc.left_edge_detected + + + def test_wide_road_no_lines_no_block(self, relc): + drive(relc, [0.0, 0.0], [0.05, 0.4, 0.4, 0.05], EDGE_REACTION_TIME + 0.1, + road_edges=edges(-8.0, 8.0)) + assert not relc.left_edge_detected + assert not relc.right_edge_detected + + + def test_narrow_road_both_block(self, relc): + drive(relc, [0.0, 0.0], [0.02, 0.4, 0.4, 0.02], EDGE_REACTION_TIME + 0.1, + road_edges=edges(-2.5, 2.5)) + assert relc.left_edge_detected + assert relc.right_edge_detected + + + def test_clearance_boundary(self, relc): + boundary = VEHICLE_EDGE_MARGIN + EDGE_CLEARANCE # 4.78m + drive(relc, [0.0, 0.9], [0.05, 0.5, 0.5, 0.08], EDGE_REACTION_TIME + 0.1, + road_edges=edges(-(boundary - 0.1), 10.0)) + assert relc.left_edge_detected + + relc.reset() + + drive(relc, [0.0, 0.9], [0.05, 0.5, 0.5, 0.08], EDGE_REACTION_TIME + 0.1, + road_edges=edges(-(boundary + 0.1), 10.0)) + assert not relc.left_edge_detected diff --git a/openpilot/sunnypilot/selfdrive/locationd/locationd.cc b/openpilot/sunnypilot/selfdrive/locationd/locationd.cc index b24daa961b..499088a340 100644 --- a/openpilot/sunnypilot/selfdrive/locationd/locationd.cc +++ b/openpilot/sunnypilot/selfdrive/locationd/locationd.cc @@ -492,7 +492,7 @@ void Localizer::handle_cam_odo(double current_time, const cereal::CameraOdometry this->camodo_yawrate_distribution = Vector2d(rot_device[2], rotate_std(this->device_from_calib, rot_calib_std)[2]); } -void Localizer::handle_live_calib(double current_time, const cereal::LiveCalibrationData::Reader& log) { +void Localizer::handle_live_calib(double current_time, const cereal::ExtrinsicsCalibration::Reader& log) { if (!this->is_timestamp_valid(current_time)) { this->observation_timings_invalid = true; return; @@ -501,15 +501,15 @@ void Localizer::handle_live_calib(double current_time, const cereal::LiveCalibra if (log.getRpyCalib().size() > 0) { auto live_calib = floatlist2vector(log.getRpyCalib()); if ((live_calib.minCoeff() < -CALIB_RPY_SANITY_CHECK) || (live_calib.maxCoeff() > CALIB_RPY_SANITY_CHECK)) { - this->observation_values_invalid["liveCalibration"] += 1.0; + this->observation_values_invalid["extrinsicsCalibration"] += 1.0; return; } this->calib = live_calib; this->device_from_calib = euler2rot(this->calib); this->calib_from_device = this->device_from_calib.transpose(); - this->calibrated = log.getCalStatus() == cereal::LiveCalibrationData::Status::CALIBRATED; - this->observation_values_invalid["liveCalibration"] *= DECAY; + this->calibrated = log.getCalStatus() == cereal::ExtrinsicsCalibration::Status::CALIBRATED; + this->observation_values_invalid["extrinsicsCalibration"] *= DECAY; } } @@ -604,8 +604,8 @@ void Localizer::handle_msg(const cereal::Event::Reader& log) { this->handle_car_state(t, log.getCarState()); } else if (log.isCameraOdometry()) { this->handle_cam_odo(t, log.getCameraOdometry()); - } else if (log.isLiveCalibration()) { - this->handle_live_calib(t, log.getLiveCalibration()); + } else if (log.isExtrinsicsCalibration()) { + this->handle_live_calib(t, log.getExtrinsicsCalibration()); } this->finite_check(); this->update_reset_tracker(); @@ -688,7 +688,7 @@ int Localizer::locationd_thread() { } this->configure_gnss_source(source); - const std::initializer_list service_list = {gps_location_socket, "cameraOdometry", "liveCalibration", + const std::initializer_list service_list = {gps_location_socket, "cameraOdometry", "extrinsicsCalibration", "carState", "accelerometer", "gyroscope"}; SubMaster sm(service_list, {}, nullptr, {gps_location_socket}); @@ -696,7 +696,7 @@ int Localizer::locationd_thread() { uint64_t cnt = 0; bool filterInitialized = false; - const std::vector critical_input_services = {"cameraOdometry", "liveCalibration", "accelerometer", "gyroscope"}; + const std::vector critical_input_services = {"cameraOdometry", "extrinsicsCalibration", "accelerometer", "gyroscope"}; for (std::string service : critical_input_services) { this->observation_values_invalid.insert({service, 0.0}); } diff --git a/openpilot/sunnypilot/selfdrive/locationd/locationd.h b/openpilot/sunnypilot/selfdrive/locationd/locationd.h index a6ce697f30..c0535e6686 100644 --- a/openpilot/sunnypilot/selfdrive/locationd/locationd.h +++ b/openpilot/sunnypilot/selfdrive/locationd/locationd.h @@ -60,7 +60,7 @@ public: void handle_gnss(double current_time, const cereal::GnssMeasurements::Reader& log); void handle_car_state(double current_time, const cereal::CarState::Reader& log); void handle_cam_odo(double current_time, const cereal::CameraOdometry::Reader& log); - void handle_live_calib(double current_time, const cereal::LiveCalibrationData::Reader& log); + void handle_live_calib(double current_time, const cereal::ExtrinsicsCalibration::Reader& log); void input_fake_gps_observations(double current_time); diff --git a/openpilot/sunnypilot/selfdrive/locationd/tests/test_locationd.py b/openpilot/sunnypilot/selfdrive/locationd/tests/test_locationd.py index 551259fa01..2e99122bc7 100644 --- a/openpilot/sunnypilot/selfdrive/locationd/tests/test_locationd.py +++ b/openpilot/sunnypilot/selfdrive/locationd/tests/test_locationd.py @@ -1,5 +1,5 @@ -import pytest import platform +import unittest import json import random import time @@ -11,14 +11,12 @@ from openpilot.common.params import Params from openpilot.common.transformations.coordinates import ecef2geodetic from openpilot.system.manager.process_config import managed_processes +from openpilot.common.test import OpenpilotTestCase -if platform.system() == 'Darwin': - pytest.skip("Skipping locationd test on macOS due to unsupported msgq.", allow_module_level=True) - - -class TestLocationdProc: - LLD_MSGS = ['gpsLocationExternal', 'cameraOdometry', 'carState', 'liveCalibration', +@unittest.skipIf(platform.system() == 'Darwin', "msgq unsupported on macOS") +class TestLocationdProc(OpenpilotTestCase): + LLD_MSGS = ['gpsLocationExternal', 'cameraOdometry', 'carState', 'extrinsicsCalibration', 'accelerometer', 'gyroscope'] def setup_method(self): @@ -26,7 +24,6 @@ class TestLocationdProc: self.params = Params() self.params.put_bool("UbloxAvailable", True) - managed_processes['locationd_llk'].prepare() managed_processes['locationd_llk'].start() def teardown_method(self): @@ -85,10 +82,15 @@ class TestLocationdProc: for msg in sorted(msgs, key=lambda x: x.logMonoTime): self.pm.send(msg.which(), msg) if msg.which() == "cameraOdometry": - self.pm.wait_for_readers_to_update(msg.which(), 0.1, dt=0.005) - time.sleep(1) # wait for async params write + self.pm.wait_for_readers_to_update(msg.which(), timeout=1, dt=0.005) + for _ in range(50): + val = self.params.get('LastGPSPositionLLK') + if val is not None: + break + time.sleep(0.1) - lastGPS = json.loads(self.params.get('LastGPSPositionLLK')) - assert lastGPS['latitude'] == pytest.approx(self.lat, abs=0.001) - assert lastGPS['longitude'] == pytest.approx(self.lon, abs=0.001) - assert lastGPS['altitude'] == pytest.approx(self.alt, abs=0.001) + self.assertIsNotNone(val, "LastGPSPositionLLK not written within 5s") + lastGPS = json.loads(val) + self.assertAlmostEqual(lastGPS['latitude'], self.lat, delta=0.001) + self.assertAlmostEqual(lastGPS['longitude'], self.lon, delta=0.001) + self.assertAlmostEqual(lastGPS['altitude'], self.alt, delta=0.001) diff --git a/openpilot/sunnypilot/selfdrive/selfdrived/button_state_tracker.py b/openpilot/sunnypilot/selfdrive/selfdrived/button_state_tracker.py new file mode 100644 index 0000000000..ffbff7b44c --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/selfdrived/button_state_tracker.py @@ -0,0 +1,26 @@ +""" +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 opendbc.car import structs + + +class ButtonStateTracker: + def __init__(self) -> None: + self.pressed: int = 0 + self.release_toggle: int = 0 + + def update(self, CS: structs.CarState) -> None: + for b in CS.buttonEvents: + bit = 1 << b.type.raw + if b.pressed: + self.pressed |= bit + else: + self.pressed &= ~bit + self.release_toggle ^= bit + + def publish(self, ss_sp) -> None: + ss_sp.buttonsPressed = self.pressed + ss_sp.buttonsReleaseToggle = self.release_toggle diff --git a/openpilot/sunnypilot/selfdrive/selfdrived/events.py b/openpilot/sunnypilot/selfdrive/selfdrived/events.py index 2001d0dbee..3c010cc776 100644 --- a/openpilot/sunnypilot/selfdrive/selfdrived/events.py +++ b/openpilot/sunnypilot/selfdrive/selfdrived/events.py @@ -244,4 +244,12 @@ EVENTS_SP: dict[int, dict[str, Alert | AlertCallbackType]] = { AlertStatus.normal, AlertSize.none, Priority.MID, VisualAlert.none, AudibleAlert.prompt, 3.), }, + + EventNameSP.laneChangeRoadEdge: { + ET.WARNING: Alert( + "Lane Change Unavailable: Road Edge", + "", + AlertStatus.userPrompt, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 0.1), + }, } diff --git a/openpilot/sunnypilot/selfdrive/selfdrived/tests/__init__.py b/openpilot/sunnypilot/selfdrive/selfdrived/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpilot/sunnypilot/selfdrive/selfdrived/tests/test_button_state_tracker.py b/openpilot/sunnypilot/selfdrive/selfdrived/tests/test_button_state_tracker.py new file mode 100644 index 0000000000..5824f82531 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/selfdrived/tests/test_button_state_tracker.py @@ -0,0 +1,68 @@ +""" +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 opendbc.car.structs import car +from openpilot.sunnypilot.selfdrive.selfdrived.button_state_tracker import ButtonStateTracker +from openpilot.common.test import OpenpilotTestCase + +ButtonEvent = car.CarState.ButtonEvent +ButtonType = car.CarState.ButtonEvent.Type + + +class TestButtonStateTracker(OpenpilotTestCase): + def setup_method(self) -> None: + self.tracker = ButtonStateTracker() + + def make_cs(self, events: list) -> car.CarState: + CS = car.CarState() + CS.buttonEvents = events + return CS + + def test_initial_state(self) -> None: + assert self.tracker.pressed == 0 + assert self.tracker.release_toggle == 0 + + def test_press_sets_bit(self) -> None: + self.tracker.update(self.make_cs([ButtonEvent(type=ButtonType.accelCruise, pressed=True)])) + assert self.tracker.pressed == (1 << ButtonType.accelCruise) + assert self.tracker.release_toggle == 0 + + def test_release_clears_and_toggles(self) -> None: + self.tracker.update(self.make_cs([ButtonEvent(type=ButtonType.accelCruise, pressed=True)])) + self.tracker.update(self.make_cs([ButtonEvent(type=ButtonType.accelCruise, pressed=False)])) + assert self.tracker.pressed == 0 + assert self.tracker.release_toggle == (1 << ButtonType.accelCruise) + + def test_multiple_buttons(self) -> None: + self.tracker.update(self.make_cs([ + ButtonEvent(type=ButtonType.accelCruise, pressed=True), + ButtonEvent(type=ButtonType.decelCruise, pressed=True), + ])) + assert self.tracker.pressed == (1 << ButtonType.accelCruise) | (1 << ButtonType.decelCruise) + + self.tracker.update(self.make_cs([ButtonEvent(type=ButtonType.accelCruise, pressed=False)])) + assert self.tracker.pressed == (1 << ButtonType.decelCruise) + assert self.tracker.release_toggle == (1 << ButtonType.accelCruise) + + def test_release_toggle_flips(self) -> None: + for _ in range(2): + self.tracker.update(self.make_cs([ButtonEvent(type=ButtonType.gapAdjustCruise, pressed=True)])) + self.tracker.update(self.make_cs([ButtonEvent(type=ButtonType.gapAdjustCruise, pressed=False)])) + assert self.tracker.release_toggle == 0 + + def test_publish(self) -> None: + self.tracker.update(self.make_cs([ButtonEvent(type=ButtonType.accelCruise, pressed=True)])) + self.tracker.update(self.make_cs([ButtonEvent(type=ButtonType.decelCruise, pressed=True)])) + self.tracker.update(self.make_cs([ButtonEvent(type=ButtonType.accelCruise, pressed=False)])) + + class MockSP: + buttonsPressed = 0 + buttonsReleaseToggle = 0 + + sp = MockSP() + self.tracker.publish(sp) + assert sp.buttonsPressed == self.tracker.pressed + assert sp.buttonsReleaseToggle == self.tracker.release_toggle diff --git a/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py b/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py index ac168db7d8..c31e4ac711 100755 --- a/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py +++ b/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py @@ -28,7 +28,7 @@ from websocket import (ABNF, WebSocket, WebSocketException, WebSocketTimeoutExce create_connection, WebSocketConnectionClosedException) import openpilot.cereal.messaging as messaging -from openpilot.sunnypilot.models.default_model import DEFAULT_MODEL +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 from openpilot.sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, get_param_as_byte, save_param_from_base64_encoded_string @@ -182,6 +182,8 @@ def getParamsMetadata() -> str: schema["capabilities"] = generate_capabilities() schema["capability_labels"] = CAPABILITY_LABELS 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/athena/tests/test_sunnylinkd.py b/openpilot/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py index 616bff037e..9540d8329d 100644 --- a/openpilot/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py +++ b/openpilot/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py @@ -5,9 +5,10 @@ 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.sunnypilot.sunnylink.athena import sunnylinkd +from openpilot.common.test import OpenpilotTestCase -class TestSunnylinkdMethods: +class TestSunnylinkdMethods(OpenpilotTestCase): def setup_method(self): self.saved_params = [] @@ -16,10 +17,10 @@ class TestSunnylinkdMethods: def mock_save_param(key, value, compression=False): self.saved_params.append((key, value, compression)) - sunnylinkd.save_param_from_base64_encoded_string = mock_save_param + sunnylinkd.save_param_from_base64_encoded_string = mock_save_param # ty: ignore[invalid-assignment] def teardown_method(self): - sunnylinkd.save_param_from_base64_encoded_string = self.original_save + sunnylinkd.save_param_from_base64_encoded_string = self.original_save # ty: ignore[invalid-assignment] def test_saveParams_blocked(self): blocked_params = { diff --git a/openpilot/sunnypilot/sunnylink/backups/utils.py b/openpilot/sunnypilot/sunnylink/backups/utils.py index b479b0aaf5..054a01793f 100644 --- a/openpilot/sunnypilot/sunnylink/backups/utils.py +++ b/openpilot/sunnypilot/sunnylink/backups/utils.py @@ -183,6 +183,6 @@ def transform_dict(obj): class SnakeCaseEncoder(json.JSONEncoder): - def encode(self, obj): - transformed_obj = transform_dict(obj) + def encode(self, o): + transformed_obj = transform_dict(o) return super().encode(transformed_obj) diff --git a/openpilot/sunnypilot/sunnylink/settings_ui.json b/openpilot/sunnypilot/sunnylink/settings_ui.json index 1745235d96..63bf342fcb 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui.json +++ b/openpilot/sunnypilot/sunnylink/settings_ui.json @@ -323,6 +323,32 @@ "equals": true }, "items": [ + { + "key": "LateralJerkTorqueController", + "widget": "toggle", + "title": "Lateral Jerk Torque Controller", + "description": "Looks ahead at planned steering to reduce sudden corrections, so the wheel moves more smoothly through turns. Works with Self-Tune and custom tuning. Thanks to @twilsonco for the implementation.", + "visibility": [ + { + "type": "not", + "condition": { + "type": "capability", + "field": "steer_control_type", + "equals": "angle" + } + } + ], + "enablement": [ + { + "type": "offroad_only" + }, + { + "type": "param", + "key": "NeuralNetworkLateralControl", + "equals": false + } + ] + }, { "key": "LiveTorqueParamsToggle", "widget": "toggle", @@ -519,6 +545,12 @@ } ] }, + { + "key": "RoadEdgeLaneChangeEnabled", + "widget": "toggle", + "title": "Block Lane Change: Road Edge Detection", + "description": "Blocks lane change when the model sees a road edge on the side you signal." + }, { "key": "AutoLaneChangeBsmDelay", "widget": "toggle", @@ -1260,6 +1292,67 @@ "label": "2 m" } ] + }, + { + "key": "ScreenSaverEnabled", + "widget": "toggle", + "title": "Screen Saver", + "description": "Show a screen saver when the device is offroad and idle, instead of turning the screen off." + }, + { + "key": "ScreenSaverTimeout", + "widget": "multiple_button", + "title": "Screen Saver Duration", + "description": "How long the screen saver runs before the screen turns off.", + "options": [ + { + "value": 60, + "label": "1 m" + }, + { + "value": 120, + "label": "2 m" + }, + { + "value": 180, + "label": "3 m" + }, + { + "value": 240, + "label": "4 m" + }, + { + "value": 300, + "label": "5 m" + }, + { + "value": 360, + "label": "6 m" + }, + { + "value": 420, + "label": "7 m" + }, + { + "value": 480, + "label": "8 m" + }, + { + "value": 540, + "label": "9 m" + }, + { + "value": 600, + "label": "10 m" + } + ], + "enablement": [ + { + "type": "param", + "key": "ScreenSaverEnabled", + "equals": true + } + ] } ] } @@ -2037,6 +2130,11 @@ "type": "param", "key": "EnforceTorqueControl", "equals": false + }, + { + "type": "param", + "key": "LateralJerkTorqueController", + "equals": false } ] } @@ -2161,6 +2259,42 @@ "type": "offroad_only" } ] + }, + { + "key": "TeslaMadsScreenButton", + "widget": "multiple_button", + "title": "MADS Screen Activation", + "description": "Use a multi-finger press on the infotainment screen to toggle MADS. This allows the use of full MADS functionality when enabled. Selecting a higher finger count may reduce accidental activations. Note: Setting this to Off will reset your MADS settings to default.", + "options": [ + { + "value": 0, + "label": "Off" + }, + { + "value": 1, + "label": "3-Finger" + }, + { + "value": 2, + "label": "4-Finger" + }, + { + "value": 3, + "label": "5-Finger" + } + ], + "visibility": [ + { + "type": "capability", + "field": "tesla_has_vehicle_bus", + "equals": true + } + ], + "enablement": [ + { + "type": "offroad_only" + } + ] } ] }, diff --git a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/display.yaml b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/display.yaml index 39a8cbaf80..3e3b16c374 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/display.yaml +++ b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/display.yaml @@ -128,3 +128,36 @@ sections: label: 1 m - value: 120 label: 2 m + - key: ScreenSaverEnabled + widget: toggle + title: Screen Saver + description: Show a screen saver when the device is offroad and idle, instead of turning the screen off. + - key: ScreenSaverTimeout + widget: multiple_button + title: Screen Saver Duration + description: How long the screen saver runs before the screen turns off. + options: + - value: 60 + label: 1 m + - value: 120 + label: 2 m + - value: 180 + label: 3 m + - value: 240 + label: 4 m + - value: 300 + label: 5 m + - value: 360 + label: 6 m + - value: 420 + label: 7 m + - value: 480 + label: 8 m + - value: 540 + label: 9 m + - value: 600 + label: 10 m + enablement: + - type: param + key: ScreenSaverEnabled + equals: true diff --git a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/models.yaml b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/models.yaml index 4ae1fca88b..bcb8b895b9 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/models.yaml +++ b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/models.yaml @@ -73,6 +73,9 @@ sections: - type: param key: EnforceTorqueControl equals: false + - type: param + key: LateralJerkTorqueController + equals: false - id: camera title: Camera description: Camera position and calibration diff --git a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/steering.yaml b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/steering.yaml index 697c5f4f21..73d46681a6 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/steering.yaml +++ b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/steering.yaml @@ -127,6 +127,21 @@ sections: key: EnforceTorqueControl equals: true items: + - key: LateralJerkTorqueController + widget: toggle + title: Lateral Jerk Torque Controller + description: Looks ahead at planned steering to reduce sudden corrections, so the wheel moves more smoothly through turns. Works with Self-Tune and custom tuning. Thanks to @twilsonco for the implementation. + visibility: + - type: not + condition: + type: capability + field: steer_control_type + equals: angle + enablement: + - $ref: '#/macros/offroad' + - type: param + key: NeuralNetworkLateralControl + equals: false - key: LiveTorqueParamsToggle widget: toggle title: Self-Tune @@ -242,6 +257,10 @@ sections: label: 2 seconds - value: 5 label: 3 seconds + - key: RoadEdgeLaneChangeEnabled + widget: toggle + title: 'Block Lane Change: Road Edge Detection' + description: Blocks lane change when the model sees a road edge on the side you signal. - key: AutoLaneChangeBsmDelay widget: toggle title: 'Auto Lane Change: Delay with Blind Spot' diff --git a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml index 2f6f20730e..7dd23256b1 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml +++ b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml @@ -56,6 +56,28 @@ sections: title: Cooperative Steering (Beta) enablement: - $ref: '#/macros/offroad' + - key: TeslaMadsScreenButton + widget: multiple_button + title: MADS Screen Activation + description: 'Use a multi-finger press on the infotainment screen to toggle MADS. + This allows the use of full MADS functionality when enabled. Selecting a higher + finger count may reduce accidental activations. Note: Setting this to Off will + reset your MADS settings to default.' + options: + - value: 0 + label: 'Off' + - value: 1 + label: 3-Finger + - value: 2 + label: 4-Finger + - value: 3 + label: 5-Finger + visibility: + - type: capability + field: tesla_has_vehicle_bus + equals: true + enablement: + - $ref: '#/macros/offroad' - id: toyota title: Toyota / Lexus Settings description: '' 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/sunnylink/tests/test_capabilities.py b/openpilot/sunnypilot/sunnylink/tests/test_capabilities.py index 4af1462479..7e0272a7c9 100644 --- a/openpilot/sunnypilot/sunnylink/tests/test_capabilities.py +++ b/openpilot/sunnypilot/sunnylink/tests/test_capabilities.py @@ -12,7 +12,6 @@ the same commit so the bump shows up in code review. """ from __future__ import annotations -import pytest from openpilot.sunnypilot.sunnylink.capabilities import ( CAPABILITY_DEFAULTS, @@ -21,18 +20,18 @@ from openpilot.sunnypilot.sunnylink.capabilities import ( PROTOCOL_VERSION, generate_capabilities, ) +from openpilot.common.test import OpenpilotTestCase KNOWN_PROTOCOL_VERSIONS = (1,) LATEST_KNOWN = max(KNOWN_PROTOCOL_VERSIONS) -@pytest.fixture(scope="module") def caps(): return generate_capabilities() -class TestProtocolVersion: +class TestProtocolVersion(OpenpilotTestCase): def test_protocol_version_in_capability_fields(self): assert "protocol_version" in CAPABILITY_FIELDS @@ -63,7 +62,7 @@ class TestProtocolVersion: ) -class TestOpaquePerBrandFlags: +class TestOpaquePerBrandFlags(OpenpilotTestCase): def test_subaru_has_sng_field_present(self): assert "subaru_has_sng" in CAPABILITY_FIELDS @@ -77,7 +76,7 @@ class TestOpaquePerBrandFlags: assert caps["hyundai_alpha_long_available"] is False -class TestCapabilitiesShape: +class TestCapabilitiesShape(OpenpilotTestCase): def test_all_fields_present(self, caps): for field in CAPABILITY_FIELDS: assert field in caps, f"capabilities missing {field}" diff --git a/openpilot/sunnypilot/sunnylink/tests/test_compile_settings_ui.py b/openpilot/sunnypilot/sunnylink/tests/test_compile_settings_ui.py index 2fcd889ec3..797bf42558 100644 --- a/openpilot/sunnypilot/sunnylink/tests/test_compile_settings_ui.py +++ b/openpilot/sunnypilot/sunnylink/tests/test_compile_settings_ui.py @@ -19,7 +19,6 @@ import difflib import json import os -import pytest import yaml from openpilot.sunnypilot.sunnylink.tools.compile_settings_ui import ( @@ -29,20 +28,19 @@ from openpilot.sunnypilot.sunnylink.tools.compile_settings_ui import ( _resolve_refs, compile_schema, ) +from openpilot.common.test import OpenpilotTestCase -@pytest.fixture(scope="module") def compiled() -> dict: return compile_schema(DEFAULT_SRC) -@pytest.fixture(scope="module") def committed() -> dict: with open(DEFAULT_OUT) as f: return json.load(f) -class TestRoundtrip: +class TestRoundtrip(OpenpilotTestCase): def test_compiled_matches_committed(self, compiled, committed): """Compiled output must match the checked-in JSON.""" if compiled == committed: @@ -54,7 +52,7 @@ class TestRoundtrip: tofile="settings_ui.json (freshly compiled)", lineterm="", )) - pytest.fail(f"settings_ui.json schema mismatch — run compile_settings_ui.py\n\n{diff}") + self.fail(f"settings_ui.json schema mismatch — run compile_settings_ui.py\n\n{diff}") def test_committed_file_is_canonical(self): """Compiled output must byte-match the checked-in file (including trailing newline). @@ -72,10 +70,10 @@ class TestRoundtrip: tofile="settings_ui.json (freshly compiled)", lineterm="", )) - pytest.fail(f"settings_ui.json out of sync — run compile_settings_ui.py\n\n{diff}") + self.fail(f"settings_ui.json out of sync — run compile_settings_ui.py\n\n{diff}") -class TestRefResolution: +class TestRefResolution(OpenpilotTestCase): def test_list_context_splices(self): macros = {"a": [{"type": "offroad_only"}], "b": [{"type": "not_engaged"}]} out = _resolve_refs([{"$ref": "#/macros/a"}, {"$ref": "#/macros/b"}], macros) @@ -95,12 +93,12 @@ class TestRefResolution: assert out == [{"type": "offroad_only"}] def test_unknown_macro_raises(self): - with pytest.raises(CompileError, match="unknown macro"): + with self.assertRaisesRegex(CompileError, "unknown macro"): _resolve_refs([{"$ref": "#/macros/missing"}], {}) def test_cycle_raises(self): macros = {"a": [{"$ref": "#/macros/b"}], "b": [{"$ref": "#/macros/a"}]} - with pytest.raises(CompileError, match="cycle"): + with self.assertRaisesRegex(CompileError, "cycle"): _resolve_refs([{"$ref": "#/macros/a"}], macros) def test_depth_limit(self): @@ -111,20 +109,20 @@ class TestRefResolution: "l3": [{"$ref": "#/macros/l4"}], "l4": [{"type": "offroad_only"}], } - with pytest.raises(CompileError, match="depth"): + with self.assertRaisesRegex(CompileError, "depth"): _resolve_refs([{"$ref": "#/macros/l1"}], macros) def test_invalid_ref_scheme(self): - with pytest.raises(CompileError, match="unsupported"): + with self.assertRaisesRegex(CompileError, "unsupported"): _resolve_refs([{"$ref": "https://example.com/x"}], {}) def test_scalar_macro_in_list_context_raises(self): macros = {"x": {"type": "offroad_only"}} # macro is a single rule (dict), not a list - with pytest.raises(CompileError, match="must resolve to a list"): + with self.assertRaisesRegex(CompileError, "must resolve to a list"): _resolve_refs([{"$ref": "#/macros/x"}], macros) -class TestCompiledShape: +class TestCompiledShape(OpenpilotTestCase): def test_panels_present(self, compiled): assert isinstance(compiled["panels"], list) assert len(compiled["panels"]) == 9 @@ -145,7 +143,7 @@ class TestCompiledShape: def walk(node): if isinstance(node, dict): if "$ref" in node: - pytest.fail(f"unresolved $ref: {node}") + self.fail(f"unresolved $ref: {node}") for v in node.values(): walk(v) elif isinstance(node, list): @@ -154,7 +152,7 @@ class TestCompiledShape: walk(compiled) -class TestSourceTreeIntegrity: +class TestSourceTreeIntegrity(OpenpilotTestCase): def test_macros_yaml_well_formed(self): with open(os.path.join(DEFAULT_SRC, "_macros.yaml")) as f: doc = yaml.safe_load(f) diff --git a/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py b/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py index 07b05d4ac4..d9b1267a10 100644 --- a/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py +++ b/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py @@ -15,7 +15,7 @@ import json import os from typing import Any -import pytest +from openpilot.common.parameterized import parameterized from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import ( DEFINITION_PATH, @@ -24,6 +24,7 @@ from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import ( _load_torque_versions, generate_schema, ) +from openpilot.common.test import OpenpilotTestCase SCHEMA_VALIDATOR_PATH = os.path.join(os.path.dirname(DEFINITION_PATH), "settings_ui.schema.json") @@ -105,12 +106,11 @@ def _references_capability_field(rules: list[dict[str, Any]] | None, field: str) return found -@pytest.fixture(scope="module") def schema(): return generate_schema() -class TestMadsBrandGates: +class TestMadsBrandGates(OpenpilotTestCase): def test_mads_main_cruise_has_brand_gate(self, schema): """MadsMainCruiseAllowed must gate on brand and tesla_has_vehicle_bus.""" item = _find_item(schema, "MadsMainCruiseAllowed") @@ -126,7 +126,7 @@ class TestMadsBrandGates: assert _references_capability_field(item.get("enablement"), "tesla_has_vehicle_bus") -class TestTestManeuversSection: +class TestTestManeuversSection(OpenpilotTestCase): def test_lateral_maneuver_mode_in_test_maneuvers(self, schema): section = _find_section(schema, "developer", "test_maneuvers") assert section is not None, "developer.test_maneuvers section missing" @@ -153,10 +153,13 @@ class TestTestManeuversSection: "test_maneuvers must gate ShowAdvancedControls via enablement" -class TestValidator: +class TestValidator(OpenpilotTestCase): def test_validator_accepts_real_json(self): """settings_ui.json validates against settings_ui.schema.json.""" - jsonschema = pytest.importorskip("jsonschema") + try: + import jsonschema + except ImportError: + self.skipTest("jsonschema not installed") with open(DEFINITION_PATH) as f: data = json.load(f) with open(SCHEMA_VALIDATOR_PATH) as f: @@ -164,7 +167,7 @@ class TestValidator: jsonschema.validate(instance=data, schema=validator) -class TestTorqueOptionGeneration: +class TestTorqueOptionGeneration(OpenpilotTestCase): def test_torque_versions_match_generated_options(self, schema): versions = _load_torque_versions() assert versions, "latcontrol_torque_versions.json must have at least one version" @@ -179,11 +182,11 @@ class TestTorqueOptionGeneration: ) -class TestReleaseBranchGates: - @pytest.mark.parametrize("key", [ +class TestReleaseBranchGates(OpenpilotTestCase): + @parameterized.expand([ "EnableGithubRunner", "QuickBootToggle", - ]) + ], names=["key"]) def test_sp_dev_items_gate_on_is_sp_release(self, schema, key): """sunnypilot dev items must hide on sunnypilot release branches (is_sp_release gate).""" item = _find_item(schema, key) @@ -192,7 +195,7 @@ class TestReleaseBranchGates: assert _references_capability_field(rules, "is_sp_release"), f"{key} missing is_sp_release gate" -class TestSpuriousOffroadGatesDropped: +class TestSpuriousOffroadGatesDropped(OpenpilotTestCase): def test_disengage_on_accelerator_has_no_offroad_only(self, schema): item = _find_item(schema, "DisengageOnAccelerator") assert item is not None @@ -204,12 +207,12 @@ class TestSpuriousOffroadGatesDropped: assert "offroad_only" not in _flatten_rule_types(item.get("enablement")) -class TestNotEngagedReplacement: - @pytest.mark.parametrize("key", [ +class TestNotEngagedReplacement(OpenpilotTestCase): + @parameterized.expand([ "AlphaLongitudinalEnabled", "ToyotaEnforceStockLongitudinal", "ToyotaStopAndGoHack", - ]) + ], names=["key"]) def test_offroad_only_replaced_with_not_engaged(self, schema, key): """These items should use not_engaged, not offroad_only.""" item = _find_item(schema, key) diff --git a/openpilot/sunnypilot/sunnylink/tests/test_settings_schema.py b/openpilot/sunnypilot/sunnylink/tests/test_settings_schema.py index 61cc0131cf..e60ac000f4 100644 --- a/openpilot/sunnypilot/sunnylink/tests/test_settings_schema.py +++ b/openpilot/sunnypilot/sunnylink/tests/test_settings_schema.py @@ -5,7 +5,6 @@ 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 json -import pytest from openpilot.common.params import Params from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import ( @@ -16,6 +15,7 @@ from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import ( collect_capability_refs, ) from openpilot.sunnypilot.sunnylink.capabilities import CAPABILITY_FIELDS +from openpilot.common.test import OpenpilotTestCase VALID_WIDGET_TYPES = {"toggle", "option", "multiple_button", "button", "info"} @@ -55,18 +55,16 @@ def _brand_items(brand_data) -> list[dict]: return [] -@pytest.fixture(scope="module") def schema(): return generate_schema() -@pytest.fixture(scope="module") def all_param_keys(): """All keys registered in the device param store.""" return {k.decode("utf-8") for k in Params().all_keys()} -class TestSchemaStructure: +class TestSchemaStructure(OpenpilotTestCase): def test_schema_is_valid_json(self): """Schema serializes to valid JSON.""" raw = generate_schema_json() @@ -141,16 +139,16 @@ class TestSchemaStructure: for item in _iter_panel_items(panel): key = item["key"] if key in seen: - pytest.fail(f"Key '{key}' appears in both panel '{seen[key]}' and '{panel['id']}'") + self.fail(f"Key '{key}' appears in both panel '{seen[key]}' and '{panel['id']}'") seen[key] = panel["id"] for sub in item.get("sub_items", []): sub_key = sub["key"] if sub_key in seen: - pytest.fail(f"Sub-item key '{sub_key}' appears in both '{seen[sub_key]}' and '{panel['id']}'") + self.fail(f"Sub-item key '{sub_key}' appears in both '{seen[sub_key]}' and '{panel['id']}'") seen[sub_key] = panel["id"] -class TestSchemaCoverage: +class TestSchemaCoverage(OpenpilotTestCase): def test_all_schema_keys_exist_in_params(self, schema, all_param_keys): """Schema keys must exist in Params().all_keys().""" schema_keys = collect_all_keys(schema) @@ -169,7 +167,7 @@ class TestSchemaCoverage: assert set(schema["capability_fields"]) == set(CAPABILITY_FIELDS) -class TestRuleWellFormedness: +class TestRuleWellFormedness(OpenpilotTestCase): def _validate_rule(self, rule: dict, context: str = ""): """Recursively validate a single rule dict.""" assert "type" in rule, f"Rule missing 'type' in {context}" @@ -232,7 +230,7 @@ class TestRuleWellFormedness: key = item.get("key") for rule in item.get(rules_field, []): if rule.get("type") == "param" and rule.get("key") == key: - pytest.fail(f"Item {key} has self-referencing {rules_field} rule") + self.fail(f"Item {key} has self-referencing {rules_field} rule") for panel in schema["panels"]: for item in _iter_panel_items(panel): @@ -245,7 +243,7 @@ class TestRuleWellFormedness: _check_self_ref(item, "enablement") -class TestKnownPanels: +class TestKnownPanels(OpenpilotTestCase): def test_expected_panels_exist(self, schema): panel_ids = {p["id"] for p in schema["panels"]} expected = {"steering", "cruise", "display", "visuals", "device", "software", "developer"} @@ -257,23 +255,29 @@ class TestKnownPanels: assert "mads_settings" in sub_ids def test_mutual_exclusion_torque_nnlc(self, schema): - """EnforceTorqueControl and NNLC must reference each other in enablement.""" - torque = nnlc = None + """EnforceTorqueControl, EnhancedLatAccel, and NNLC must reference each other in enablement.""" + torque = nnlc = enhanced = None for panel in schema["panels"]: for item in _iter_panel_items(panel): if item["key"] == "EnforceTorqueControl": torque = item elif item["key"] == "NeuralNetworkLateralControl": nnlc = item + elif item["key"] == "LateralJerkTorqueController": + enhanced = item assert torque is not None, "EnforceTorqueControl item missing" assert nnlc is not None, "NeuralNetworkLateralControl item missing" + assert enhanced is not None, "LateralJerkTorqueController item missing" torque_enable_keys = {r.get("key") for r in torque.get("enablement", []) if r.get("type") == "param"} assert "NeuralNetworkLateralControl" in torque_enable_keys nnlc_enable_keys = {r.get("key") for r in nnlc.get("enablement", []) if r.get("type") == "param"} assert "EnforceTorqueControl" in nnlc_enable_keys + assert "LateralJerkTorqueController" in nnlc_enable_keys + enhanced_enable_keys = {r.get("key") for r in enhanced.get("enablement", []) if r.get("type") == "param"} + assert "NeuralNetworkLateralControl" in enhanced_enable_keys -class TestKnownVehicleSettings: +class TestKnownVehicleSettings(OpenpilotTestCase): def test_hyundai_has_longitudinal_tuning(self, schema): keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("hyundai"))} assert "HyundaiLongitudinalTuning" in keys @@ -293,7 +297,7 @@ class TestKnownVehicleSettings: assert "SubaruStopAndGoManualParkingBrake" in keys -class TestItemCompleteness: +class TestItemCompleteness(OpenpilotTestCase): def _collect_all_items(self, schema): """Collect all items and sub_items from panels and vehicle_settings.""" items = [] @@ -313,7 +317,7 @@ class TestItemCompleteness: """All items must have titles.""" missing = [i["key"] for i in self._collect_all_items(schema) if "title" not in i] if len(missing) > MAX_ALLOWED_MISSING_TITLES: - pytest.fail(f"Items without titles ({len(missing)}): {missing[:10]}") + self.fail(f"Items without titles ({len(missing)}): {missing[:10]}") def test_no_default_titles(self, schema): """Item titles must differ from keys.""" diff --git a/openpilot/sunnypilot/sunnylink/tools/validate_settings_ui.py b/openpilot/sunnypilot/sunnylink/tools/validate_settings_ui.py index f1e094a742..a7bfe7cf67 100755 --- a/openpilot/sunnypilot/sunnylink/tools/validate_settings_ui.py +++ b/openpilot/sunnypilot/sunnylink/tools/validate_settings_ui.py @@ -129,6 +129,10 @@ def validate_rule(rule: dict, path: str, result: ValidationResult, return False valid = True for i, cond in enumerate(rule["conditions"]): + if not isinstance(cond, dict): + result.error("rule well-formedness", f"{path}.{rule_type}[{i}]: condition must be a dict") + valid = False + continue if not validate_rule(cond, f"{path}.{rule_type}[{i}]", result, capability_fields): valid = False return valid diff --git a/openpilot/sunnypilot/sunnylink/utils.py b/openpilot/sunnypilot/sunnylink/utils.py index 5588711977..59b2d15c12 100644 --- a/openpilot/sunnypilot/sunnylink/utils.py +++ b/openpilot/sunnypilot/sunnylink/utils.py @@ -108,20 +108,22 @@ def _convert_param_to_type(value: bytes, param_type: ParamKeyType) -> bytes | st """ # We convert to string anything that isn't bytes first. We later transform further. - if param_type != ParamKeyType.BYTES: - value = value.decode('utf-8') + if param_type == ParamKeyType.BYTES: + return value + + decoded = value.decode('utf-8') if param_type == ParamKeyType.STRING: - value = value + return decoded elif param_type == ParamKeyType.BOOL: - value = value.lower() in ('true', '1', 'yes') + return decoded.lower() in ('true', '1', 'yes') elif param_type == ParamKeyType.INT: - value = int(value) + return int(decoded) elif param_type == ParamKeyType.FLOAT: - value = float(value) + return float(decoded) elif param_type == ParamKeyType.TIME: - value = str(value) + return str(decoded) elif param_type == ParamKeyType.JSON: - value = json.loads(value) + return json.loads(decoded) - return value + return decoded diff --git a/openpilot/sunnypilot/system/params_migration.py b/openpilot/sunnypilot/system/params_migration.py index f9d7d1bc43..41cad9ad28 100644 --- a/openpilot/sunnypilot/system/params_migration.py +++ b/openpilot/sunnypilot/system/params_migration.py @@ -17,6 +17,26 @@ ONROAD_BRIGHTNESS_TIMER_VALUES = {0: 3, 1: 5, 2: 7, 3: 10, 4: 15, 5: 30, **{i: ( VALID_TIMER_VALUES = set(ONROAD_BRIGHTNESS_TIMER_VALUES.values()) +def _resolve_brand(_params) -> str: + bundle = _params.get("CarPlatformBundle") + if isinstance(bundle, dict) and bundle.get("brand"): + return str(bundle["brand"]) + + # Auto-fingerprinted cars have no bundle, fall back to the last known CarParams. + CP_bytes = _params.get("CarParamsPersistent") + if CP_bytes is None: + return "" + + # Never raises: callers rely on "" to mean "brand unknown, skip the migration". + try: + from openpilot.cereal import messaging # lazy: avoids heavy import at module level + from opendbc.car.structs import car + return str(messaging.log_from_bytes(CP_bytes, car.CarParams).brand) + except Exception as e: + cloudlog.exception(f"params_migration: failed to resolve brand from CarParamsPersistent: {e}") + return "" + + def _migrate_car_platform_bundle(_params): bundle = _params.get("CarPlatformBundle") if bundle is None: @@ -47,6 +67,42 @@ def _migrate_car_platform_bundle(_params): cloudlog.info(f"params_migration: CarPlatformBundle migrated {old_platform!r} -> {new_platform!r}") +def _migrate_tesla_mads_screen_button(_params): + # TeslaMadsScreenButton defaults to Off for fresh installs, but the screen button was previously always + # active on Teslas with a vehicle bus. Seed existing Tesla installs with 3-finger to preserve that. + try: + if _params.get("TeslaMadsScreenButton") is not None: + return + + if _resolve_brand(_params) != "tesla": + return + + from opendbc.sunnypilot.car.tesla.values import MadsScreenButtonType # lazy: avoids heavy import at module level + _params.put("TeslaMadsScreenButton", MadsScreenButtonType.THREE_FINGER, block=True) + cloudlog.info("params_migration: seeded TeslaMadsScreenButton with 3-finger to preserve existing behavior") + except Exception as e: + 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: @@ -80,3 +136,9 @@ def run_migration(_params): cloudlog.exception(f"Error migrating OnroadScreenOffTimer: {e}") _migrate_car_platform_bundle(_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/sensord/tests/test_sensord.py b/openpilot/sunnypilot/system/sensord/tests/test_sensord.py index 98321fb12c..39486fde76 100644 --- a/openpilot/sunnypilot/system/sensord/tests/test_sensord.py +++ b/openpilot/sunnypilot/system/sensord/tests/test_sensord.py @@ -1,6 +1,5 @@ import os import subprocess -import pytest import time import numpy as np from collections import namedtuple, defaultdict @@ -12,6 +11,7 @@ from openpilot.common.gpio import get_irqs_for_action from openpilot.common.timeout import Timeout from openpilot.common.hardware import HARDWARE from openpilot.system.manager.process_config import managed_processes +from openpilot.common.test import OpenpilotTestCase BMX = { ('bmx055', 'acceleration'), @@ -71,7 +71,7 @@ ALL_SENSORS = { } -def get_irq_count(irq: int): +def get_irq_count(irq: str): with open(f"/sys/kernel/irq/{irq}/per_cpu_count") as f: per_cpu = map(int, f.read().split(",")) return sum(per_cpu) @@ -103,8 +103,9 @@ def read_sensor_events(duration_sec): return {k: v for k, v in events.items() if len(v) > 0} -@pytest.mark.tici -class TestSensord: +class TestSensord(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True + @classmethod def setup_class(cls): # enable LSM self test @@ -181,7 +182,7 @@ class TestSensord: def test_logmonottime_timestamp_diff(self): # ensure diff between the message logMonotime and sample timestamp is small - tdiffs = list() + tdiffs = [] for etype in self.events: for measurement in self.events[etype]: m = getattr(measurement, measurement.which()) @@ -203,7 +204,7 @@ class TestSensord: assert avg_diff < 4, f"Avg packet diff: {avg_diff:.1f}ms" def test_sensor_values(self): - sensor_values = dict() + sensor_values = {} for etype in self.events: for measurement in self.events[etype]: m = getattr(measurement, measurement.which()) 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/sunnypilot/system/updated/tests/test_sp_branch_migrations.py b/openpilot/sunnypilot/system/updated/tests/test_sp_branch_migrations.py index b2742841a0..86f82fd9d2 100644 --- a/openpilot/sunnypilot/system/updated/tests/test_sp_branch_migrations.py +++ b/openpilot/sunnypilot/system/updated/tests/test_sp_branch_migrations.py @@ -4,70 +4,72 @@ 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 pytest +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.common.params import Params from openpilot.system.updated.updated import Updater -@pytest.mark.parametrize(("device_type", "branch", "expected"), [ - ("tici", "staging-c3-new", "staging-tici"), - ("tici", "dev-c3-new", "staging-tici"), - ("tici", "master", "master-tici"), - ("tici", "master-dev-c3-new", "master-tici"), - ("tizi", "staging-c3-new", "staging"), - ("tizi", "dev-c3-new", "dev"), - ("tizi", "master-dev-c3-new", "master-dev"), - ("tizi", "release3", "release-tizi"), - ("tizi", "release3-staging", "release-tizi-staging"), - ("mici", "release3", "release-mici"), - ("mici", "release3-staging", "release-mici-staging"), -]) -def test_sp_branch_migrations_from_current_branch(mocker, device_type, branch, expected): - params = Params() - params.remove("UpdaterTargetBranch") - - mocker.patch("openpilot.system.updated.updated.HARDWARE.get_device_type", return_value=device_type) - mocker.patch.object(Updater, "get_branch", return_value=branch) - - assert Updater().target_branch == expected - - -@pytest.mark.parametrize(("device_type", "branch", "expected"), [ - ("tici", "staging-c3-new", "staging-tici"), - ("tici", "dev-c3-new", "staging-tici"), - ("tici", "master", "master-tici"), - ("tici", "master-dev-c3-new", "master-tici"), - ("tizi", "staging-c3-new", "staging"), - ("tizi", "dev-c3-new", "dev"), - ("tizi", "master-dev-c3-new", "master-dev"), - ("tizi", "release3", "release-tizi"), - ("tizi", "release3-staging", "release-tizi-staging"), - ("mici", "release3", "release-mici"), - ("mici", "release3-staging", "release-mici-staging"), -]) -def test_sp_branch_migrations_from_param(mocker, device_type, branch, expected): - params = Params() - params.put("UpdaterTargetBranch", branch, block=True) - - mocker.patch("openpilot.system.updated.updated.HARDWARE.get_device_type", return_value=device_type) - - try: - assert Updater().target_branch == expected - finally: +class TestBranchMigrations(OpenpilotTestCase): + @parameterized.expand([ + ("tici", "staging-c3-new", "staging-tici"), + ("tici", "dev-c3-new", "staging-tici"), + ("tici", "master", "master-tici"), + ("tici", "master-dev-c3-new", "master-tici"), + ("tizi", "staging-c3-new", "staging"), + ("tizi", "dev-c3-new", "dev"), + ("tizi", "master-dev-c3-new", "master-dev"), + ("tizi", "release3", "release-tizi"), + ("tizi", "release3-staging", "release-tizi-staging"), + ("mici", "release3", "release-mici"), + ("mici", "release3-staging", "release-mici-staging"), + ], names=["device_type", "branch", "expected"]) + def test_sp_branch_migrations_from_current_branch(self, mocker, device_type, branch, expected): + params = Params() params.remove("UpdaterTargetBranch") + mocker.patch("openpilot.system.updated.updated.HARDWARE.get_device_type", return_value=device_type) + mocker.patch.object(Updater, "get_branch", return_value=branch) -@pytest.mark.parametrize(("device_type", "branch"), [ - ("tici", "unknown"), - ("tizi", "unknown"), - ("mici", "unknown"), -]) -def test_sp_branch_migrations_passthrough(mocker, device_type, branch): - params = Params() - params.remove("UpdaterTargetBranch") + assert Updater().target_branch == expected - mocker.patch("openpilot.system.updated.updated.HARDWARE.get_device_type", return_value=device_type) - mocker.patch.object(Updater, "get_branch", return_value=branch) - assert Updater().target_branch == branch + @parameterized.expand([ + ("tici", "staging-c3-new", "staging-tici"), + ("tici", "dev-c3-new", "staging-tici"), + ("tici", "master", "master-tici"), + ("tici", "master-dev-c3-new", "master-tici"), + ("tizi", "staging-c3-new", "staging"), + ("tizi", "dev-c3-new", "dev"), + ("tizi", "master-dev-c3-new", "master-dev"), + ("tizi", "release3", "release-tizi"), + ("tizi", "release3-staging", "release-tizi-staging"), + ("mici", "release3", "release-mici"), + ("mici", "release3-staging", "release-mici-staging"), + ], names=["device_type", "branch", "expected"]) + def test_sp_branch_migrations_from_param(self, mocker, device_type, branch, expected): + params = Params() + params.put("UpdaterTargetBranch", branch, block=True) + + mocker.patch("openpilot.system.updated.updated.HARDWARE.get_device_type", return_value=device_type) + + try: + assert Updater().target_branch == expected + finally: + params.remove("UpdaterTargetBranch") + + + @parameterized.expand([ + ("tici", "unknown"), + ("tizi", "unknown"), + ("mici", "unknown"), + ], names=["device_type", "branch"]) + def test_sp_branch_migrations_passthrough(self, mocker, device_type, branch): + params = Params() + params.remove("UpdaterTargetBranch") + + mocker.patch("openpilot.system.updated.updated.HARDWARE.get_device_type", return_value=device_type) + mocker.patch.object(Updater, "get_branch", return_value=branch) + + assert Updater().target_branch == branch diff --git a/openpilot/sunnypilot/tools/lib/__init__.py b/openpilot/sunnypilot/tools/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpilot/sunnypilot/tools/lib/sunnypilot_car_segments.py b/openpilot/sunnypilot/tools/lib/sunnypilot_car_segments.py new file mode 100644 index 0000000000..a3a0e576cd --- /dev/null +++ b/openpilot/sunnypilot/tools/lib/sunnypilot_car_segments.py @@ -0,0 +1,21 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +import os + +SUNNYPILOT_CAR_SEGMENTS_REPO = os.environ.get("SUNNYPILOT_CAR_SEGMENTS_REPO", + "https://huggingface.co/datasets/sunnypilot/sunnypilotCarSegments") +SUNNYPILOT_CAR_SEGMENTS_BRANCH = os.environ.get("SUNNYPILOT_CAR_SEGMENTS_BRANCH", "main") + + +def get_url(route, segment, file="rlog.zst"): + return f"{SUNNYPILOT_CAR_SEGMENTS_REPO}/resolve/{SUNNYPILOT_CAR_SEGMENTS_BRANCH}/segments/{route.replace('|', '/')}/{segment}/{file}" + + +def sunnypilot_car_segments_source(sr, seg_idxs, fns, /): + from openpilot.tools.lib.file_sources import eval_source + return eval_source({seg: [get_url(sr.route_name, seg, fn) for fn in fns] for seg in seg_idxs}) diff --git a/openpilot/sunnypilot/tools/upload_ci_routes.py b/openpilot/sunnypilot/tools/upload_ci_routes.py new file mode 100755 index 0000000000..3f511a6a72 --- /dev/null +++ b/openpilot/sunnypilot/tools/upload_ci_routes.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +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 argparse +import os +import tempfile + +import requests +from huggingface_hub import HfApi +from tqdm import tqdm + +from openpilot.tools.lib.route import Route + +REPO_ID = os.environ.get("SUNNYPILOT_CAR_SEGMENTS_REPO_ID", "sunnypilot/sunnypilotCarSegments") + + +def upload_route(route_name: str, dry_run: bool = False) -> None: + route = Route(route_name) + log_paths = route.log_paths() + valid_segments = [(i, url) for i, url in enumerate(log_paths) if url is not None] + + print(f"Route: {route_name}") + print(f"Segments: {len(valid_segments)}/{len(log_paths)}") + + if not valid_segments: + print("No segments found.") + return + + api = HfApi() + + with tempfile.TemporaryDirectory() as tmpdir: + for seg_idx, url in tqdm(valid_segments, desc="Uploading"): + filename = url.split("?")[0].rsplit("/", 1)[-1] + local_path = os.path.join(tmpdir, f"{seg_idx}_{filename}") + resp = requests.get(url, stream=True) + resp.raise_for_status() + with open(local_path, "wb") as f: + for chunk in resp.iter_content(chunk_size=8192): + f.write(chunk) + + repo_path = f"segments/{route_name.replace('|', '/')}/{seg_idx}/{filename}" + + if dry_run: + size_mb = os.path.getsize(local_path) / 1024 / 1024 + print(f" [{seg_idx}] {size_mb:.1f} MB -> {repo_path}") + else: + api.upload_file( + path_or_fileobj=local_path, + path_in_repo=repo_path, + repo_id=REPO_ID, + repo_type="dataset", + ) + + print("Done.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Upload route rlogs to sunnypilot HuggingFace dataset") + parser.add_argument("route", help="Route ID (e.g. 5beb9b58bd12b691/0000010a--a51155e496)") + parser.add_argument("--dry-run", action="store_true", help="Download and show sizes without uploading") + args = parser.parse_args() + + upload_route(args.route, dry_run=args.dry_run) diff --git a/openpilot/system/athena/athenad.py b/openpilot/system/athena/athenad.py index 292dcdd174..4d4735b461 100755 --- a/openpilot/system/athena/athenad.py +++ b/openpilot/system/athena/athenad.py @@ -5,22 +5,26 @@ import base64 import hashlib import itertools import json +import math import os import queue import random +import re import select import socket +import subprocess import sys import tempfile import threading import time import gzip +from contextlib import suppress from dataclasses import asdict, dataclass, replace from datetime import datetime from functools import partial, total_ordering from queue import Queue from typing import cast -from collections.abc import Callable +from collections.abc import Callable, Iterable import requests from requests.adapters import HTTPAdapter, DEFAULT_POOLBLOCK @@ -32,11 +36,14 @@ from openpilot.cereal import log from opendbc.car.structs import car from openpilot.cereal.services import SERVICE_LIST from openpilot.common.api import Api, get_key_pair +from openpilot.common.basedir import BASEDIR from openpilot.common.utils import CallbackReader, get_upload_stream from openpilot.common.params import Params from openpilot.common.realtime import set_core_affinity from openpilot.common.hardware import HARDWARE, PC +from openpilot.system.loggerd.config import CAMERA_FPS, SEGMENT_LENGTH from openpilot.system.loggerd.xattr_cache import getxattr, setxattr +from openpilot.tools.lib.helpers import RE from openpilot.common.swaglog import cloudlog from openpilot.common.version import get_build_metadata from openpilot.common.hardware.hw import Paths @@ -57,6 +64,7 @@ MAX_AGE = 31 * 24 * 3600 # seconds WS_FRAME_SIZE = 4096 DEVICE_STATE_UPDATE_INTERVAL = 1.0 # in seconds DEFAULT_UPLOAD_PRIORITY = 99 # higher number = lower priority +CLIP_CHUNK_SIZE = 512 * 1024 SEND_PRIORITY_HIGH = 0 SEND_PRIORITY_LOW = 1 @@ -373,6 +381,7 @@ def getVersion() -> dict[str, str]: "remote": build_metadata.openpilot.git_normalized_origin, "branch": build_metadata.channel, "commit": build_metadata.openpilot.git_commit, + "commit_date": build_metadata.openpilot.git_commit_date.strip("'").split()[0], } @@ -420,6 +429,217 @@ def listDataDirectory(prefix='') -> list[str]: return sorted(set(internal_files + external_files)) +class VideoClips: + @dataclass + class Clip: + route: str + camera: str + source_start_time: float + source_end_time: float + bitrate: int + speedup: int + filename: str + requested_at: float + + def __init__(self): + self.clip_path = os.path.join(Paths.log_root(), "clips") + self.lock = threading.Condition() + self.clips: dict[str, VideoClips.Clip] = {} + self.transcode_proc: tuple[str, subprocess.Popen] | None = None + threading.Thread(target=self._worker, name="video_clip", daemon=True).start() + + def _encode(self, clip: Clip, inputs: Iterable[str], output_path: str, start_time: float, duration: float) -> None: + inputs = list(inputs) + metadata = json.dumps(asdict(clip), separators=(',', ':')) + if PC: + command = [ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", "-y", + "-r", str(CAMERA_FPS * clip.speedup), "-f", "concat", "-safe", "0", "-protocol_whitelist", "file,pipe", "-c:v", "hevc", + "-i", "pipe:0", "-ss", str(start_time / clip.speedup), "-t", str(duration / clip.speedup), + "-map", "0:v:0", "-an", "-r", str(CAMERA_FPS), "-c:v", "libx264", "-preset", "veryfast", + "-b:v", f"{clip.bitrate}M", "-pix_fmt", "yuv420p", "-movflags", "+faststart+use_metadata_tags", + "-metadata", f"ai.comma.clip.settings={metadata}", output_path, + ] + else: + command = [os.path.join(BASEDIR, "openpilot/system/loggerd/encoderd"), "--clip", output_path, + str(start_time), str(duration), "--bitrate", str(clip.bitrate * 1_000_000), + "--speedup", str(clip.speedup), "--metadata", metadata, "--", *inputs] + + with self.lock: + if self.clips.get(clip.filename) is not clip: + return + process = subprocess.Popen(command, stdin=subprocess.PIPE if PC else subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, text=True) + self.transcode_proc = (clip.filename, process) + try: + if PC: + if process.stdin is None: + raise RuntimeError("ffmpeg stdin is unavailable") + process.stdin.write("ffconcat version 1.0\n") + for path in inputs: + escaped_path = path.replace("'", "'\\''") + process.stdin.write(f"file 'file:{escaped_path}'\noption framerate {CAMERA_FPS}\nduration {SEGMENT_LENGTH}\n") + process.stdin.close() + process.wait() + if process.returncode != 0: + raise RuntimeError(f"clip encoder exited with code {process.returncode}") + finally: + with suppress(OSError): + if process.stdin is not None: + process.stdin.close() + if process.poll() is None: + process.terminate() + process.wait() + with self.lock: + if self.transcode_proc is not None and self.transcode_proc[0] == clip.filename: + self.transcode_proc = None + + def _worker(self) -> None: + while True: + with self.lock: + while not self.clips: + self.lock.wait() + clip = next(iter(self.clips.values())) + temporary_path = "" + try: + with self.lock: + if self.clips.get(clip.filename) is not clip: + continue + first_segment = math.floor(clip.source_start_time / SEGMENT_LENGTH) + inputs = ( + os.path.join(Paths.log_root(), f"{clip.route}--{segment}", clip.camera) + for segment in range(first_segment, math.ceil(clip.source_end_time / SEGMENT_LENGTH)) + ) + os.makedirs(self.clip_path, exist_ok=True) + temporary_path = os.path.join(self.clip_path, f".{clip.filename}") + output_path = os.path.join(self.clip_path, clip.filename) + self._encode(clip, inputs, temporary_path, clip.source_start_time - first_segment * SEGMENT_LENGTH, + clip.source_end_time - clip.source_start_time) + with self.lock: + if self.clips.get(clip.filename) is clip: + os.replace(temporary_path, output_path) + del self.clips[clip.filename] + except Exception: + with self.lock: + failed = self.clips.get(clip.filename) is clip + if failed: + del self.clips[clip.filename] + if failed: + cloudlog.exception("athena.video_clip.failed") + finally: + with suppress(OSError): + if temporary_path: + os.unlink(temporary_path) + + def _on_disk(self) -> dict[str, dict]: + clips = {} + try: + entries = os.scandir(self.clip_path) + except FileNotFoundError: + return clips + with entries: + for entry in entries: + if entry.name.startswith(".") or not entry.is_file(): + continue + probe = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format_tags=ai.comma.clip.settings", + "-of", "json", entry.path], capture_output=True, text=True) + if probe.returncode != 0: + continue + try: + metadata = json.loads(json.loads(probe.stdout)["format"]["tags"]["ai.comma.clip.settings"]) + size = entry.stat().st_size + except (FileNotFoundError, KeyError, TypeError, json.JSONDecodeError): + continue + if not isinstance(metadata, dict) or not isinstance(metadata.get("requested_at"), (int, float)): + continue + clips[entry.name] = {**metadata, "filename": entry.name, "status": "ready", + "fn": os.path.relpath(entry.path, Paths.log_root()), "size": size} + return clips + + def _available_ranges(self, route: str) -> dict: + cameras: dict[str, list[int]] = {} + try: + with os.scandir(Paths.log_root()) as entries: + for entry in entries: + entry_route, _, segment = entry.name.rpartition("--") + if entry_route != route or not segment.isdigit() or not entry.is_dir(): + continue + with os.scandir(entry.path) as files: + for camera in files: + if camera.is_file() and camera.name.endswith("camera.hevc"): + cameras.setdefault(camera.name, []).append(int(segment)) + except OSError: + return {} + + available = {} + for camera, camera_segments in cameras.items(): + ranges: list[list[int]] = [] + for segment in sorted(camera_segments): + if ranges and ranges[-1][1] == segment * SEGMENT_LENGTH: + ranges[-1][1] += SEGMENT_LENGTH + else: + ranges.append([segment * SEGMENT_LENGTH, (segment + 1) * SEGMENT_LENGTH]) + available[camera] = {"available_ranges": ranges} + return available + + def createClip(self, route: str, source_start_time: float, source_end_time: float, clip: dict): + if not PC and not Params().get_bool("IsOffroad"): + raise RuntimeError("video clips can only be created while offroad") + route_match = re.fullmatch(RE.ROUTE_NAME, route) + assert route_match is not None, "invalid route" + route_name = route_match.group("log_id") + camera = clip["camera"] + filename = clip["filename"] + assert camera == os.path.basename(camera) and camera.endswith("camera.hevc"), "invalid camera filename" + assert filename == os.path.basename(filename), "invalid filename" + with self.lock: + self.clips[filename] = self.Clip(route_name, camera, source_start_time, source_end_time, clip["bitrate"], clip["speedup"], + filename, datetime.now().timestamp()) + self.lock.notify() + + def getClipState(self, route: str | None = None) -> dict: + route_match = re.search(RE.ROUTE_NAME, route or "") + with self.lock: + transcode_filename = self.transcode_proc[0] if self.transcode_proc is not None else None + active_clips = {clip.filename: {**asdict(clip), "status": "encoding" if clip.filename == transcode_filename else "queued"} + for clip in self.clips.values()} + clips = self._on_disk() + clips.update(active_clips) + state = {"clips": sorted(clips.values(), key=lambda clip: clip["requested_at"], reverse=True)} + if route_match is not None: + route_name = route_match.group("log_id") + state.update({"route": route_name, "cameras": self._available_ranges(route_name)}) + return state + + def deleteClip(self, filename: str) -> None: + assert filename == os.path.basename(filename), "invalid filename" + with self.lock: + self.clips.pop(filename, None) + output_path = os.path.join(self.clip_path, filename) + if self.transcode_proc is not None and self.transcode_proc[0] == filename: + self.transcode_proc[1].terminate() + if os.path.exists(output_path): + os.unlink(output_path) + + def getClipChunk(self, filename: str, offset: int) -> dict: + assert filename == os.path.basename(filename) and not filename.startswith("."), "invalid filename" + assert isinstance(offset, int) and offset >= 0, "invalid offset" + path = os.path.join(self.clip_path, filename) + size = os.path.getsize(path) + assert offset <= size, "offset past end of file" + with open(path, "rb") as f: + f.seek(offset) + data = f.read(CLIP_CHUNK_SIZE) + return {"data": base64.b64encode(data).decode(), "offset": offset, "size": size} + + +video_clips = VideoClips() +dispatcher.add_method(video_clips.createClip) +dispatcher.add_method(video_clips.getClipState) +dispatcher.add_method(video_clips.deleteClip) +dispatcher.add_method(video_clips.getClipChunk) + + @dispatcher.add_method def uploadFileToUrl(fn: str, url: str, headers: dict[str, str]) -> UploadFilesToUrlResponse: # this is because mypy doesn't understand that the decorator doesn't change the return type @@ -608,22 +828,24 @@ def startStream(sdp: str, enabled: bool) -> dict: bridge_services_in = [] # stale car params case taken care of by webrtcd being shut off on ignition - cp_bytes = Params().get("CarParamsPersistent") + cp_bytes = params.get("CarParamsPersistent") if cp_bytes is not None: with car.CarParams.from_bytes(cp_bytes) as CP: if CP.notCar: bridge_services_in.append("testJoystick") - else: - raise Exception("failed to get CarParamsPersistent") if params.get_bool("IsOffroad"): # manager owns camerad/stream_encoderd/webrtcd; flip the param and let it bring them up. # webrtcd clears IsLiveStreaming when the session ends params.put_bool("IsLiveStreaming", True) # wait for webrtcd end points to wake up - wait_for_webrtcd() + try: + wait_for_webrtcd() + except TimeoutError: + cloudlog.event("athena.startStream.webrtcd_offroad_start_timeout", error=True) + raise - return post_stream_request(StreamRequestBody(sdp, "wideRoad", enabled, bridge_services_in, ["carState", "deviceState"])) + return post_stream_request(StreamRequestBody(sdp, ["wideRoad"], enabled, bridge_services_in, ["carState", "deviceState"])) def get_logs_to_send_sorted(log_attr_name=LOG_ATTR_NAME) -> list[str]: @@ -679,18 +901,17 @@ def add_log_to_queue(log_path, log_id, is_sunnylink=False): f"after compression: {compressed_size} bytes, " + f"after encoding: {encoded_size} bytes") - jsonrpc = { + params: dict[str, str | bool] = {"logs": payload} + if is_sunnylink and is_compressed: + params["compressed"] = is_compressed + + jsonrpc: dict = { "method": "forwardLogs", - "params": { - "logs": payload - }, + "params": params, "jsonrpc": "2.0", "id": log_id } - if is_sunnylink and is_compressed: - jsonrpc["params"]["compressed"] = is_compressed - jsonrpc_str = json.dumps(jsonrpc) size_in_bytes = len(jsonrpc_str.encode('utf-8')) @@ -783,18 +1004,17 @@ def stat_handler(end_event: threading.Event, stats_dir=None, is_sunnylink=False) payload = base64.b64encode(compressed_data).decode() is_compressed = True - jsonrpc = { + params: dict[str, str | bool] = {"stats": payload} + if is_sunnylink and is_compressed: + params["compressed"] = is_compressed + + jsonrpc: dict = { "method": "storeStats", - "params": { - "stats": payload - }, + "params": params, "jsonrpc": "2.0", "id": stat_filenames[0] } - if is_sunnylink and is_compressed: - jsonrpc["params"]["compressed"] = is_compressed - send_queue_push(json.dumps(jsonrpc), SEND_PRIORITY_LOW) os.remove(stat_path) last_scan = curr_scan @@ -806,7 +1026,10 @@ def stat_handler(end_event: threading.Event, stats_dir=None, is_sunnylink=False) def ws_proxy_recv(ws: WebSocket, local_sock: socket.socket, ssock: socket.socket, end_event: threading.Event, global_end_event: threading.Event) -> None: while not (end_event.is_set() or global_end_event.is_set()): try: - r = select.select((ws.sock,), (), (), 30) + sock = ws.sock + if sock is None: + return + r = select.select((sock,), (), (), 30) if r[0]: data = ws.recv() if isinstance(data, str): diff --git a/openpilot/system/athena/registration.py b/openpilot/system/athena/registration.py index f8ff40ca79..5e80fe5598 100755 --- a/openpilot/system/athena/registration.py +++ b/openpilot/system/athena/registration.py @@ -83,6 +83,9 @@ def register(show_spinner=False) -> str | None: dongleauth = json.loads(resp.text) dongle_id = dongleauth["dongle_id"] break + except NotImplementedError: + # dependency issues with PyJWT will hang the registration test in backoff loop otherwise + raise except Exception: cloudlog.exception("failed to authenticate") backoff = min(backoff + 1, 15) diff --git a/openpilot/system/athena/tests/helpers.py b/openpilot/system/athena/tests/helpers.py index a0a9cccdc1..dbca66be10 100644 --- a/openpilot/system/athena/tests/helpers.py +++ b/openpilot/system/athena/tests/helpers.py @@ -43,11 +43,10 @@ class MockApi: class MockWebsocket: - sock = socket.socket() - def __init__(self, recv_queue, send_queue): self.recv_queue = recv_queue self.send_queue = send_queue + self.sock = socket.socket() def recv(self): data = self.recv_queue.get() @@ -59,7 +58,7 @@ class MockWebsocket: self.send_queue.put_nowait((data, opcode)) def close(self): - pass + self.sock.close() class HTTPRequestHandler(http.server.SimpleHTTPRequestHandler): diff --git a/openpilot/system/athena/tests/test_athenad.py b/openpilot/system/athena/tests/test_athenad.py index 253a057b91..f9e4c0d334 100644 --- a/openpilot/system/athena/tests/test_athenad.py +++ b/openpilot/system/athena/tests/test_athenad.py @@ -1,4 +1,3 @@ -import pytest from functools import wraps import json import multiprocessing @@ -14,6 +13,8 @@ from datetime import datetime, timedelta from websocket import ABNF from websocket._exceptions import WebSocketConnectionClosedException +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import messaging from openpilot.common.params import Params @@ -47,20 +48,18 @@ def with_upload_handler(func): thread.join() return wrapper -@pytest.fixture def mock_create_connection(mocker): - return mocker.patch('openpilot.system.athena.athenad.create_connection') + return mocker.patch('openpilot.system.athena.athenad.create_connection') -@pytest.fixture def host(): with http_server_context(handler=HTTPRequestHandler, setup=seed_athena_server) as (host, port): yield f"http://{host}:{port}" -class TestAthenadMethods: +class TestAthenadMethods(OpenpilotTestCase): @classmethod def setup_class(cls): cls.SOCKET_PORT = 45454 - athenad.Api = MockApi + athenad.Api = MockApi # ty: ignore[invalid-assignment] # test double athenad.LOCAL_PORT_WHITELIST = {cls.SOCKET_PORT} def setup_method(self): @@ -104,6 +103,14 @@ class TestAthenadMethods: f.write(data) return fn + @staticmethod + def _video_clips(clip): + clips = object.__new__(athenad.VideoClips) + clips.lock = threading.Condition() + clips.clips = {clip.filename: clip} + clips.transcode_proc = None + return clips + # *** test cases *** @@ -111,7 +118,7 @@ class TestAthenadMethods: assert dispatcher["echo"]("bob") == "bob" def test_get_message(self): - with pytest.raises(TimeoutError) as _: + with self.assertRaises(TimeoutError) as _: dispatcher["getMessage"]("controlsState") end_event = multiprocessing.Event() @@ -174,6 +181,56 @@ class TestAthenadMethods: assert resp, 'list empty!' assert len(resp) == len(expected) + def test_video_clip_hardware_encoder(self, mocker): + clip = athenad.VideoClips.Clip("route", "fcamera.hevc", 10, 130, 2, 4, "clip.mp4", 123) + clips = self._video_clips(clip) + process = mocker.Mock(stdin=None, returncode=0) + process.poll.return_value = 0 + popen = mocker.patch("openpilot.system.athena.athenad.subprocess.Popen", return_value=process) + mocker.patch.object(athenad, "PC", False) + + clips._encode(clip, ["segment0", "segment1"], "output.mp4", 10, 120) + + metadata = json.dumps(asdict(clip), separators=(',', ':')) + assert popen.call_args.args[0] == [ + os.path.join(athenad.BASEDIR, "openpilot/system/loggerd/encoderd"), "--clip", "output.mp4", "10", "120", + "--bitrate", "2000000", "--speedup", "4", "--metadata", metadata, "--", "segment0", "segment1", + ] + assert popen.call_args.kwargs["stdin"] == athenad.subprocess.DEVNULL + assert clips.transcode_proc is None + + def test_video_clip_hardware_encoder_failure(self, mocker): + clip = athenad.VideoClips.Clip("route", "fcamera.hevc", 0, 60, 1, 1, "clip.mp4", 123) + clips = self._video_clips(clip) + process = mocker.Mock(stdin=None, returncode=1) + process.poll.return_value = 1 + mocker.patch("openpilot.system.athena.athenad.subprocess.Popen", return_value=process) + mocker.patch.object(athenad, "PC", False) + + with self.assertRaisesRegex(RuntimeError, "clip encoder exited with code 1"): + clips._encode(clip, ["segment"], "output.mp4", 0, 60) + assert clips.transcode_proc is None + + def test_video_clip_software_fallback(self, mocker): + clip = athenad.VideoClips.Clip("route", "fcamera.hevc", 10, 30, 3, 2, "clip.mp4", 123) + clips = self._video_clips(clip) + stdin = mocker.Mock() + process = mocker.Mock(stdin=stdin, returncode=0) + process.poll.return_value = 0 + popen = mocker.patch("openpilot.system.athena.athenad.subprocess.Popen", return_value=process) + mocker.patch.object(athenad, "PC", True) + + clips._encode(clip, ["segment'0", "segment1"], "output.mp4", 10, 20) + + command = popen.call_args.args[0] + assert ["-r", "40"] == command[command.index("-r"):command.index("-r") + 2] + assert ["-ss", "5.0"] == command[command.index("-ss"):command.index("-ss") + 2] + assert ["-t", "10.0"] == command[command.index("-t"):command.index("-t") + 2] + assert ["-b:v", "3M"] == command[command.index("-b:v"):command.index("-b:v") + 2] + writes = [call.args[0] for call in stdin.write.call_args_list] + assert "file 'file:segment'\\''0'\n" in writes[1] + assert writes[-1].startswith("file 'file:segment1'") + def test_strip_extension(self): # any requested log file with an invalid extension won't return as existing fn = self._create_file('qlog.bz2') @@ -184,14 +241,14 @@ class TestAthenadMethods: if fn.endswith('.zst'): assert athenad.strip_zst_extension(fn) == fn[:-4] - @pytest.mark.parametrize("compress", [True, False]) + @parameterized.expand([True, False], names=("compress",)) def test_do_upload(self, host, compress): # random bytes to ensure rather large object post-compression fn = self._create_file('qlog', data=os.urandom(10000 * 1024)) upload_fn = fn + ('.zst' if compress else '') item = athenad.UploadItem(path=upload_fn, url="http://localhost:1238", headers={}, created_at=int(time.time()*1000), id='') # noqa: TID251 - with pytest.raises(requests.exceptions.ConnectionError): + with self.assertRaises(requests.exceptions.ConnectionError): athenad._do_upload(item) item = athenad.UploadItem(path=upload_fn, url=f"{host}/qlog.zst", headers={}, created_at=int(time.time()*1000), id='') # noqa: TID251 @@ -236,7 +293,7 @@ class TestAthenadMethods: # TODO: also check that end_event and metered network raises AbortTransferException assert athenad.upload_queue.qsize() == 0 - @pytest.mark.parametrize("status,retry", [(500,True), (412,False)]) + @parameterized.expand([(500,True), (412,False)], names=("status", "retry")) @with_upload_handler def test_upload_handler_retry(self, mocker, host, status, retry): mock_put = mocker.patch('openpilot.system.athena.athenad.UPLOAD_SESS.put') @@ -351,6 +408,7 @@ class TestAthenadMethods: assert items[0] == asdict(item) assert not items[0]['current'] + assert item.id is not None athenad.cancelled_uploads.add(item.id) items = dispatcher["listUploadQueue"]() assert len(items) == 0 @@ -363,6 +421,7 @@ class TestAthenadMethods: athenad.upload_queue.put_nowait(item2) # Ensure canceled items are not persisted + assert item2.id is not None athenad.cancelled_uploads.add(item2.id) # serialize item @@ -408,7 +467,7 @@ class TestAthenadMethods: def test_get_version(self): resp = dispatcher["getVersion"]() - keys = ["version", "remote", "branch", "commit"] + keys = ["version", "remote", "branch", "commit", "commit_date"] assert list(resp.keys()) == keys for k in keys: assert isinstance(resp[k], str), f"{k} is not a string" @@ -437,7 +496,7 @@ class TestAthenadMethods: thread.join() def test_get_logs_to_send_sorted(self): - fl = list() + fl = [] for i in range(10): file = f'swaglog.{i:010}' self._create_file(file, Paths.swaglog_root()) diff --git a/openpilot/system/athena/tests/test_athenad_ping.py b/openpilot/system/athena/tests/test_athenad_ping.py index 44a6b8a56b..33876a6235 100644 --- a/openpilot/system/athena/tests/test_athenad_ping.py +++ b/openpilot/system/athena/tests/test_athenad_ping.py @@ -1,25 +1,26 @@ -import pytest +import unittest import subprocess import threading import time from typing import cast +from openpilot.common.test import OpenpilotTestCase from openpilot.common.params import Params from openpilot.common.timeout import Timeout from openpilot.system.athena import athenad -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE TIMEOUT_TOLERANCE = 20 # seconds def wifi_radio(on: bool) -> None: - if not TICI: + if not COMMA_HARDWARE: return print(f"wifi {'on' if on else 'off'}") subprocess.run(["nmcli", "radio", "wifi", "on" if on else "off"], check=True) -class TestAthenadPing: +class TestAthenadPing(OpenpilotTestCase): params: Params dongle_id: str @@ -90,12 +91,12 @@ class TestAthenadPing: time.sleep(0.1) print("ping received") - @pytest.mark.skipif(not TICI, reason="only run on desk") + @unittest.skipIf(not COMMA_HARDWARE, "only run on desk") def test_offroad(self, subtests, mocker) -> None: self.params.put_bool("IsOffroad", True, block=True) self.assertTimeout(60 + TIMEOUT_TOLERANCE, subtests, mocker) # based using TCP keepalive settings - @pytest.mark.skipif(not TICI, reason="only run on desk") + @unittest.skipIf(not COMMA_HARDWARE, "only run on desk") def test_onroad(self, subtests, mocker) -> None: self.params.put_bool("IsOffroad", False, block=True) self.assertTimeout(21 + TIMEOUT_TOLERANCE, subtests, mocker) diff --git a/openpilot/system/athena/tests/test_registration.py b/openpilot/system/athena/tests/test_registration.py index bb1523de80..3c75255c61 100644 --- a/openpilot/system/athena/tests/test_registration.py +++ b/openpilot/system/athena/tests/test_registration.py @@ -2,13 +2,14 @@ import json from Crypto.PublicKey import RSA from pathlib import Path +from openpilot.common.test import OpenpilotTestCase from openpilot.common.params import Params from openpilot.system.athena.registration import register, UNREGISTERED_DONGLE_ID from openpilot.system.athena.tests.helpers import MockResponse from openpilot.common.hardware.hw import Paths -class TestRegistration: +class TestRegistration(OpenpilotTestCase): def setup_method(self): # clear params and setup key paths diff --git a/openpilot/system/camerad/SConscript b/openpilot/system/camerad/SConscript index c28330b32c..e6bc3f2bfb 100644 --- a/openpilot/system/camerad/SConscript +++ b/openpilot/system/camerad/SConscript @@ -6,6 +6,3 @@ if arch != "Darwin": camera_obj = env.Object(['cameras/camera_qcom2.cc', 'cameras/camera_common.cc', 'cameras/spectra.cc', 'cameras/cdm.cc', 'sensors/ox03c10.cc', 'sensors/os04c10.cc']) env.Program('camerad', ['main.cc', camera_obj], LIBS=libs) - -if GetOption("extras") and arch == "x86_64": - env.Program('test/test_ae_gray', ['test/test_ae_gray.cc', camera_obj], LIBS=libs) diff --git a/openpilot/system/camerad/cameras/camera_qcom2.cc b/openpilot/system/camerad/cameras/camera_qcom2.cc index 63640920db..4d30cd1e2c 100644 --- a/openpilot/system/camerad/cameras/camera_qcom2.cc +++ b/openpilot/system/camerad/cameras/camera_qcom2.cc @@ -234,11 +234,11 @@ void CameraState::sendState() { framed.setSensor(camera.sensor->image_sensor); // Log raw frames for road camera - if (env_log_raw_frames && camera.cc.stream_type == VISION_STREAM_ROAD && meta.frame_id % 100 == 5) { // no overlap with qlog decimation + if (env_log_raw_frames && camera.cc.stream_type == VISION_STREAM_NARROW_ROAD && meta.frame_id % 100 == 5) { // no overlap with qlog decimation framed.setImage(get_raw_frame_image(&camera.buf)); } - set_camera_exposure(calculate_exposure_value(&camera.buf, ae_xywh, 2, camera.cc.stream_type != VISION_STREAM_DRIVER ? 2 : 4)); + set_camera_exposure(calculate_exposure_value(&camera.buf, ae_xywh, 2, camera.cc.stream_type != VISION_STREAM_CABIN ? 2 : 4)); // Send the message pm->send(camera.cc.publish_name, msg); diff --git a/openpilot/system/camerad/cameras/hw.h b/openpilot/system/camerad/cameras/hw.h index be0bea872d..8f9de5bede 100644 --- a/openpilot/system/camerad/cameras/hw.h +++ b/openpilot/system/camerad/cameras/hw.h @@ -2,6 +2,7 @@ #include "common/util.h" #include "openpilot/cereal/gen/cpp/log.capnp.h" +#include "openpilot/cereal/visionstream.h" #include "msgq/visionipc/visionipc_server.h" #include "media/cam_isp_ife.h" @@ -43,12 +44,12 @@ const CameraConfig WIDE_ROAD_CAMERA_CONFIG = { .staggered_sof = false, }; -const CameraConfig ROAD_CAMERA_CONFIG = { +const CameraConfig NARROW_ROAD_CAMERA_CONFIG = { .camera_num = 1, - .stream_type = VISION_STREAM_ROAD, + .stream_type = VISION_STREAM_NARROW_ROAD, .focal_len = 8.0, - .publish_name = "roadCameraState", - .init_camera_state = &cereal::Event::Builder::initRoadCameraState, + .publish_name = "narrowRoadCameraState", + .init_camera_state = &cereal::Event::Builder::initNarrowRoadCameraState, .enabled = !getenv("DISABLE_ROAD"), .phy = CAM_ISP_IFE_IN_RES_PHY_1, .vignetting_correction = true, @@ -56,12 +57,12 @@ const CameraConfig ROAD_CAMERA_CONFIG = { .staggered_sof = false, }; -const CameraConfig DRIVER_CAMERA_CONFIG = { +const CameraConfig CABIN_CAMERA_CONFIG = { .camera_num = 2, - .stream_type = VISION_STREAM_DRIVER, + .stream_type = VISION_STREAM_CABIN, .focal_len = 1.71, - .publish_name = "driverCameraState", - .init_camera_state = &cereal::Event::Builder::initDriverCameraState, + .publish_name = "cabinCameraState", + .init_camera_state = &cereal::Event::Builder::initCabinCameraState, .enabled = !getenv("DISABLE_DRIVER"), .phy = CAM_ISP_IFE_IN_RES_PHY_2, .vignetting_correction = false, @@ -69,4 +70,4 @@ const CameraConfig DRIVER_CAMERA_CONFIG = { .staggered_sof = true, }; -const CameraConfig ALL_CAMERA_CONFIGS[] = {WIDE_ROAD_CAMERA_CONFIG, ROAD_CAMERA_CONFIG, DRIVER_CAMERA_CONFIG}; +const CameraConfig ALL_CAMERA_CONFIGS[] = {WIDE_ROAD_CAMERA_CONFIG, NARROW_ROAD_CAMERA_CONFIG, CABIN_CAMERA_CONFIG}; diff --git a/openpilot/system/camerad/cameras/spectra.cc b/openpilot/system/camerad/cameras/spectra.cc index 0b5f5327f5..2cbf3594c4 100644 --- a/openpilot/system/camerad/cameras/spectra.cc +++ b/openpilot/system/camerad/cameras/spectra.cc @@ -609,7 +609,7 @@ void SpectraCamera::config_bps(int idx, int request_id) { tmp.header = CAM_ICP_CMD_GENERIC_BLOB_CLK; tmp.header |= (sizeof(cam_icp_clk_bw_request)) << 8; tmp.clk.budget_ns = 0x1fca058; - tmp.clk.frame_cycles = sensor->frame_width * sensor->frame_height; // matches striping lib pixelCount + tmp.clk.frame_cycles = 20000000; // force max BPS clock (600 MHz) tmp.clk.rt_flag = 0x0; tmp.clk.uncompressed_bw = 0x38512180; tmp.clk.compressed_bw = 0x38512180; @@ -1398,14 +1398,14 @@ bool SpectraCamera::handle_camera_event(const cam_req_mgr_message *event_data) { */ uint64_t request_id = event_data->u.frame_msg.request_id; // ID from the camera request manager - uint64_t frame_id_raw = event_data->u.frame_msg.frame_id; // raw as opposed to our re-indexed frame ID + uint64_t ife_frame_id = event_data->u.frame_msg.frame_id; // kernel counter incremented on each IFE SOF event uint64_t timestamp = event_data->u.frame_msg.timestamp; // timestamped in the kernel's SOF IRQ callback - //LOGD("handle cam %d ts %lu req id %lu frame id %lu", cc.camera_num, timestamp, request_id, frame_id_raw); + //LOGD("handle cam %d ts %lu req id %lu frame id %lu", cc.camera_num, timestamp, request_id, ife_frame_id); // if there's a lag, some more frames could have already come in before // we cleared the queue, so we'll still get them with valid (> 0) request IDs. if (timestamp < last_requeue_ts) { - LOGD("skipping frame: ts before requeue / cam %d ts %lu req id %lu frame id %lu", cc.camera_num, timestamp, request_id, frame_id_raw); + LOGD("skipping frame: ts before requeue / cam %d ts %lu req id %lu frame id %lu", cc.camera_num, timestamp, request_id, ife_frame_id); return false; } @@ -1413,39 +1413,39 @@ bool SpectraCamera::handle_camera_event(const cam_req_mgr_message *event_data) { return false; } - if (!validateEvent(request_id, frame_id_raw)) { + if (!validateEvent(request_id, ife_frame_id)) { return false; } // Update tracking variables - if (request_id == request_id_last + 1) { + if (request_id == last_valid_request_id + 1) { skip_expected = false; } - frame_id_raw_last = frame_id_raw; - request_id_last = request_id; + last_valid_ife_frame_id = ife_frame_id; + last_valid_request_id = request_id; // Wait until frame's fully read out and processed if (!waitForFrameReady(request_id)) { // Reset queue on sync failure to prevent frame tearing - LOGE("camera %d sync failure %ld %ld ", cc.camera_num, request_id, frame_id_raw); + LOGE("camera %d sync failure %ld %ld ", cc.camera_num, request_id, ife_frame_id); clearAndRequeue(request_id + 1); return false; } int buf_idx = request_id % ife_buf_depth; - bool ret = processFrame(buf_idx, request_id, frame_id_raw, timestamp); + bool ret = processFrame(buf_idx, request_id, ife_frame_id, timestamp); destroySyncObjectAt(buf_idx); enqueue_frame(request_id + ife_buf_depth); // request next frame for this slot return ret; } -bool SpectraCamera::validateEvent(uint64_t request_id, uint64_t frame_id_raw) { +bool SpectraCamera::validateEvent(uint64_t request_id, uint64_t ife_frame_id) { // check if the request ID is even valid. this happens after queued // requests are cleared. unclear if it happens any other time. if (request_id == 0) { if (invalid_request_count++ > ife_buf_depth+2) { LOGE("camera %d reset after half second of invalid requests", cc.camera_num); - clearAndRequeue(request_id_last + 1); + clearAndRequeue(last_valid_request_id + 1); invalid_request_count = 0; } return false; @@ -1454,14 +1454,14 @@ bool SpectraCamera::validateEvent(uint64_t request_id, uint64_t frame_id_raw) { // check for skips in frame_id or request_id if (!skip_expected) { - if (frame_id_raw != frame_id_raw_last + 1) { - LOGE("camera %d frame ID skipped, %lu -> %lu", cc.camera_num, frame_id_raw_last, frame_id_raw); + if (ife_frame_id != last_valid_ife_frame_id + 1) { + LOGE("camera %d frame ID skipped, %lu -> %lu", cc.camera_num, last_valid_ife_frame_id, ife_frame_id); clearAndRequeue(request_id + 1); return false; } - if (request_id != request_id_last + 1) { - LOGE("camera %d requests skipped %ld -> %ld", cc.camera_num, request_id_last, request_id); + if (request_id != last_valid_request_id + 1) { + LOGE("camera %d requests skipped %ld -> %ld", cc.camera_num, last_valid_request_id, request_id); clearAndRequeue(request_id + 1); return false; } @@ -1512,8 +1512,8 @@ bool SpectraCamera::waitForFrameReady(uint64_t request_id) { return success; } -bool SpectraCamera::processFrame(int buf_idx, uint64_t request_id, uint64_t frame_id_raw, uint64_t timestamp) { - if (!syncFirstFrame(cc.camera_num, request_id, frame_id_raw, timestamp, cc.staggered_sof)) { +bool SpectraCamera::processFrame(int buf_idx, uint64_t request_id, uint64_t ife_frame_id, uint64_t timestamp) { + if (!syncFirstFrame(cc.camera_num, request_id, ife_frame_id, timestamp, cc.staggered_sof)) { return false; } @@ -1523,7 +1523,7 @@ bool SpectraCamera::processFrame(int buf_idx, uint64_t request_id, uint64_t fram // Update buffer and frame data buf.cur_buf_idx = buf_idx; buf.cur_frame_data = { - .frame_id = (uint32_t)(frame_id_raw - camera_sync_data[cc.camera_num].frame_id_offset), + .frame_id = (uint32_t)(ife_frame_id - camera_sync_data[cc.camera_num].frame_id_offset), .request_id = (uint32_t)request_id, .timestamp_sof = timestamp, .timestamp_eof = timestamp_eof, @@ -1532,11 +1532,11 @@ bool SpectraCamera::processFrame(int buf_idx, uint64_t request_id, uint64_t fram return true; } -bool SpectraCamera::syncFirstFrame(int camera_id, uint64_t request_id, uint64_t raw_id, uint64_t timestamp, bool staggered) { +bool SpectraCamera::syncFirstFrame(int camera_id, uint64_t request_id, uint64_t ife_frame_id, uint64_t timestamp, bool staggered) { if (first_frame_synced) return true; // Store the frame data for this camera - camera_sync_data[camera_id] = SyncData{timestamp, raw_id + 1, staggered}; + camera_sync_data[camera_id] = SyncData{timestamp, ife_frame_id + 1, staggered}; // Ensure all cameras are up int enabled_camera_count = std::count_if(std::begin(ALL_CAMERA_CONFIGS), std::end(ALL_CAMERA_CONFIGS), @@ -1569,7 +1569,7 @@ bool SpectraCamera::syncFirstFrame(int camera_id, uint64_t request_id, uint64_t } // Timeout in case the timestamps never line up - if (raw_id > 40) { + if (ife_frame_id > 40) { LOGE("camera first frame sync timed out"); first_frame_synced = true; } diff --git a/openpilot/system/camerad/cameras/spectra.h b/openpilot/system/camerad/cameras/spectra.h index eb6d95af1c..acb2000313 100644 --- a/openpilot/system/camerad/cameras/spectra.h +++ b/openpilot/system/camerad/cameras/spectra.h @@ -205,9 +205,9 @@ public: int buf_handle_raw[MAX_IFE_BUFS] = {}; int sync_objs_ife[MAX_IFE_BUFS] = {}; int sync_objs_bps[MAX_IFE_BUFS] = {}; - uint64_t request_id_last = 0; + uint64_t last_valid_request_id = 0; uint64_t last_requeue_ts = 0; - uint64_t frame_id_raw_last = 0; + uint64_t last_valid_ife_frame_id = 0; int invalid_request_count = 0; bool skip_expected = true; @@ -216,10 +216,10 @@ public: private: void clearAndRequeue(uint64_t from_request_id); - bool validateEvent(uint64_t request_id, uint64_t frame_id_raw); + bool validateEvent(uint64_t request_id, uint64_t ife_frame_id); bool waitForFrameReady(uint64_t request_id); - bool processFrame(int buf_idx, uint64_t request_id, uint64_t frame_id_raw, uint64_t timestamp); - static bool syncFirstFrame(int camera_id, uint64_t request_id, uint64_t raw_id, uint64_t timestamp, bool staggered); + bool processFrame(int buf_idx, uint64_t request_id, uint64_t ife_frame_id, uint64_t timestamp); + static bool syncFirstFrame(int camera_id, uint64_t request_id, uint64_t ife_frame_id, uint64_t timestamp, bool staggered); struct SyncData { uint64_t timestamp; uint64_t frame_id_offset = 0; diff --git a/openpilot/system/camerad/snapshot.py b/openpilot/system/camerad/snapshot.py index 8383865fce..622da4faef 100755 --- a/openpilot/system/camerad/snapshot.py +++ b/openpilot/system/camerad/snapshot.py @@ -3,13 +3,14 @@ import numpy as np import openpilot.cereal.messaging as messaging -from msgq.visionipc import VisionIpcClient, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcClient from openpilot.common.realtime import DT_MDL VISION_STREAMS = { - "roadCameraState": VisionStreamType.VISION_STREAM_ROAD, - "driverCameraState": VisionStreamType.VISION_STREAM_DRIVER, + "narrowRoadCameraState": VisionStreamType.VISION_STREAM_NARROW_ROAD, + "cabinCameraState": VisionStreamType.VISION_STREAM_CABIN, "wideRoadCameraState": VisionStreamType.VISION_STREAM_WIDE_ROAD, } @@ -44,7 +45,7 @@ def extract_image(buf): return yuv_to_rgb(y, u, v) -def get_snapshots(frame="roadCameraState", front_frame="driverCameraState"): +def get_snapshots(frame="narrowRoadCameraState", front_frame="cabinCameraState"): sockets = [s for s in (frame, front_frame) if s is not None] sm = messaging.SubMaster(sockets) vipc_clients = {s: VisionIpcClient("camerad", VISION_STREAMS[s], True) for s in sockets} diff --git a/openpilot/system/camerad/test/test_ae_gray.cc b/openpilot/system/camerad/test/test_ae_gray.cc deleted file mode 100644 index 39c3d9c4e5..0000000000 --- a/openpilot/system/camerad/test/test_ae_gray.cc +++ /dev/null @@ -1,84 +0,0 @@ -#define CATCH_CONFIG_MAIN -#include "catch2/catch.hpp" - -#include - -#include -#include - -#include "common/util.h" -#include "system/camerad/cameras/camera_common.h" - -#define W 240 -#define H 160 - - -#define TONE_SPLITS 3 - -float gts[TONE_SPLITS * TONE_SPLITS * TONE_SPLITS * TONE_SPLITS] = { - 0.917969, 0.917969, 0.375000, 0.917969, 0.375000, 0.375000, 0.187500, 0.187500, 0.187500, 0.917969, - 0.375000, 0.375000, 0.187500, 0.187500, 0.187500, 0.187500, 0.187500, 0.187500, 0.093750, 0.093750, - 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.917969, 0.375000, 0.375000, - 0.187500, 0.187500, 0.187500, 0.187500, 0.187500, 0.187500, 0.093750, 0.093750, 0.093750, 0.093750, - 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, - 0.093750, 0.093750, 0.093750, 0.093750, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, - 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, - 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, - 0.000000}; - - -TEST_CASE("camera.test_calculate_exposure_value") { - // set up fake camerabuf - CameraBuf cb = {}; - VisionBuf vb = {}; - uint8_t * fb_y = new uint8_t[W*H]; - vb.y = fb_y; - cb.cur_yuv_buf = &vb; - cb.out_img_width = W; - cb.out_img_height = H; - Rect rect = {0, 0, W-1, H-1}; - - printf("AE test patterns %dx%d\n", cb.out_img_width, cb.out_img_height); - - // mix of 5 tones - uint8_t l[5] = {0, 24, 48, 96, 235}; // 235 is yuv max - - bool passed = true; - float rtol = 0.05; - // generate pattern and calculate EV - int cnt = 0; - for (int i_0=0; i_0 rtol*evgt) { - passed = false; - } - - // report - printf("%d/%d/%d/%d/%d: ev %f, gt %f, err %f\n", h_0, h_1, h_2, h_3, h_4, ev, evgt, fabs(ev - evgt) / (evgt != 0 ? evgt : 0.00001f)); - cnt++; - } - } - } - } - assert(passed); - - delete[] fb_y; -} diff --git a/openpilot/system/camerad/test/test_camerad.py b/openpilot/system/camerad/test/test_camerad.py old mode 100644 new mode 100755 index afc49c02bf..410ea9fdb3 --- a/openpilot/system/camerad/test/test_camerad.py +++ b/openpilot/system/camerad/test/test_camerad.py @@ -1,15 +1,19 @@ +#!/usr/bin/env python3 + import os import time -import pytest +import unittest import numpy as np +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal.services import SERVICE_LIST from openpilot.tools.lib.log_time_series import msgs_to_time_series from openpilot.system.camerad.snapshot import get_snapshots from openpilot.selfdrive.test.helpers import collect_logs, log_collector, processes_context TEST_TIMESPAN = 10 -CAMERAS = ('roadCameraState', 'driverCameraState', 'wideRoadCameraState') +CAMERAS = ('narrowRoadCameraState', 'cabinCameraState', 'wideRoadCameraState') EXPOSURE_STABLE_COUNT = 3 EXPOSURE_RANGE = (0.15, 0.35) MAX_TEST_TIME = 25 @@ -38,7 +42,6 @@ def run_and_log(procs, services, duration): with processes_context(procs): return collect_logs(services, duration) -@pytest.fixture(scope="module") def _camera_session(): """Single camerad session that collects logs and exposure data. Runs until exposure stabilizes (min TEST_TIMESPAN seconds for enough log data).""" @@ -46,7 +49,7 @@ def _camera_session(): exposure = {cam: [] for cam in CAMERAS} start = time.monotonic() while time.monotonic() - start < MAX_TEST_TIME: - rpic, dpic = get_snapshots(frame="roadCameraState", front_frame="driverCameraState") + rpic, dpic = get_snapshots(frame="narrowRoadCameraState", front_frame="cabinCameraState") wpic, _ = get_snapshots(frame="wideRoadCameraState") for cam, img in zip(CAMERAS, [rpic, dpic, wpic], strict=True): exposure[cam].append(_exposure_stats(img)) @@ -69,20 +72,18 @@ def _camera_session(): return ts, exposure -@pytest.fixture(scope="module") -def logs(_camera_session): - return _camera_session[0] +class TestCamerad(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True -@pytest.fixture(scope="module") -def exposure_data(_camera_session): - return _camera_session[1] + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.logs, cls.exposure_data = _camera_session() -@pytest.mark.tici -class TestCamerad: - @pytest.mark.parametrize("cam", CAMERAS) - def test_camera_exposure(self, exposure_data, cam): + @parameterized.expand(CAMERAS, names=("cam",)) + def test_camera_exposure(self, cam): lo, hi = EXPOSURE_RANGE - checks = exposure_data[cam] + checks = self.exposure_data[cam] assert len(checks) >= EXPOSURE_STABLE_COUNT, f"{cam}: only got {len(checks)} samples" # check that exposure converges into the valid range @@ -96,34 +97,34 @@ class TestCamerad: for i, (median, mean) in enumerate(checks): ok = _in_range(median, mean) if in_range and not ok: - pytest.fail(f"{cam}: exposure regressed on sample {i+1} " + + self.fail(f"{cam}: exposure regressed on sample {i+1} " + f"(median={median:.4f}, mean={mean:.4f}, expected: ({lo}, {hi}))") in_range = ok - def test_frame_skips(self, logs): + def test_frame_skips(self): for c in CAMERAS: - assert set(np.diff(logs[c]['frameId'])) == {1, }, f"{c} has frame skips" + assert set(np.diff(self.logs[c]['frameId'])) == {1, }, f"{c} has frame skips" - def test_frame_sync(self, logs): - SYNCED_CAMS = ('roadCameraState', 'wideRoadCameraState') - n = range(len(logs['roadCameraState']['t'][:-10])) + def test_frame_sync(self): + SYNCED_CAMS = ('narrowRoadCameraState', 'wideRoadCameraState') + n = range(len(self.logs['narrowRoadCameraState']['t'][:-10])) - frame_ids = {i: [logs[cam]['frameId'][i] for cam in CAMERAS] for i in n} + frame_ids = {i: [self.logs[cam]['frameId'][i] for cam in CAMERAS] for i in n} assert all(len(set(v)) == 1 for v in frame_ids.values()), "frame IDs not aligned" # road and wide cameras should be synced within 1.1ms - synced_times = {i: [logs[cam]['timestampSof'][i] for cam in SYNCED_CAMS] for i in n} + synced_times = {i: [self.logs[cam]['timestampSof'][i] for cam in SYNCED_CAMS] for i in n} diffs = {i: (max(ts) - min(ts))/1e6 for i, ts in synced_times.items()} laggy_frames = {k: v for k, v in diffs.items() if v > 1.1} assert len(laggy_frames) == 0, f"Frames not synced properly: {laggy_frames=}" - # driver camera should be staggered ~25ms from road camera + # cabin camera should be staggered ~25ms from road camera for i in n: - offset_ms = abs(logs['driverCameraState']['timestampSof'][i] - logs['roadCameraState']['timestampSof'][i]) / 1e6 - assert 20 < offset_ms < 30, f"driver camera stagger out of range at frame {i}: {offset_ms:.1f}ms (expected ~25ms)" + offset_ms = abs(self.logs['cabinCameraState']['timestampSof'][i] - self.logs['narrowRoadCameraState']['timestampSof'][i]) / 1e6 + assert 20 < offset_ms < 30, f"cabin camera stagger out of range at frame {i}: {offset_ms:.1f}ms (expected ~25ms)" - def test_sanity_checks(self, logs): - self._sanity_checks(logs) + def test_sanity_checks(self): + self._sanity_checks(self.logs) def _sanity_checks(self, ts): for c in CAMERAS: @@ -160,3 +161,7 @@ class TestCamerad: assert np.max([ np.max(np.diff(ts[c]['requestId'])) for c in CAMERAS ]) > 1 self._sanity_checks(ts) + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/system/camerad/webcam/README.md b/openpilot/system/camerad/webcam/README.md index 17d7ee6d80..7fa28a9919 100644 --- a/openpilot/system/camerad/webcam/README.md +++ b/openpilot/system/camerad/webcam/README.md @@ -1,7 +1,7 @@ # Run openpilot with webcam on PC ## Setup openpilot -- Follow [this readme](../README.md) to install and build the requirements +- Follow [this readme](/tools/README.md) to install and build the requirements ## Connect the hardware - Connect the camera first diff --git a/openpilot/system/camerad/webcam/camerad.py b/openpilot/system/camerad/webcam/camerad.py index c46482360d..4dfa5f3371 100755 --- a/openpilot/system/camerad/webcam/camerad.py +++ b/openpilot/system/camerad/webcam/camerad.py @@ -4,25 +4,26 @@ import os import platform from collections import namedtuple -from msgq.visionipc import VisionIpcServer, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcServer from openpilot.cereal import messaging from openpilot.system.camerad.webcam.camera import Camera from openpilot.common.realtime import Ratekeeper -ROAD_CAM = os.getenv("ROAD_CAM", "0") +NARROW_ROAD_CAM = os.getenv("NARROW_ROAD_CAM", os.getenv("ROAD_CAM", "0")) WIDE_CAM = os.getenv("WIDE_CAM") DRIVER_CAM = os.getenv("DRIVER_CAM") CameraType = namedtuple("CameraType", ["msg_name", "stream_type", "cam_id"]) CAMERAS = [ - CameraType("roadCameraState", VisionStreamType.VISION_STREAM_ROAD, ROAD_CAM) + CameraType("narrowRoadCameraState", VisionStreamType.VISION_STREAM_NARROW_ROAD, NARROW_ROAD_CAM) ] if WIDE_CAM: CAMERAS.append(CameraType("wideRoadCameraState", VisionStreamType.VISION_STREAM_WIDE_ROAD, WIDE_CAM)) if DRIVER_CAM: - CAMERAS.append(CameraType("driverCameraState", VisionStreamType.VISION_STREAM_DRIVER, DRIVER_CAM)) + CAMERAS.append(CameraType("cabinCameraState", VisionStreamType.VISION_STREAM_CABIN, DRIVER_CAM)) class Camerad: def __init__(self): diff --git a/openpilot/system/hardware/chestnut/firmware_wrapped.bin b/openpilot/system/hardware/chestnut/firmware_wrapped.bin new file mode 100644 index 0000000000..1d738e4a75 Binary files /dev/null and b/openpilot/system/hardware/chestnut/firmware_wrapped.bin differ diff --git a/openpilot/system/hardware/chestnut/flash.py b/openpilot/system/hardware/chestnut/flash.py new file mode 100755 index 0000000000..f0d828031d --- /dev/null +++ b/openpilot/system/hardware/chestnut/flash.py @@ -0,0 +1,581 @@ +#!/usr/bin/env python3 +"""chestnut (ASM2464) SPI flasher using data-USB EP0 control transfers.""" +import argparse +import ctypes +import errno +import fcntl +import glob +import hashlib +import os +import re +import signal +import struct +import sys +import time +import zlib +from pathlib import Path + +VID_PIDS = (("add1", "0001"), ("3801", "0001")) +ROM_VID_PIDS = (("174c", "2464"), ("174c", "2463")) +ROM_PRODUCT = "USB 3.2 PCIe TinyEnclosure" +FIRMWARE_PATH = Path(__file__).with_name("firmware_wrapped.bin") +CONFIG_DIR = "/data/chestnut_config" +PM_PATHS = ("/sys/bus/platform/devices/a600000.ssusb", "/sys/bus/usb/devices/usb4") +VBUS_PATH = "/sys/kernel/debug/regulator/smb2-vbus/enable" +IMAGE_OFFSET = 0x100 +SECTOR, PAGE = 4096, 128 +MAX_CODE_SIZE = 0x10000 +FLASH_BUDGET = 600.0 +USBDEVFS_CONTROL = 0xC0185500 +USBDEVFS_BULK = 0xC0185502 +USBDEVFS_SETINTERFACE = 0x80085504 +USBDEVFS_SETCONFIGURATION = 0x80045505 +USBDEVFS_CLAIMINTERFACE = 0x8004550F +USBDEVFS_RESET = 0x5514 +USBDEVFS_CLEAR_HALT = 0x80045515 +MAX_REGISTER_READ_SIZE = 255 + +_deadline = float("inf") + + +def check_budget(): + if time.monotonic() > _deadline: + raise TimeoutError(f"flash did not converge within {FLASH_BUDGET:g}s") + + +class Ctrl(ctypes.Structure): + _fields_ = [("request_type", ctypes.c_uint8), ("request", ctypes.c_uint8), + ("value", ctypes.c_uint16), ("index", ctypes.c_uint16), + ("length", ctypes.c_uint16), ("timeout", ctypes.c_uint32), + ("data", ctypes.c_void_p)] + + +class Bulk(ctypes.Structure): + _fields_ = [("ep", ctypes.c_uint), ("len", ctypes.c_uint), + ("timeout", ctypes.c_uint), ("data", ctypes.c_void_p)] + + +class RomFallback(Exception): + pass + + +def find_chestnut(): + found = [] + for d in glob.glob("/sys/bus/usb/devices/*"): + try: + vid_pid = (open(d + "/idVendor").read().strip(), open(d + "/idProduct").read().strip()) + if vid_pid in VID_PIDS + ROM_VID_PIDS: + found.append((d, vid_pid, open(d + "/product").read().strip())) + except OSError: + pass + if len(found) > 1: + raise RuntimeError(f"expected one chestnut, found {len(found)}") + return found[0] if found else (None, None, None) + + +def in_rom_bootloader(vid_pid, product): + # the ROM bootloader reports the config page strings, or its own when the config page is lost + return vid_pid in ROM_VID_PIDS or product == ROM_PRODUCT or (product or "").startswith("AS2462") + + +def disable_runtime_pm(path): + control = os.path.join(path, "power/control") + if not os.path.exists(control): + return + with open(control, "w") as f: + f.write("on\n") + if open(control).read().strip() != "on": + raise RuntimeError(f"could not disable USB runtime PM: {control}") + delay = os.path.join(path, "power/autosuspend_delay_ms") + if os.path.exists(delay): + with open(delay, "w") as f: + f.write("-1\n") + + +def unbind_drivers(path): + for interface in glob.glob(path + ":*"): + driver = interface + "/driver" + if os.path.islink(driver): + with open(os.path.realpath(driver) + "/unbind", "w") as f: + f.write(os.path.basename(interface)) + + +def open_device(path): + bus, dev = int(open(path + "/busnum").read()), int(open(path + "/devnum").read()) + return os.open(f"/dev/bus/usb/{bus:03d}/{dev:03d}", os.O_RDWR) + + +def link_up() -> bool: + # asm enumerates on USB-C alone, gpu is only usable once pcie link is up + try: + path, _, _ = find_chestnut() + if path is None: + return False + fd = open_device(path) + except (OSError, RuntimeError): + return False + try: + fcntl.ioctl(fd, USBDEVFS_CONTROL, Ctrl(0x40, 0xF3, 1, 0, 0, 2000, None)) + buf = (ctypes.c_ubyte * 1)() + fcntl.ioctl(fd, USBDEVFS_CONTROL, Ctrl(0xC0, 0xE4, 0xB450, 0, 1, 1000, ctypes.cast(buf, ctypes.c_void_p))) + return buf[0] == 0x78 # LTSSM L0 + except OSError: + return False + finally: + os.close(fd) + + +def claim_interface(path, setup=False): + # unbind usb-storage, which binds to the ROM bootloader + disable_runtime_pm(path) + unbind_drivers(path) + fd = open_device(path) + try: + if setup: + fcntl.ioctl(fd, USBDEVFS_SETCONFIGURATION, struct.pack("I", 1)) + fcntl.ioctl(fd, USBDEVFS_CLAIMINTERFACE, struct.pack("I", 0)) + if setup: + fcntl.ioctl(fd, USBDEVFS_SETINTERFACE, struct.pack("II", 0, 0)) + except OSError as e: + os.close(fd) + if e.errno == errno.EBUSY: + raise RuntimeError("chestnut is in use, stop modeld/GPU processes before flashing") from e + raise + return fd + + +class Flash: + def __init__(self): + self.fd = -1 + self.max_register_read_size = MAX_REGISTER_READ_SIZE + + def close(self): + if self.fd >= 0: + os.close(self.fd) + self.fd = -1 + + def connect(self, timeout=5.0): + self.close() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + path, vid_pid, product = find_chestnut() + if in_rom_bootloader(vid_pid, product): + raise RomFallback("chestnut fell back to the ROM bootloader") + if path is not None: + speed = int(open(path + "/speed").read()) + # USB2 firmware truncates larger reads to one full packet without a terminating ZLP. + self.max_register_read_size = 64 if speed < 5000 else MAX_REGISTER_READ_SIZE + self.fd = claim_interface(path) + return + time.sleep(0.1) + raise RuntimeError(f"chestnut did not enumerate within {timeout:g}s") + + def reg_write(self, addr, value): + fcntl.ioctl(self.fd, USBDEVFS_CONTROL, + Ctrl(0x40, 0xE5, addr & 0xFFFF, value & 0xFFFF, 0, 2000, None)) + + def reg_read(self, addr, length=1): + buf = (ctypes.c_ubyte * length)() + fcntl.ioctl(self.fd, USBDEVFS_CONTROL, + Ctrl(0xC0, 0xE4, addr & 0xFFFF, 0, length, 2000, ctypes.cast(buf, ctypes.c_void_p))) + return bytes(buf) + + def write_buffer(self, data): + for i, value in enumerate(data): + self.reg_write(0x7000 + i, value) + + def wait_controller(self, timeout=2.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not self.reg_read(0xC8A9)[0] & 1: + return + raise TimeoutError("flash controller timeout") + + def transaction(self, command, addr=0, length=0, addr_len=0x07, mode=0): + for reg, value in ((0xC8AD, mode), (0xC8AE, 0), (0xC8AF, 0), (0xC8AA, command), (0xC8AC, addr_len), + (0xC8A1, addr), (0xC8A2, addr >> 8), (0xC8AB, addr >> 16), (0xC8A3, length >> 8), (0xC8A4, length)): + self.reg_write(reg, value & 0xFF) + self.reg_write(0xC8A9, 1) + self.wait_controller() + for _ in range(4): + self.reg_write(0xC8AD, 0) + + def write_enable(self): + for reg, value in ((0xC8AD, 0), (0xC8AA, 0x06), (0xC8AC, 0x04), (0xC8A3, 0), (0xC8A4, 0), (0xC8A9, 1)): + self.reg_write(reg, value) + self.wait_controller() + + def status(self): + self.transaction(0x05, length=1, addr_len=0x04) + return self.reg_read(0x7000)[0] + + def wait_write_done(self, timeout=10.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not self.status() & 1: + return + time.sleep(0.005) + raise TimeoutError("SPI flash WIP timeout") + + def init(self): + self.reg_write(0xCC33, 0x04) + self.reg_write(0xCA81, self.reg_read(0xCA81)[0] | 1) + self.reg_write(0xC805, 0x02) + self.reg_write(0xC8A6, 0x04) + for _ in range(5): + self.write_enable() + self.write_buffer(bytes(4)) + self.transaction(0x01, length=1, addr_len=0x04, mode=1) + time.sleep(0.01) + if not self.status() & 0x1C: + return + raise RuntimeError("could not clear SPI block protection") + + def read(self, addr, length): + out = bytearray() + while len(out) < length: + n = min(4096, length - len(out)) + self.transaction(0x03, addr + len(out), max(4096, n)) + for off in range(0, n, self.max_register_read_size): + out += self.reg_read(0x7000 + off, min(self.max_register_read_size, n - off)) + return bytes(out) + + def erase_sector(self, addr): + self.write_enable() + self.transaction(0x20, addr) + self.wait_write_done() + + def program(self, addr, data): + self.write_buffer(data + bytes((-len(data)) % 4)) + self.write_enable() + self.transaction(0x02, addr, len(data), mode=1) + self.wait_write_done() + + +def validate_image(data): + if len(data) < 10: + raise ValueError("wrapped firmware is too short") + body_len = int.from_bytes(data[:4], "little") + if body_len > MAX_CODE_SIZE: + raise ValueError(f"wrapped firmware body exceeds {MAX_CODE_SIZE} bytes") + if len(data) != body_len + 10 or data[4 + body_len] != 0xA5: + raise ValueError("invalid wrapped firmware length or magic") + body = data[4:4 + body_len] + if data[5 + body_len] != sum(body) & 0xFF: + raise ValueError("invalid wrapped firmware checksum") + if data[6 + body_len:] != zlib.crc32(body).to_bytes(4, "little"): + raise ValueError("invalid wrapped firmware CRC") + + +def image_product(image): + match = re.search(rb"custom [0-9a-f]{8}-CLEAN", image) + if match is None: + raise ValueError("no product string in wrapped firmware") + return match.group().decode() + + +def reconnect(flash): + attempt = 0 + while True: + attempt += 1 + check_budget() + try: + flash.connect() + flash.init() + return + except (OSError, TimeoutError, RuntimeError) as e: + print(f"waiting for chestnut (attempt {attempt}): {e}", flush=True) + time.sleep(1) + + +def with_retries(flash, label, operation): + # on any transfer error, reconnect and restart the operation + attempt = 0 + while True: + attempt += 1 + try: + return operation() + except (OSError, TimeoutError, RuntimeError) as e: + check_budget() + print(f"{label} attempt {attempt}: {e}", flush=True) + reconnect(flash) + + +def stable_read(flash, addr, length, count=2): + def read(): + reads = [flash.read(addr, length) for _ in range(count)] + if any(x != reads[0] for x in reads[1:]): + raise RuntimeError(f"unstable flash read at 0x{addr:05x}") + return reads[0] + return with_retries(flash, f"read 0x{addr:05x}", read) + + +def program_sector(flash, addr, target): + def program(): + flash.erase_sector(addr) + if flash.read(addr, SECTOR) != bytes([0xFF]) * SECTOR: + raise RuntimeError("sector erase verification failed") + for off in range(0, SECTOR, PAGE): + chunk = target[off:off + PAGE] + if chunk != bytes([0xFF]) * len(chunk): + flash.program(addr + off, chunk) + if flash.read(addr + off, len(chunk)) != chunk: + raise RuntimeError(f"page verify failed at 0x{addr + off:05x}") + if flash.read(addr, SECTOR) != target: + raise RuntimeError("sector verification failed") + with_retries(flash, f"sector 0x{addr:05x}", program) + + +def config_path(): + return os.path.join(CONFIG_DIR, f"{os.uname().nodename}.bin") + + +def saved_config(path, data): + os.makedirs(os.path.dirname(path), exist_ok=True) + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError as e: + backup = open(path, "rb").read() + if len(backup) != 0x100: + raise RuntimeError(f"invalid config backup: {path}") from e + if backup != data: + print(f"restoring config from {path}", flush=True) + return backup + with os.fdopen(fd, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + return data + + +def rom_write(image, config): + # the ROM bootloader implements only the BOT protocol, and requires a port reset before bulk transfers + path, _, _ = find_chestnut() + if path is None: + raise RuntimeError("chestnut disappeared before recovery") + unbind_drivers(path) + fd = open_device(path) + try: + fcntl.ioctl(fd, USBDEVFS_RESET) + finally: + os.close(fd) + time.sleep(3) + path, _, _ = find_chestnut() + if path is None: + raise RuntimeError("chestnut did not re-enumerate after reset") + fd = claim_interface(path, setup=True) + for ep in (0x02, 0x81): + fcntl.ioctl(fd, USBDEVFS_CLEAR_HALT, struct.pack("I", ep)) + tag = 0 + + def bulk(ep, payload, timeout): + buf = ctypes.create_string_buffer(bytes(payload), len(payload)) + fcntl.ioctl(fd, USBDEVFS_BULK, Bulk(ep, len(payload), timeout, ctypes.cast(buf, ctypes.c_void_p))) + return buf.raw + + def cmd(cdb, data=b"", timeout=30000): + nonlocal tag + tag += 1 + bulk(0x02, struct.pack("BBB12x", 0xE1, 0x50, 0), config[:0x80]) + cmd(struct.pack(">BBB12x", 0xE1, 0x50, 1), config[0x80:]) + cmd(struct.pack(">BBI", 0xE3, 0x50, min(len(image), 0xFF00)), image[:0xFF00]) + if len(image) > 0xFF00: + cmd(struct.pack(">BBI", 0xE3, 0xD0, len(image) - 0xFF00), image[0xFF00:]) + cmd(struct.pack(">BB13x", 0xE8, 0x51)) + finally: + os.close(fd) + print("recovery flash done", flush=True) + + +def vbus_write(value): + try: + with open(VBUS_PATH, "w") as f: + f.write(value + "\n") + except OSError: + pass + + +def vbus_cycle(): + if os.path.exists(VBUS_PATH): + vbus_write("0") + time.sleep(2) + vbus_write("1") + time.sleep(5) + + +def activate(expected_product): + if not os.path.exists(VBUS_PATH): + print("no VBUS control, firmware activates on the next chestnut power cycle", flush=True) + return + print("power-cycling chestnut VBUS", flush=True) + vbus_write("0") + disconnected = False + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + path, _, _ = find_chestnut() + if path is None: + disconnected = True + break + time.sleep(0.2) + time.sleep(1) + vbus_write("1") + if not disconnected: + print("chestnut stayed powered, firmware activates on its next power cycle", flush=True) + return + deadline = time.monotonic() + 15.0 + while time.monotonic() < deadline: + _, _, product = find_chestnut() + if product is not None: + if product == expected_product: + print(f"activated {expected_product}", flush=True) + else: + print(f"chestnut re-enumerated with {product!r}, firmware activates on its next power cycle", flush=True) + return + time.sleep(0.2) + print("chestnut did not re-enumerate, firmware activates on its next power cycle", flush=True) + + +def defer_signal(signum, _frame): + # writing from a handler must not reenter a print already in progress + os.write(1, f"signal {signum} deferred until the chestnut is powered back up\n".encode()) + + +def flash_chestnut(expected_version=None, force=False): + global _deadline + + image = FIRMWARE_PATH.read_bytes() + validate_image(image) + expected_product = image_product(image) + if expected_version is not None and expected_product != f"custom {expected_version}-CLEAN": + raise RuntimeError(f"bundled firmware is {expected_product!r}, expected version {expected_version}") + + path, vid_pid, product = find_chestnut() + if path is None: + print("no chestnut connected", flush=True) + return + if product == expected_product and not force: + print(f"chestnut firmware is up to date ({expected_product})", flush=True) + return + + _deadline = time.monotonic() + FLASH_BUDGET + for pm_path in PM_PATHS: + disable_runtime_pm(pm_path) + + previous = {sig: signal.signal(sig, defer_signal) for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)} + try: + if in_rom_bootloader(vid_pid, product): + if not recover_from_rom(image, expected_product): + return + # firmware is back, verify it against the bundled image + force, product = True, None + write_image(image, expected_product, product, force) + finally: + for sig, handler in previous.items(): + signal.signal(sig, handler) + + +def recover_from_rom(image, expected_product): + # returns whether the chestnut came back on custom firmware + backup = config_path() + if not os.path.isfile(backup): + raise RuntimeError(f"cannot recover from the ROM bootloader without a config backup at {backup}") + config = open(backup, "rb").read() + if len(config) != 0x100: + raise RuntimeError(f"invalid config backup: {backup}") + + committed = False + while True: + check_budget() + path, vid_pid, product = find_chestnut() + if path is None: + if committed: + print("chestnut is offline, recovered firmware boots on its next power cycle", flush=True) + return False + vbus_cycle() + continue + if not in_rom_bootloader(vid_pid, product): + return True + if committed: + print("chestnut stayed powered, recovered firmware boots on its next power cycle", flush=True) + return False + try: + rom_write(image, config) + committed = True + except (OSError, TimeoutError, RuntimeError) as e: + print(f"ROM recovery failed, retrying: {e}", flush=True) + vbus_cycle() + continue + activate(expected_product) + + +def write_image(image, expected_product, product, force): + if force: + print(f"forced reflash of {expected_product}", flush=True) + else: + print(f"chestnut firmware mismatch: {product!r}; expected {expected_product!r}", flush=True) + + flash = Flash() + try: + reconnect(flash) + config = stable_read(flash, 0, 0x100, 3) + config = saved_config(config_path(), config) + image_end = IMAGE_OFFSET + len(image) + first_sector = IMAGE_OFFSET & ~(SECTOR - 1) + span = (image_end + SECTOR - 1) & ~(SECTOR - 1) + current = stable_read(flash, first_sector, span - first_sector) + target = bytearray(current) + target[:len(config)] = config + target[IMAGE_OFFSET - first_sector:image_end - first_sector] = image + target = bytes(target) + print(f"target {len(image)} bytes at 0x{IMAGE_OFFSET:05x}, sha256={hashlib.sha256(image).hexdigest()}", flush=True) + + for addr in range(first_sector, span, SECTOR): + off = addr - first_sector + wanted = target[off:off + SECTOR] + if current[off:off + SECTOR] == wanted: + print(f"sector 0x{addr:05x}: unchanged", flush=True) + else: + print(f"sector 0x{addr:05x}: programming", flush=True) + program_sector(flash, addr, wanted) + + verified = stable_read(flash, first_sector, span - first_sector, 3) + if verified != target: + raise RuntimeError("final full-image verification failed") + print(f"verified sha256={hashlib.sha256(verified).hexdigest()}", flush=True) + finally: + flash.close() + + activate(expected_product) + + +def main(): + parser = argparse.ArgumentParser(description="check and flash the bundled chestnut firmware") + parser.add_argument("version", nargs="?", help="expected firmware version hash") + parser.add_argument("--force", action="store_true", help="reflash even when the version matches") + args = parser.parse_args() + if os.geteuid() != 0: + raise RuntimeError("flash.py must run as root") + flash_chestnut(expected_version=args.version, force=args.force) + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"FAIL: {type(e).__name__}: {e}", file=sys.stderr) + sys.exit(1) diff --git a/openpilot/system/hardware/comma/agnos.json b/openpilot/system/hardware/comma/agnos.json new file mode 120000 index 0000000000..b1465ff49d --- /dev/null +++ b/openpilot/system/hardware/comma/agnos.json @@ -0,0 +1 @@ +../../../common/hardware/comma/agnos.json \ No newline at end of file diff --git a/openpilot/system/hardware/hardwared.py b/openpilot/system/hardware/hardwared.py index e5b3d8020c..dd9c92269b 100755 --- a/openpilot/system/hardware/hardwared.py +++ b/openpilot/system/hardware/hardwared.py @@ -3,6 +3,8 @@ import fcntl import os import queue import struct +import subprocess +import sys import threading import time from collections import OrderedDict, namedtuple @@ -14,16 +16,18 @@ from openpilot.common.utils import strip_deprecated_keys from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params from openpilot.common.realtime import DT_HW +from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_compiled from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert -from openpilot.common.hardware import HARDWARE, TICI -from openpilot.common.hardware.usb import get_usb_state, set_usb_state +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.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 +from openpilot.common.version import terms_version, training_version, get_build_metadata, terms_version_sp, CHESTNUT_BRANCHES ThermalStatus = log.DeviceState.ThermalStatus @@ -35,6 +39,44 @@ DISCONNECT_TIMEOUT = 5. # wait 5 seconds before going offroad after disconnect PANDA_STATES_TIMEOUT = round(1000 / SERVICE_LIST['pandaStates'].frequency * 1.5) # 1.5x the expected pandaState frequency ONROAD_CYCLE_TIME = 1 # seconds to wait offroad after requesting an onroad cycle +class Chestnut: + # flash offroad, modeld ignores chestnut until the product string matches + MAX_ATTEMPTS = 3 + RETRY_INTERVAL = 20. + + def __init__(self): + self.thread: threading.Thread | None = None + self.attempts = 0 + self.last_attempt = 0. + self.flashed = False + + def flash(self) -> None: + ret = subprocess.run(["sudo", sys.executable, os.path.join(BASEDIR, "openpilot/system/hardware/chestnut/flash.py"), CHESTNUT_FW_VERSION], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=False) + cloudlog.event("chestnut flash done", returncode=ret.returncode, output=ret.stdout[-1000:], error=ret.returncode != 0) + 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.flashed = False + return + + if not offroad or self.flashed or self.attempts >= self.MAX_ATTEMPTS: + return + if self.thread is not None and self.thread.is_alive(): + return + if time.monotonic() - self.last_attempt < self.RETRY_INTERVAL: + return + + self.attempts += 1 + self.last_attempt = time.monotonic() + cloudlog.warning(f"chestnut firmware out of date, flashing (attempt {self.attempts})") + self.thread = threading.Thread(target=self.flash, daemon=True) + self.thread.start() + + ThermalBand = namedtuple("ThermalBand", ['min_temp', 'max_temp']) HardwareState = namedtuple("HardwareState", ['network_type', 'network_info', 'network_strength', 'network_stats', 'network_metered', 'modem_temps', 'usb_state']) @@ -106,10 +148,15 @@ def hw_state_thread(end_event, hw_queue): """Handles non critical hardware state, and sends over queue""" count = 0 prev_hw_state = None + prev_usb_topology = set() while not end_event.is_set(): - # these are expensive calls. update every 10s - if (count % int(10. / DT_HW)) == 0: + usb_topology = get_usb_topology() + usb_changed = usb_topology != prev_usb_topology + + # these are expensive calls. update every 10s or when USB devices change + if (count % int(10. / DT_HW)) == 0 or usb_changed: + prev_usb_topology = usb_topology try: network_type = HARDWARE.get_network_type() modem_temps = HARDWARE.get_modem_temperatures() @@ -191,6 +238,8 @@ def hardware_thread(end_event, hw_queue) -> None: thermal_config = HARDWARE.get_thermal_config() fan_controller = FanController(int(1./DT_HW)) + chestnut = Chestnut() + big_model_available = (MODELS_DIR / 'big_driving_supercombo.onnx').is_file() or chestnut_compiled() while not end_event.is_set(): sm.update(PANDA_STATES_TIMEOUT) @@ -251,6 +300,12 @@ def hardware_thread(end_event, hw_queue) -> None: msg.deviceState.screenBrightnessPercent = HARDWARE.get_screen_brightness() 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) # this subset is only used for offroad temp_sources = [ @@ -309,7 +364,7 @@ def hardware_thread(end_event, hw_queue) -> None: # - TIZI, or # - TICI and channel_type is "tici" build_metadata = get_build_metadata() - is_unsupported_combo = TICI and HARDWARE.get_device_type() == "tici" and build_metadata.channel_type != "tici" + is_unsupported_combo = COMMA_HARDWARE and HARDWARE.get_device_type() == "tici" and build_metadata.channel_type != "tici" startup_conditions["not_tici"] = not is_unsupported_combo onroad_conditions["not_tici"] = not is_unsupported_combo set_offroad_alert("Offroad_TiciSupport", is_unsupported_combo, extra_text=build_metadata.channel) @@ -465,7 +520,7 @@ def main(): threading.Thread(target=hardware_thread, args=(end_event, hw_queue)), ] - if TICI: + if COMMA_HARDWARE: threads.append(threading.Thread(target=touch_thread, args=(end_event,))) for t in threads: diff --git a/openpilot/system/hardware/power_monitoring.py b/openpilot/system/hardware/power_monitoring.py index ca3390b8f0..8dcd0fa6dc 100644 --- a/openpilot/system/hardware/power_monitoring.py +++ b/openpilot/system/hardware/power_monitoring.py @@ -35,7 +35,7 @@ class PowerMonitoring: self.car_battery_capacity_uWh = max((CAR_BATTERY_CAPACITY_uWh / 10), car_battery_capacity_uWh) # Calculation tick - def calculate(self, voltage: int | None, ignition: bool): + def calculate(self, voltage: float | None, ignition: bool): try: now = time.monotonic() @@ -106,18 +106,10 @@ class PowerMonitoring: # Max Time Offroad def max_time_offroad_exceeded(self, offroad_time): - """ - Check if the max time offroad has been exceeded. If the value is 0, it means no limit. - :param offroad_time: Time spent offroad in seconds - :return: True if the max time offroad has been exceeded, False otherwise - """ - try: - param = self.params.get("MaxTimeOffroad") - sp_max_time_val_s = param * 60 if param is not None and param >= 0 else MAX_TIME_OFFROAD_S - except Exception: - sp_max_time_val_s = MAX_TIME_OFFROAD_S - - return 0 < sp_max_time_val_s <= offroad_time + param = self.params.get("MaxTimeOffroad") # minutes, 0 = no limit + if param is not None and param >= 0: + return 0 < param * 60 <= offroad_time + return offroad_time > MAX_TIME_OFFROAD_S # See if we need to shutdown def should_shutdown(self, ignition: bool, in_car: bool, offroad_timestamp: float | None, started_seen: bool): diff --git a/openpilot/system/hardware/tests/test_fan_controller.py b/openpilot/system/hardware/tests/test_fan_controller.py index e1aceeb081..63f9967101 100644 --- a/openpilot/system/hardware/tests/test_fan_controller.py +++ b/openpilot/system/hardware/tests/test_fan_controller.py @@ -1,10 +1,11 @@ -import pytest +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.system.hardware.fan_controller import FanController ALL_CONTROLLERS = [FanController] -class TestFanController: +class TestFanController(OpenpilotTestCase): def wind_up(self, controller, ignition=True): for _ in range(1000): controller.update(100, ignition) @@ -13,31 +14,31 @@ class TestFanController: for _ in range(1000): controller.update(10, ignition) - @pytest.mark.parametrize("controller_class", ALL_CONTROLLERS) + @parameterized.expand(ALL_CONTROLLERS) def test_hot_onroad(self, controller_class): controller = controller_class(2) self.wind_up(controller) assert controller.update(100, True) >= 70 - @pytest.mark.parametrize("controller_class", ALL_CONTROLLERS) + @parameterized.expand(ALL_CONTROLLERS) def test_offroad_limits(self, controller_class): controller = controller_class(2) self.wind_up(controller) assert controller.update(100, False) <= 30 - @pytest.mark.parametrize("controller_class", ALL_CONTROLLERS) + @parameterized.expand(ALL_CONTROLLERS) def test_no_fan_wear(self, controller_class): controller = controller_class(2) self.wind_down(controller) assert controller.update(10, False) == 0 - @pytest.mark.parametrize("controller_class", ALL_CONTROLLERS) + @parameterized.expand(ALL_CONTROLLERS) def test_limited(self, controller_class): controller = controller_class(2) self.wind_up(controller, True) assert controller.update(100, True) == 100 - @pytest.mark.parametrize("controller_class", ALL_CONTROLLERS) + @parameterized.expand(ALL_CONTROLLERS) def test_windup_speed(self, controller_class): controller = controller_class(2) self.wind_down(controller, True) diff --git a/openpilot/system/hardware/tests/test_power_monitoring.py b/openpilot/system/hardware/tests/test_power_monitoring.py index f0804c2071..6b254bbf57 100644 --- a/openpilot/system/hardware/tests/test_power_monitoring.py +++ b/openpilot/system/hardware/tests/test_power_monitoring.py @@ -1,5 +1,5 @@ -import pytest - +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.common.params import Params from openpilot.system.hardware.power_monitoring import PowerMonitoring, CAR_BATTERY_CAPACITY_uWh, \ CAR_CHARGING_RATE_W, VBATT_PAUSE_CHARGING, DELAY_SHUTDOWN_TIME_S, MAX_TIME_OFFROAD_S @@ -11,6 +11,10 @@ def mock_time_monotonic(): ssb += 1. return ssb +def set_mock_time(value): + global ssb + ssb = value + TEST_DURATION_S = 50 GOOD_VOLTAGE = 12 * 1e3 VOLTAGE_BELOW_PAUSE_CHARGING = (VBATT_PAUSE_CHARGING - 1) * 1e3 @@ -22,20 +26,16 @@ def pm_patch(mocker, name, value, constant=False): mocker.patch(f"openpilot.system.hardware.power_monitoring.{name}", return_value=value) -@pytest.fixture(autouse=True) -def mock_time(mocker): - mocker.patch("time.monotonic", mock_time_monotonic) - - -class TestPowerMonitoring: +class TestPowerMonitoring(OpenpilotTestCase): def setup_method(self): + self._fixture("mocker").patch("time.monotonic", mock_time_monotonic) self.params = Params() # Test to see that it doesn't do anything when pandaState is None def test_panda_state_present(self): pm = PowerMonitoring() for _ in range(10): - pm.calculate(None, None) + pm.calculate(None, False) assert pm.get_power_used() == 0 assert pm.get_car_battery_capacity() == (CAR_BATTERY_CAPACITY_uWh / 10) @@ -110,10 +110,9 @@ class TestPowerMonitoring: pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh start_time = ssb ignition = False - while ssb <= start_time + MOCKED_MAX_OFFROAD_TIME: - pm.calculate(GOOD_VOLTAGE, ignition) - if (ssb - start_time) % 1000 == 0 and ssb < start_time + MOCKED_MAX_OFFROAD_TIME: - assert not pm.should_shutdown(ignition, True, start_time, False) + set_mock_time(start_time + MOCKED_MAX_OFFROAD_TIME - 1) + assert not pm.should_shutdown(ignition, True, start_time, False) + set_mock_time(start_time + MOCKED_MAX_OFFROAD_TIME) assert pm.should_shutdown(ignition, True, start_time, False) def test_car_voltage(self, mocker): @@ -188,18 +187,16 @@ class TestPowerMonitoring: started_seen = True pm.calculate(VOLTAGE_BELOW_PAUSE_CHARGING, ignition) - while ssb < offroad_timestamp + DELAY_SHUTDOWN_TIME_S: - assert not pm.should_shutdown(ignition, in_car, - offroad_timestamp, - started_seen), \ - f"Should not shutdown before {DELAY_SHUTDOWN_TIME_S} seconds offroad time" + set_mock_time(offroad_timestamp + DELAY_SHUTDOWN_TIME_S - 1) + assert not pm.should_shutdown(ignition, in_car, offroad_timestamp, started_seen), \ + f"Should not shutdown before {DELAY_SHUTDOWN_TIME_S} seconds offroad time" + set_mock_time(offroad_timestamp + DELAY_SHUTDOWN_TIME_S) assert pm.should_shutdown(ignition, in_car, offroad_timestamp, started_seen), \ f"Should shutdown after {DELAY_SHUTDOWN_TIME_S} seconds offroad time" - @pytest.mark.parametrize( - "max_time_offroad, offroad_time_min, expected_result", + @parameterized.expand( [ # No max time set – fallback to default (30 hours) (None, 0, False), diff --git a/openpilot/system/hardware/tici/agnos.json b/openpilot/system/hardware/tici/agnos.json index ffee992f25..87cb197052 120000 --- a/openpilot/system/hardware/tici/agnos.json +++ b/openpilot/system/hardware/tici/agnos.json @@ -1 +1 @@ -../../../common/hardware/tici/agnos.json \ No newline at end of file +../comma/agnos.json \ No newline at end of file diff --git a/openpilot/system/loggerd/SConscript b/openpilot/system/loggerd/SConscript index 7f6d4faf0c..6890c29655 100644 --- a/openpilot/system/loggerd/SConscript +++ b/openpilot/system/loggerd/SConscript @@ -4,8 +4,8 @@ libs = [common, messaging, visionipc] + ffmpeg_libs + ['pthread', 'm', 'zstd'] frameworks = [] src = ['logger.cc', 'zstd_writer.cc', 'video_writer.cc', 'encoder/encoder.cc', 'encoder/jpeg_encoder.cc'] -if arch == "larch64": - src += ['encoder/v4l_encoder.cc'] +if arch == "comma_arm64": + src += ['clip_encoder.cc', 'encoder/v4l_encoder.cc', 'encoder/v4l_decoder.cc'] else: src += ['encoder/ffmpeg_encoder.cc'] if arch == "Darwin": @@ -17,6 +17,3 @@ libs.insert(0, logger_lib) env.Program('loggerd', ['loggerd.cc'], LIBS=libs, FRAMEWORKS=frameworks) env.Program('encoderd', ['encoderd.cc'], LIBS=libs, FRAMEWORKS=frameworks) env.Program('bootlog.cc', LIBS=libs, FRAMEWORKS=frameworks) - -if GetOption('extras'): - env.Program('tests/test_logger', ['tests/test_runner.cc', 'tests/test_logger.cc', 'tests/test_zstd_writer.cc'], LIBS=libs) diff --git a/openpilot/system/loggerd/clip_encoder.cc b/openpilot/system/loggerd/clip_encoder.cc new file mode 100644 index 0000000000..7e8f3a9026 --- /dev/null +++ b/openpilot/system/loggerd/clip_encoder.cc @@ -0,0 +1,290 @@ +#include "system/loggerd/clip_encoder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} + +#include "common/swaglog.h" +#include "system/loggerd/encoder/v4l_decoder.h" +#include "system/loggerd/encoder/v4l_encoder.h" +#include "system/loggerd/loggerd.h" +#include "system/loggerd/video_writer.h" + +namespace { + +constexpr double SEGMENT_DURATION = 60.0; +constexpr int CLIP_FPS = 20; +constexpr double PARALLEL_CLIP_MIN_DURATION = 2 * SEGMENT_DURATION; + +const EncoderInfo clip_encoder_info = { + .publish_name = "livestreamNarrowRoadEncodeData", + .record = false, + .fps = CLIP_FPS, + .get_settings = [](int) { return EncoderSettings::StreamEncoderSettings(); }, + INIT_ENCODE_FUNCTIONS(LivestreamNarrowRoadEncode), +}; + +bool open_input(const std::string &path, AVFormatContext **ctx, int *stream_index) { + if (avformat_open_input(ctx, path.c_str(), nullptr, nullptr) < 0 || + avformat_find_stream_info(*ctx, nullptr) < 0 || + (*stream_index = av_find_best_stream(*ctx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0)) < 0) { + LOGE("failed to open clip input %s", path.c_str()); + avformat_close_input(ctx); + return false; + } + return true; +} + +void remove_file(const std::string &path) { + std::error_code error; + std::filesystem::remove(path, error); +} + +int encode_clip_worker(const std::vector &inputs, int width, int height, + double start_time, double duration, int bitrate, int speedup, + int64_t frame_offset, int64_t *encoded_frames, + V4LEncoder::PacketCallback packet_callback) try { + EncoderInfo encoder_info = clip_encoder_info; + encoder_info.get_settings = [bitrate](int) { + return EncoderSettings{.encode_type = cereal::EncodeIndex::Type::QCAMERA_H264, + .bitrate = bitrate, .gop_size = 5}; + }; + V4LDecoder decoder; + V4LEncoder::Options options = { + .packet_callback = std::move(packet_callback), + .input_format = V4L2_PIX_FMT_NV12_UBWC, + .input_done_callback = [&decoder](VisionBuf *buf) { decoder.releaseFrame(buf); }, + .max_performance = true, + }; + V4LEncoder encoder(encoder_info, width, height, std::move(options)); + encoder.encoder_open(); + if (!decoder.init(V4LDecoder::DEVICE, width, height, V4L2_PIX_FMT_HEVC, true, V4L2_PIX_FMT_NV12_UBWC)) return 1; + + const int64_t first_frame = std::floor(start_time * CLIP_FPS); + const int64_t end_frame = std::ceil((start_time + duration) * CLIP_FPS); + int64_t input_frame = 0; + int64_t output_frame = 0; + int64_t received_frames = 0; + bool failed = false; + auto pump_decoder = [&](int timeout_ms) { + V4LDecodedFrame frame; + if (!decoder.pump(frame, timeout_ms)) return false; + if (!frame.buf) return true; + ++received_frames; + const int64_t source_frame = (int64_t)frame.token - 1; + if (source_frame < first_frame) { + decoder.releaseFrame(frame.buf); + return true; + } + if ((frame_offset + source_frame - first_frame) % speedup != 0) { + decoder.releaseFrame(frame.buf); + return true; + } + + VisionIpcBufExtra extra = {}; + extra.frame_id = output_frame; + extra.timestamp_sof = output_frame * 1000000000ULL / CLIP_FPS; + extra.timestamp_eof = extra.timestamp_sof; + if (encoder.encode_frame(frame.buf, &extra) < 0) { + decoder.releaseFrame(frame.buf); + return false; + } + + ++output_frame; + return true; + }; + + for (size_t input_index = 0; input_index < inputs.size(); ++input_index) { + const std::string &input = inputs[input_index]; + const int64_t segment_start_frame = input_frame; + AVFormatContext *ctx = nullptr; + int stream_index = -1; + if (!open_input(input, &ctx, &stream_index)) { failed = true; break; } + AVPacket packet = {}; + while (input_frame < end_frame && av_read_frame(ctx, &packet) >= 0) { + if (packet.stream_index != stream_index) { + av_packet_unref(&packet); + continue; + } + if (packet.size <= 0 || (size_t)packet.size > decoder.maxPacketSize()) { + LOGE("decoder packet too large: %d > %zu", packet.size, decoder.maxPacketSize()); + av_packet_unref(&packet); + failed = true; + break; + } + + // Keep several compressed packets in flight so the firmware can sustain + // decode/encode overlap and does not downclock due to a shallow queue. + while (!decoder.queuePacket(&packet, input_frame + 1)) { + if (!pump_decoder(-1)) { + failed = true; + break; + } + } + av_packet_unref(&packet); + if (failed) break; + + ++input_frame; + } + av_packet_unref(&packet); + avformat_close_input(&ctx); + // Only the final loggerd segment may be shorter than SEGMENT_DURATION. A + // short intermediate segment would silently close a gap in the source. + if (!failed && input_frame < end_frame && input_index + 1 < inputs.size() && + input_frame - segment_start_frame < static_cast(SEGMENT_DURATION * CLIP_FPS)) { + failed = true; + } + if (failed || input_frame >= end_frame) break; + } + + if (!failed) decoder.sendEOS(); + for (int empty_polls = 0; !failed && received_frames < input_frame;) { + const int64_t before = received_frames; + failed = !pump_decoder(1000); + empty_polls = received_frames == before ? empty_polls + 1 : 0; + if (empty_polls == 5) failed = true; + } + + encoder.encoder_close(); + const int64_t source_frames = std::max(0, std::min(input_frame, end_frame) - first_frame); + const int64_t first_output_frame = (speedup - frame_offset % speedup) % speedup; + const int64_t expected_output_frames = first_output_frame < source_frames ? + 1 + (source_frames - first_output_frame - 1) / speedup : 0; + if (failed || source_frames == 0 || output_frame != expected_output_frames) { + LOGE("clip failed: input=%lld/%lld decoded=%lld encoded=%lld/%lld", + (long long)input_frame, (long long)end_frame, (long long)received_frames, + (long long)output_frame, (long long)expected_output_frames); + return 1; + } + *encoded_frames = output_frame; + return 0; +} catch (const std::exception &e) { + LOGE("clip worker failed: %s", e.what()); + return 1; +} + +struct SpoolPacket { + uint32_t size; + int64_t timestamp; + bool keyframe; +}; + +} // namespace + +int encode_clip(const std::vector &inputs, const std::string &output, + double start_time, double duration, int bitrate, int speedup, + const std::string &metadata) { + if (inputs.empty() || !std::isfinite(start_time) || !std::isfinite(duration) || + start_time < 0 || duration <= 0 || bitrate <= 0 || speedup <= 0) { + return 1; + } + + // Inputs are consecutive loggerd segments. Skip whole files before the clip + // so a late start does not spend hardware time decoding discarded minutes. + const double available_duration = inputs.size() * SEGMENT_DURATION; + if (start_time >= available_duration || duration > available_duration - start_time) return 1; + const size_t skipped_segments = start_time / SEGMENT_DURATION; + const std::vector clip_inputs(inputs.begin() + skipped_segments, inputs.end()); + const double local_start = start_time - skipped_segments * SEGMENT_DURATION; + + AVFormatContext *ctx = nullptr; + int stream = -1; + if (!open_input(clip_inputs.front(), &ctx, &stream)) return 1; + AVCodecParameters *codec = ctx->streams[stream]->codecpar; + const int width = codec->width, height = codec->height; + const bool valid_codec = codec->codec_id == AV_CODEC_ID_HEVC && width > 0 && height > 0; + avformat_close_input(&ctx); + if (!valid_codec) return 1; + + std::filesystem::path output_path(output); + const std::string output_dir = output_path.has_parent_path() ? output_path.parent_path() : "."; + auto writer = std::make_unique(output_dir.c_str(), output_path.filename().c_str(), true, + width, height, CLIP_FPS, cereal::EncodeIndex::Type::QCAMERA_H264); + if (!metadata.empty()) writer->set_metadata("ai.comma.clip.settings", metadata.c_str()); + V4LEncoder::PacketCallback write_packet = [&writer](uint8_t *data, size_t size, int64_t timestamp, + bool config, bool keyframe) { + writer->write(data, size, timestamp, config, keyframe); + }; + + if (clip_inputs.size() < 2 || duration < PARALLEL_CLIP_MIN_DURATION) { + int64_t encoded_frames = 0; + const bool success = encode_clip_worker(clip_inputs, width, height, local_start, duration, + bitrate, speedup, 0, &encoded_frames, write_packet) == 0; + if (!success) { + writer.reset(); + remove_file(output); + } + return success ? 0 : 1; + } + + const size_t split = std::clamp(std::llround((local_start + duration / 2) / SEGMENT_DURATION), + 1, clip_inputs.size() - 1); + const double split_time = split * SEGMENT_DURATION; + const std::array, 2> shard_inputs = { + std::vector(clip_inputs.begin(), clip_inputs.begin() + split), + std::vector(clip_inputs.begin() + split, clip_inputs.end()), + }; + const std::array shard_starts = {local_start, 0}; + const std::array shard_durations = { + split_time - local_start, local_start + duration - split_time, + }; + const std::string spool_path = output + ".encoderd-" + std::to_string(getpid()) + ".tmp"; + FILE *spool = fopen(spool_path.c_str(), "w+b"); + if (!spool) { + writer.reset(); + remove_file(output); + return 1; + } + remove_file(spool_path); + bool spool_ok = true; + V4LEncoder::PacketCallback spool_packet = [&](uint8_t *data, size_t size, int64_t timestamp, + bool config, bool keyframe) { + if (config) return; + const SpoolPacket packet = {(uint32_t)size, timestamp, keyframe}; + spool_ok &= fwrite(&packet, sizeof(packet), 1, spool) == 1 && fwrite(data, 1, size, spool) == size; + }; + std::array results = {1, 1}; + std::array encoded_frames = {}; + const std::array frame_offsets = { + 0, (int64_t)std::llround(split_time * CLIP_FPS) - (int64_t)std::floor(local_start * CLIP_FPS), + }; + std::array workers; + + for (size_t i = 0; i < workers.size(); ++i) { + workers[i] = std::thread([&, i]() { + results[i] = encode_clip_worker(shard_inputs[i], width, height, shard_starts[i], shard_durations[i], + bitrate, speedup, frame_offsets[i], &encoded_frames[i], + i == 0 ? write_packet : spool_packet); + }); + } + for (std::thread &worker : workers) worker.join(); + + rewind(spool); + SpoolPacket packet; + std::vector data; + const int64_t timestamp_offset = encoded_frames[0] * 1000000 / CLIP_FPS; + while (spool_ok && fread(&packet, sizeof(packet), 1, spool) == 1) { + data.resize(packet.size); + spool_ok = fread(data.data(), 1, data.size(), spool) == data.size(); + if (spool_ok) writer->write(data.data(), data.size(), packet.timestamp + timestamp_offset, false, packet.keyframe); + } + fclose(spool); + bool success = results[0] == 0 && results[1] == 0 && spool_ok; + if (!success) { + writer.reset(); + remove_file(output); + } + return success ? 0 : 1; +} diff --git a/openpilot/system/loggerd/clip_encoder.h b/openpilot/system/loggerd/clip_encoder.h new file mode 100644 index 0000000000..5bd01b0c6c --- /dev/null +++ b/openpilot/system/loggerd/clip_encoder.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +// inputs are consecutive 60-second loggerd HEVC segments; start_time is +// relative to the beginning of the first input. +int encode_clip(const std::vector &inputs, const std::string &output, + double start_time, double duration, int bitrate = 5'000'000, + int speedup = 1, const std::string &metadata = {}); diff --git a/openpilot/system/loggerd/deleter.py b/openpilot/system/loggerd/deleter.py index d5c12474f8..51bac4ea23 100755 --- a/openpilot/system/loggerd/deleter.py +++ b/openpilot/system/loggerd/deleter.py @@ -47,6 +47,32 @@ def get_preserved_segments(dirs_by_creation: list[str]) -> set[str]: return preserved +def deleter_step() -> tuple[bool, str | None]: + out_of_bytes = get_available_bytes(default=MIN_BYTES + 1) < MIN_BYTES + out_of_percent = get_available_percent(default=MIN_PERCENT + 1) < MIN_PERCENT + out_of_space = out_of_percent or out_of_bytes + if not out_of_space: + return False, None + + dirs = listdir_by_creation(Paths.log_root()) + preserved_dirs = get_preserved_segments(dirs) + + # remove the earliest directory we can + for delete_dir in sorted(dirs, key=lambda d: (d in DELETE_LAST, d in preserved_dirs)): + delete_path = os.path.join(Paths.log_root(), delete_dir) + + if any(name.endswith(".lock") for name in os.listdir(delete_path)): + continue + + try: + cloudlog.info(f"deleting {delete_path}") + shutil.rmtree(delete_path) + return True, delete_path + except OSError: + cloudlog.exception(f"issue deleting {delete_path}") + return True, None + + def deleter_thread(exit_event: threading.Event): while not exit_event.is_set(): out_of_bytes = get_available_bytes(default=MIN_BYTES + 1) < MIN_BYTES diff --git a/openpilot/system/loggerd/encoder/jpeg_encoder.cc b/openpilot/system/loggerd/encoder/jpeg_encoder.cc index 79f5e1b80f..6258e8144d 100644 --- a/openpilot/system/loggerd/encoder/jpeg_encoder.cc +++ b/openpilot/system/loggerd/encoder/jpeg_encoder.cc @@ -93,7 +93,7 @@ void JpegEncoder::compressToJpeg(uint8_t *y_plane, uint8_t *u_plane, uint8_t *v_ frame->data[2] = v_plane; // Required for MJPEG qscale to take effect (global_quality alone is not enough). frame->quality = FF_QP2LAMBDA * MJPEG_QSCALE; - frame->pts = 0; + frame->pts = AV_NOPTS_VALUE; int err = avcodec_send_frame(codec_ctx, frame); if (err < 0) { diff --git a/openpilot/tools/replay/qcom_decoder.cc b/openpilot/system/loggerd/encoder/v4l_decoder.cc similarity index 58% rename from openpilot/tools/replay/qcom_decoder.cc rename to openpilot/system/loggerd/encoder/v4l_decoder.cc index b7f04a063b..6db41adb82 100644 --- a/openpilot/tools/replay/qcom_decoder.cc +++ b/openpilot/system/loggerd/encoder/v4l_decoder.cc @@ -1,13 +1,19 @@ -#include "qcom_decoder.h" +#include "system/loggerd/encoder/v4l_decoder.h" #include +#include +#include #include #include +#include +#include #include "common/swaglog.h" #include "common/util.h" +constexpr int OFFLINE_CORE_PLACEMENT_RATE = 80 << 16; + // echo "0xFFFF" > /sys/kernel/debug/msm_vidc/debug_level static void copyBuffer(VisionBuf *src_buf, VisionBuf *dst_buf) { @@ -26,108 +32,131 @@ static void request_buffers(int fd, v4l2_buf_type buf_type, unsigned int count) util::safe_ioctl(fd, VIDIOC_REQBUFS, &reqbuf, "VIDIOC_REQBUFS failed"); } -MsmVidc::~MsmVidc() { +V4LDecoder::~V4LDecoder() { if (fd > 0) { close(fd); } } -bool MsmVidc::init(const char* dev, size_t width, size_t height, uint64_t codec) { +bool V4LDecoder::init(const char* dev, size_t width, size_t height, uint64_t codec, + bool direct_mode, uint32_t capture_fourcc) { LOG("Initializing msm_vidc device %s", dev); this->w = width; this->h = height; - this->fd = open(dev, O_RDWR, 0); + this->direct = direct_mode; + this->capture_format = capture_fourcc; + this->fd = open(dev, O_RDWR | O_NONBLOCK, 0); if (fd < 0) { LOGE("failed to open video device %s", dev); return false; } subscribeEvents(); v4l2_buf_type out_type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE; - setPlaneFormat(out_type, V4L2_PIX_FMT_HEVC); // Also allocates the output buffer + setPlaneFormat(out_type, codec); // Also allocates the output buffers setFPS(FPS); + if (direct) { + struct v4l2_control ctrls[] = { + // A finite real-time load lets the driver place decode and encode on separate cores. + { .id = V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE, .value = OFFLINE_CORE_PLACEMENT_RATE }, + { .id = V4L2_CID_MPEG_VIDC_VIDEO_PRIORITY, .value = V4L2_MPEG_VIDC_VIDEO_PRIORITY_REALTIME_ENABLE }, + }; + for (auto ctrl : ctrls) { + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL offline decode failed"); + } + } request_buffers(fd, out_type, OUTPUT_BUFFER_COUNT); util::safe_ioctl(fd, VIDIOC_STREAMON, &out_type, "VIDIOC_STREAMON OUTPUT failed"); restartCapture(); - setupPolling(); + pfd = {fd, POLLIN | POLLOUT | POLLWRNORM | POLLRDNORM | POLLPRI, 0}; this->initialized = true; return true; } -VisionBuf* MsmVidc::decodeFrame(AVPacket *pkt, VisionBuf *buf) { - assert(initialized && (pkt != nullptr) && (buf != nullptr)); +VisionBuf* V4LDecoder::decodeFrame(AVPacket *pkt, VisionBuf *buf) { + assert(initialized && !direct && pkt != nullptr && buf != nullptr); + bool queued = false; + while (true) { + if (!queued) queued = queuePacket(pkt, 0); + V4LDecodedFrame frame; + if (!pump(frame, -1)) return nullptr; + if (!frame.buf) continue; - this->frame_ready = false; - this->current_output_buf = buf; - bool sent_packet = false; + VisionBuf *decoded = frame.buf; + copyBuffer(decoded, buf); + releaseFrame(decoded); + return buf; + } +} - while (!this->frame_ready) { - if (!sent_packet) { - int buf_index = getBufferUnlocked(); - if (buf_index >= 0) { - assert(buf_index < out_buf_cnt); - sendPacket(buf_index, pkt); - sent_packet = true; - } - } +void V4LDecoder::releaseFrame(VisionBuf *buf) { + assert(buf >= cap_bufs && buf < cap_bufs + CAPTURE_BUFFER_COUNT); + queueCaptureBuffer(buf - cap_bufs); +} - if (poll(pfd, nfds, -1) < 0) { +bool V4LDecoder::queuePacket(const AVPacket *pkt, uint64_t token) { + int buf_index = getBufferUnlocked(); + return buf_index >= 0 && sendPacket(buf_index, pkt, token); +} + +bool V4LDecoder::pump(V4LDecodedFrame &frame, int timeout_ms) { + frame = {}; + int rc; + while (true) { + rc = poll(&pfd, 1, timeout_ms); + if (rc < 0) { + if (errno == EINTR) continue; LOGE("poll() error: %d", errno); - return nullptr; - } - - if (VisionBuf* result = processEvents()) { - return result; + return false; } + break; } - return buf; + if (rc == 0) return true; + + int result; + + // Port changes must be handled before capture DQ so no old-format surface is + // handed to a client after the driver has requested a capture flush. + while ((result = handleEvent()) > 0) {} + if (result < 0) return false; + + while ((result = handleOutput()) > 0) {} + if (result < 0) return false; + + result = handleCapture(&frame); + return result >= 0; } -VisionBuf* MsmVidc::processEvents() { - for (int idx = 0; idx < nfds; idx++) { - short revents = pfd[idx].revents; - if (!revents) continue; - - if (idx == ev[EV_VIDEO]) { - if (revents & (POLLIN | POLLRDNORM)) { - VisionBuf *result = handleCapture(); - if (result == this->current_output_buf) { - this->frame_ready = true; - } - } - if (revents & (POLLOUT | POLLWRNORM)) { - handleOutput(); - } - if (revents & POLLPRI) { - handleEvent(); - } - } else { - LOGE("Unexpected event on fd %d", pfd[idx].fd); - } - } - return nullptr; -} - -VisionBuf* MsmVidc::handleCapture() { +int V4LDecoder::handleCapture(V4LDecodedFrame *frame) { struct v4l2_buffer buf = {0}; struct v4l2_plane planes[1] = {0}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; buf.memory = V4L2_MEMORY_USERPTR; buf.m.planes = planes; buf.length = 1; - util::safe_ioctl(this->fd, VIDIOC_DQBUF, &buf, "VIDIOC_DQBUF CAPTURE failed"); - - if (this->reconfigure_pending || buf.m.planes[0].bytesused == 0) { - return nullptr; + int err = HANDLE_EINTR(ioctl(this->fd, VIDIOC_DQBUF, &buf)); + if (err < 0 && errno == EAGAIN) return 0; + if (err < 0) { + LOGE("VIDIOC_DQBUF CAPTURE failed: %d", errno); + return -1; } - copyBuffer(&cap_bufs[buf.index], this->current_output_buf); - queueCaptureBuffer(buf.index); - return this->current_output_buf; + const bool has_payload = buf.m.planes[0].bytesused != 0; + const bool eos = (buf.flags & V4L2_QCOM_BUF_FLAG_EOS) != 0; + + frame->buf = nullptr; + if (!reconfigure_pending && has_payload) { + frame->buf = &cap_bufs[buf.index]; + frame->token = (uint64_t)buf.timestamp.tv_sec * 1000000ULL + buf.timestamp.tv_usec; + } else if (!reconfigure_pending && !eos) { + queueCaptureBuffer(buf.index); + } + + return 1; } -bool MsmVidc::subscribeEvents() { +bool V4LDecoder::subscribeEvents() { for (uint32_t event : subscriptions) { struct v4l2_event_subscription sub = { .type = event}; util::safe_ioctl(fd, VIDIOC_SUBSCRIBE_EVENT, &sub, "VIDIOC_SUBSCRIBE_EVENT failed"); @@ -135,7 +164,7 @@ bool MsmVidc::subscribeEvents() { return true; } -bool MsmVidc::setPlaneFormat(enum v4l2_buf_type type, uint32_t fourcc) { +bool V4LDecoder::setPlaneFormat(enum v4l2_buf_type type, uint32_t fourcc) { struct v4l2_format fmt = {.type = type}; struct v4l2_pix_format_mplane *pix = &fmt.fmt.pix_mp; *pix = { @@ -146,20 +175,17 @@ bool MsmVidc::setPlaneFormat(enum v4l2_buf_type type, uint32_t fourcc) { util::safe_ioctl(fd, VIDIOC_S_FMT, &fmt, "VIDIOC_S_FMT failed"); if (type == V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE) { this->out_buf_size = pix->plane_fmt[0].sizeimage; - int ion_size = this->out_buf_size * OUTPUT_BUFFER_COUNT; // Output (input) buffers are ION buffer. - this->out_buf.allocate(ion_size); // mmap rw for (int i = 0; i < OUTPUT_BUFFER_COUNT; i++) { - this->out_buf_off[i] = i * this->out_buf_size; - this->out_buf_addr[i] = (char *)this->out_buf.addr + this->out_buf_off[i]; + this->out_bufs[i].allocate(this->out_buf_size); this->out_buf_flag[i] = false; } - LOGD("Set output buffer size to %d, count %d, addr %p", this->out_buf_size, OUTPUT_BUFFER_COUNT, this->out_buf.addr); + LOGD("Set output buffer size to %d, count %d, addr %p", this->out_buf_size, OUTPUT_BUFFER_COUNT, this->out_bufs[0].addr); } else if (type == V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE) { request_buffers(this->fd, type, CAPTURE_BUFFER_COUNT); util::safe_ioctl(fd, VIDIOC_G_FMT, &fmt, "VIDIOC_G_FMT failed"); const __u32 y_size = pix->plane_fmt[0].sizeimage; const __u32 y_stride = pix->plane_fmt[0].bytesperline; - for (int i = 0; i < CAPTURE_BUFFER_COUNT; i++) { + for (size_t i = 0; i < CAPTURE_BUFFER_COUNT; i++) { size_t uv_offset = (size_t)y_stride * pix->height; size_t required = uv_offset + (y_stride * pix->height / 2); // enough for Y + UV. For linear NV12, UV plane starts at y_stride * height. size_t alloc_size = std::max(y_size, required); @@ -172,7 +198,7 @@ bool MsmVidc::setPlaneFormat(enum v4l2_buf_type type, uint32_t fourcc) { return true; } -bool MsmVidc::setFPS(uint32_t fps) { +bool V4LDecoder::setFPS(uint32_t fps) { struct v4l2_streamparm streamparam = { .type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, }; @@ -181,7 +207,7 @@ bool MsmVidc::setFPS(uint32_t fps) { return true; } -bool MsmVidc::restartCapture() { +bool V4LDecoder::restartCapture() { // stop if already initialized enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; if (this->initialized) { @@ -191,23 +217,36 @@ bool MsmVidc::restartCapture() { util::safe_ioctl(this->fd, VIDIOC_REQBUFS, &reqbuf, "VIDIOC_REQBUFS failed"); for (size_t i = 0; i < CAPTURE_BUFFER_COUNT; ++i) { this->cap_bufs[i].free(); - this->cap_buf_flag[i] = false; // mark as not queued cap_bufs[i].~VisionBuf(); new (&cap_bufs[i]) VisionBuf(); } } // setup, start and queue capture buffers setDBP(); - setPlaneFormat(type, V4L2_PIX_FMT_NV12); + setPlaneFormat(type, capture_format); + if (direct) { + struct v4l2_control ctrl = { + .id = V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE, + .value = OFFLINE_CORE_PLACEMENT_RATE, + }; + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL placement decode failed"); + } util::safe_ioctl(this->fd, VIDIOC_STREAMON, &type, "VIDIOC_STREAMON CAPTURE failed"); for (size_t i = 0; i < CAPTURE_BUFFER_COUNT; ++i) { queueCaptureBuffer(i); } + if (direct) { + struct v4l2_control ctrl = { + .id = V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE, + .value = INT_MAX, + }; + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL turbo decode failed"); + } return true; } -bool MsmVidc::queueCaptureBuffer(int i) { +bool V4LDecoder::queueCaptureBuffer(int i) { struct v4l2_buffer buf = {0}; struct v4l2_plane planes[1] = {0}; @@ -224,27 +263,28 @@ bool MsmVidc::queueCaptureBuffer(int i) { planes[0].bytesused = this->cap_bufs[i].len; planes[0].data_offset = 0; util::safe_ioctl(this->fd, VIDIOC_QBUF, &buf, "VIDIOC_QBUF failed"); - this->cap_buf_flag[i] = true; // mark as queued return true; } -bool MsmVidc::queueOutputBuffer(int i, size_t size) { +bool V4LDecoder::queueOutputBuffer(int i, size_t size, uint64_t token) { struct v4l2_buffer buf = {0}; struct v4l2_plane planes[1] = {0}; buf.type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE; buf.memory = V4L2_MEMORY_USERPTR; buf.index = i; + buf.flags = V4L2_BUF_FLAG_TIMESTAMP_COPY; + buf.timestamp.tv_sec = token / 1000000ULL; + buf.timestamp.tv_usec = token % 1000000ULL; buf.m.planes = planes; buf.length = 1; // decoded frame plane - planes[0].m.userptr = (unsigned long)this->out_buf_off[i]; // check this + planes[0].m.userptr = (unsigned long)this->out_bufs[i].addr; planes[0].length = this->out_buf_size; - planes[0].reserved[0] = this->out_buf.fd; // ION fd + planes[0].reserved[0] = this->out_bufs[i].fd; // ION fd planes[0].reserved[1] = 0; planes[0].bytesused = size; planes[0].data_offset = 0; - assert((this->out_buf_off[i] & 0xfff) == 0); // must be 4 KiB aligned assert(this->out_buf_size % 4096 == 0); // ditto for size util::safe_ioctl(this->fd, VIDIOC_QBUF, &buf, "VIDIOC_QBUF failed"); @@ -252,7 +292,7 @@ bool MsmVidc::queueOutputBuffer(int i, size_t size) { return true; } -bool MsmVidc::setDBP() { +bool V4LDecoder::setDBP() { struct v4l2_ext_control control[2] = {0}; struct v4l2_ext_controls controls = {0}; control[0].id = V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_MODE; @@ -266,27 +306,19 @@ bool MsmVidc::setDBP() { return true; } -bool MsmVidc::setupPolling() { - // Initialize poll array - pfd[EV_VIDEO] = {fd, POLLIN | POLLOUT | POLLWRNORM | POLLRDNORM | POLLPRI, 0}; - ev[EV_VIDEO] = EV_VIDEO; - nfds = 1; - return true; -} - -bool MsmVidc::sendPacket(int buf_index, AVPacket *pkt) { - assert(buf_index >= 0 && buf_index < out_buf_cnt); +bool V4LDecoder::sendPacket(int buf_index, const AVPacket *pkt, uint64_t token) { + assert(buf_index >= 0 && buf_index < OUTPUT_BUFFER_COUNT); assert(pkt != nullptr && pkt->data != nullptr && pkt->size > 0); + assert((size_t)pkt->size <= (size_t)this->out_buf_size); // Prepare output buffer - memset(this->out_buf_addr[buf_index], 0, this->out_buf_size); - uint8_t * data = (uint8_t *)this->out_buf_addr[buf_index]; + uint8_t * data = (uint8_t *)this->out_bufs[buf_index].addr; memcpy(data, pkt->data, pkt->size); - queueOutputBuffer(buf_index, pkt->size); + queueOutputBuffer(buf_index, pkt->size, token); return true; } -int MsmVidc::getBufferUnlocked() { - for (int i = 0; i < this->out_buf_cnt; i++) { +int V4LDecoder::getBufferUnlocked() { + for (int i = 0; i < OUTPUT_BUFFER_COUNT; i++) { if (!out_buf_flag[i]) { return i; } @@ -295,22 +327,32 @@ int MsmVidc::getBufferUnlocked() { } -bool MsmVidc::handleOutput() { +int V4LDecoder::handleOutput() { struct v4l2_buffer buf = {0}; struct v4l2_plane planes[1]; buf.type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE; buf.memory = V4L2_MEMORY_USERPTR; buf.m.planes = planes; buf.length = 1; - util::safe_ioctl(this->fd, VIDIOC_DQBUF, &buf, "VIDIOC_DQBUF OUTPUT failed"); + int err = HANDLE_EINTR(ioctl(this->fd, VIDIOC_DQBUF, &buf)); + if (err < 0 && errno == EAGAIN) return 0; + if (err < 0) { + LOGE("VIDIOC_DQBUF OUTPUT failed: %d", errno); + return -1; + } this->out_buf_flag[buf.index] = false; // mark as not queued - return true; + return 1; } -bool MsmVidc::handleEvent() { +int V4LDecoder::handleEvent() { // dequeue event struct v4l2_event event = {0}; - util::safe_ioctl(this->fd, VIDIOC_DQEVENT, &event, "VIDIOC_DQEVENT failed"); + int err = HANDLE_EINTR(ioctl(this->fd, VIDIOC_DQEVENT, &event)); + if (err < 0 && (errno == EAGAIN || errno == ENOENT)) return 0; + if (err < 0) { + LOGE("VIDIOC_DQEVENT failed: %d", errno); + return -1; + } switch (event.type) { case V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT: { unsigned int *ptr = (unsigned int *)event.u.data; @@ -342,5 +384,10 @@ bool MsmVidc::handleEvent() { default: break; } - return true; + return 1; +} + +void V4LDecoder::sendEOS() { + struct v4l2_decoder_cmd command = { .cmd = V4L2_DEC_CMD_STOP }; + util::safe_ioctl(fd, VIDIOC_DECODER_CMD, &command, "VIDIOC_DECODER_CMD STOP failed"); } diff --git a/openpilot/system/loggerd/encoder/v4l_decoder.h b/openpilot/system/loggerd/encoder/v4l_decoder.h new file mode 100644 index 0000000000..c7a96803ed --- /dev/null +++ b/openpilot/system/loggerd/encoder/v4l_decoder.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include + +#include "msgq/visionipc/visionbuf.h" + +extern "C" { + #include + #include +} + +#define V4L2_EVENT_MSM_VIDC_START (V4L2_EVENT_PRIVATE_START + 0x00001000) +#define V4L2_EVENT_MSM_VIDC_FLUSH_DONE (V4L2_EVENT_MSM_VIDC_START + 1) +#define V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT (V4L2_EVENT_MSM_VIDC_START + 3) +#ifndef V4L2_CID_MPEG_MSM_VIDC_BASE +#define V4L2_CID_MPEG_MSM_VIDC_BASE 0x00992000 +#endif +#ifndef V4L2_CID_MPEG_VIDC_VIDEO_DPB_COLOR_FORMAT +#define V4L2_CID_MPEG_VIDC_VIDEO_DPB_COLOR_FORMAT (V4L2_CID_MPEG_MSM_VIDC_BASE + 44) +#endif +#ifndef V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_MODE +#define V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_MODE (V4L2_CID_MPEG_MSM_VIDC_BASE + 22) +#endif +#ifndef V4L2_PIX_FMT_NV12_UBWC +#define V4L2_PIX_FMT_NV12_UBWC v4l2_fourcc('Q', '1', '2', '8') +#endif +#ifndef V4L2_CID_MPEG_VIDC_VIDEO_PRIORITY +#define V4L2_CID_MPEG_VIDC_VIDEO_PRIORITY (V4L2_CID_MPEG_MSM_VIDC_BASE + 52) +#define V4L2_MPEG_VIDC_VIDEO_PRIORITY_REALTIME_ENABLE 0 +#define V4L2_MPEG_VIDC_VIDEO_PRIORITY_REALTIME_DISABLE 1 +#endif +#ifndef V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE +#define V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE (V4L2_CID_MPEG_MSM_VIDC_BASE + 53) +#endif +#define V4L2_QCOM_CMD_FLUSH_CAPTURE (1 << 1) +#define V4L2_QCOM_CMD_FLUSH (4) +#ifndef V4L2_QCOM_BUF_FLAG_EOS +#define V4L2_QCOM_BUF_FLAG_EOS 0x02000000 +#endif + +#define OUTPUT_BUFFER_COUNT 8 +#define CAPTURE_BUFFER_COUNT 16 +#define FPS 20 + +struct V4LDecodedFrame { + VisionBuf *buf = nullptr; + uint64_t token = 0; +}; + +class V4LDecoder { +public: + static constexpr const char *DEVICE = "/dev/video32"; + + V4LDecoder() = default; + ~V4LDecoder(); + + bool init(const char* dev, size_t width, size_t height, uint64_t codec, + bool direct_mode = false, uint32_t capture_fourcc = V4L2_PIX_FMT_NV12); + VisionBuf* decodeFrame(AVPacket* pkt, VisionBuf* buf); + // queuePacket() and pump() are single-threaded. releaseFrame() may be called + // from a consumer thread after a direct capture surface is no longer needed. + bool queuePacket(const AVPacket *pkt, uint64_t token); + bool pump(V4LDecodedFrame &frame, int timeout_ms); + void releaseFrame(VisionBuf *buf); + void sendEOS(); + size_t maxPacketSize() const { return out_buf_size; } + + AVFormatContext* avctx = nullptr; + int fd = 0; + +private: + bool initialized = false; + bool reconfigure_pending = false; + bool direct = false; + uint32_t capture_format = V4L2_PIX_FMT_NV12; + + VisionBuf out_bufs[OUTPUT_BUFFER_COUNT]; // Distinct dma-buf per in-flight packet + VisionBuf cap_bufs[CAPTURE_BUFFER_COUNT]; // Capture (output) buffers + + size_t w = 0, h = 0; + int out_buf_size = 0; + + bool out_buf_flag[OUTPUT_BUFFER_COUNT] = {false}; + + const int subscriptions[2] = { + V4L2_EVENT_MSM_VIDC_FLUSH_DONE, + V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT + }; + + struct pollfd pfd = {}; + + bool subscribeEvents(); + bool setPlaneFormat(v4l2_buf_type type, uint32_t fourcc); + bool setFPS(uint32_t fps); + bool restartCapture(); + bool queueCaptureBuffer(int i); + bool queueOutputBuffer(int i, size_t size, uint64_t token); + bool setDBP(); + bool sendPacket(int buf_index, const AVPacket* pkt, uint64_t token); + int getBufferUnlocked(); + int handleCapture(V4LDecodedFrame *frame); + int handleOutput(); + int handleEvent(); +}; diff --git a/openpilot/system/loggerd/encoder/v4l_encoder.cc b/openpilot/system/loggerd/encoder/v4l_encoder.cc index 1b4d976b93..0d7e744568 100644 --- a/openpilot/system/loggerd/encoder/v4l_encoder.cc +++ b/openpilot/system/loggerd/encoder/v4l_encoder.cc @@ -2,6 +2,7 @@ #include #include #include +#include #include "system/loggerd/encoder/v4l_encoder.h" #include "common/util.h" @@ -77,9 +78,10 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { uint32_t idx = -1; bool exit = false; - // POLLIN is capture, POLLOUT is frame + // POLLIN is capture, POLLOUT is frame. Qualcomm's reference client also + // requests the corresponding normal-data bits. struct pollfd pfd; - pfd.events = POLLIN | POLLOUT; + pfd.events = POLLIN | POLLRDNORM | POLLOUT | POLLWRNORM; pfd.fd = e->fd; // save the header @@ -104,7 +106,7 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { } int frame_id = -1; - if (pfd.revents & POLLIN) { + if (pfd.revents & (POLLIN | POLLRDNORM)) { unsigned int bytesused, flags, index; struct timeval timestamp; dequeue_buffer(e->fd, V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, &index, &bytesused, &flags, ×tamp); @@ -118,12 +120,17 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { } else if (flags & V4L2_QCOM_BUF_FLAG_CODECCONFIG) { // save header header = kj::heapArray(buf, bytesused); + if (e->packet_callback) e->packet_callback(header.begin(), header.size(), ts, true, false); } else { VisionIpcBufExtra extra = e->extras.pop(); assert(extra.timestamp_eof/1000 == ts); // stay in sync frame_id = extra.frame_id; ++idx; - e->publisher_publish(e->segment_num, idx, extra, flags, header, kj::arrayPtr(buf, bytesused)); + if (e->packet_callback) { + e->packet_callback(buf, bytesused, ts, false, flags & V4L2_BUF_FLAG_KEYFRAME); + } else { + e->publisher_publish(e->segment_num, idx, extra, flags, header, kj::arrayPtr(buf, bytesused)); + } } if (env_debug_encoder) { @@ -135,16 +142,22 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { queue_buffer(e->fd, V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, index, &e->buf_out[index]); } - if (pfd.revents & POLLOUT) { + if (pfd.revents & (POLLOUT | POLLWRNORM)) { unsigned int index; dequeue_buffer(e->fd, V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, &index); + VisionBuf *input_buf = e->input_bufs[index].exchange(nullptr); + if (input_buf && e->input_done_callback) e->input_done_callback(input_buf); e->free_buf_in.push(index); } } } V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_height) - : VideoEncoder(encoder_info, in_width, in_height) { + : V4LEncoder(encoder_info, in_width, in_height, Options{}) {} + +V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_height, Options options) + : VideoEncoder(encoder_info, in_width, in_height), packet_callback(std::move(options.packet_callback)), + input_done_callback(std::move(options.input_done_callback)) { fd = HANDLE_EINTR(open("/dev/v4l/by-path/platform-aa00000.qcom_vidc-video-index1", O_RDWR|O_NONBLOCK)); assert(fd >= 0); @@ -193,7 +206,7 @@ V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_hei .pix_mp = { .width = (unsigned int)in_width, .height = (unsigned int)in_height, - .pixelformat = V4L2_PIX_FMT_NV12, + .pixelformat = options.input_format, .field = V4L2_FIELD_ANY, .colorspace = V4L2_COLORSPACE_470_SYSTEM_BG, } @@ -220,6 +233,13 @@ V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_hei util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL failed"); } } + if (options.max_performance) { + struct v4l2_control ctrl = { + .id = V4L2_CID_MPEG_VIDC_VIDEO_PRIORITY, + .value = V4L2_MPEG_VIDC_VIDEO_PRIORITY_REALTIME_ENABLE, + }; + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL offline encode failed"); + } if (is_h265) { struct v4l2_control ctrls[] = { @@ -231,12 +251,30 @@ V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_hei util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL failed"); } } else { + if (encoder_info.is_live) { + struct v4l2_control ctrls[] = { + { .id = V4L2_CID_MPEG_VIDEO_H264_PROFILE, .value = V4L2_MPEG_VIDEO_H264_PROFILE_HIGH}, + { .id = V4L2_CID_MPEG_VIDEO_H264_LEVEL, .value = V4L2_MPEG_VIDEO_H264_LEVEL_3_1}, + { .id = V4L2_CID_MPEG_VIDEO_H264_ENTROPY_MODE, .value = V4L2_MPEG_VIDEO_H264_ENTROPY_MODE_CABAC}, + { .id = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL, .value = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL_0}, + }; + for (auto ctrl : ctrls) { + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL failed"); + } + } else { + struct v4l2_control ctrls[] = { + { .id = V4L2_CID_MPEG_VIDEO_H264_PROFILE, .value = V4L2_MPEG_VIDEO_H264_PROFILE_HIGH}, + { .id = V4L2_CID_MPEG_VIDEO_H264_LEVEL, .value = V4L2_MPEG_VIDEO_H264_LEVEL_UNKNOWN}, + { .id = V4L2_CID_MPEG_VIDEO_H264_ENTROPY_MODE, .value = V4L2_MPEG_VIDEO_H264_ENTROPY_MODE_CABAC}, + { .id = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL, .value = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL_0}, + }; + for (auto ctrl : ctrls) { + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL failed"); + } + } + struct v4l2_control ctrls[] = { - { .id = V4L2_CID_MPEG_VIDEO_H264_PROFILE, .value = V4L2_MPEG_VIDEO_H264_PROFILE_HIGH}, - { .id = V4L2_CID_MPEG_VIDEO_H264_LEVEL, .value = V4L2_MPEG_VIDEO_H264_LEVEL_UNKNOWN}, - { .id = V4L2_CID_MPEG_VIDEO_H264_ENTROPY_MODE, .value = V4L2_MPEG_VIDEO_H264_ENTROPY_MODE_CABAC}, - { .id = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL, .value = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL_0}, - { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_MODE, .value = 0}, + { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_MODE, .value = V4L2_MPEG_VIDEO_H264_LOOP_FILTER_MODE_ENABLED}, { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_ALPHA, .value = 0}, { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_BETA, .value = 0}, { .id = V4L2_CID_MPEG_VIDEO_MULTI_SLICE_MODE, .value = 0}, @@ -281,6 +319,7 @@ int V4LEncoder::encode_frame(VisionBuf* buf, VisionIpcBufExtra *extra) { // reserve buffer int buffer_in = free_buf_in.pop(); + input_bufs[buffer_in].store(buf); // push buffer extras.push(*extra); diff --git a/openpilot/system/loggerd/encoder/v4l_encoder.h b/openpilot/system/loggerd/encoder/v4l_encoder.h index b1a84e654f..c8399e1225 100644 --- a/openpilot/system/loggerd/encoder/v4l_encoder.h +++ b/openpilot/system/loggerd/encoder/v4l_encoder.h @@ -1,14 +1,27 @@ #pragma once +#include +#include + #include "common/queue.h" #include "system/loggerd/encoder/encoder.h" -#define BUF_IN_COUNT 7 +#define BUF_IN_COUNT 9 #define BUF_OUT_COUNT 6 class V4LEncoder : public VideoEncoder { public: + using PacketCallback = std::function; + using InputDoneCallback = std::function; + struct Options { + PacketCallback packet_callback; + uint32_t input_format = V4L2_PIX_FMT_NV12; + InputDoneCallback input_done_callback; + bool max_performance = false; + }; + V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_height); + V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_height, Options options); ~V4LEncoder(); int encode_frame(VisionBuf* buf, VisionIpcBufExtra *extra); void encoder_open(); @@ -23,12 +36,14 @@ private: int segment_num = -1; int counter = 0; int current_bitrate = -1; - SafeQueue extras; + PacketCallback packet_callback; + InputDoneCallback input_done_callback; static void dequeue_handler(V4LEncoder *e); std::thread dequeue_handler_thread; VisionBuf buf_out[BUF_OUT_COUNT]; + std::atomic input_bufs[BUF_IN_COUNT] = {}; SafeQueue free_buf_in; }; diff --git a/openpilot/system/loggerd/encoderd.cc b/openpilot/system/loggerd/encoderd.cc index 11db07671d..55f0777fbb 100644 --- a/openpilot/system/loggerd/encoderd.cc +++ b/openpilot/system/loggerd/encoderd.cc @@ -1,9 +1,16 @@ #include +#ifdef __COMMA_HARDWARE__ +#include +#include +#endif +#ifdef __COMMA_HARDWARE__ +#include "system/loggerd/clip_encoder.h" +#endif #include "system/loggerd/loggerd.h" #include "system/loggerd/encoder/jpeg_encoder.h" -#ifdef __TICI__ +#ifdef __COMMA_HARDWARE__ #include "system/loggerd/encoder/v4l_encoder.h" #define Encoder V4LEncoder #else @@ -171,6 +178,37 @@ void encoderd_thread(const LogCameraInfo (&cameras)[N]) { } int main(int argc, char* argv[]) { +#ifdef __COMMA_HARDWARE__ + if (argc > 1 && std::string(argv[1]) == "--clip") { + if (argc < 6) { + fprintf(stderr, "usage: encoderd --clip OUTPUT START DURATION [--bitrate BPS] [--speedup N] " + "[--metadata JSON] SEGMENT [SEGMENT ...]\n"); + return 2; + } + try { + int bitrate = 5'000'000; + int speedup = 1; + std::string metadata; + int input_arg = 5; + while (input_arg < argc && std::string(argv[input_arg]).rfind("--", 0) == 0) { + const std::string option = argv[input_arg++]; + if (option == "--") break; + if (input_arg == argc) throw std::invalid_argument("missing clip option value"); + if (option == "--bitrate") bitrate = std::stoi(argv[input_arg++]); + else if (option == "--speedup") speedup = std::stoi(argv[input_arg++]); + else if (option == "--metadata") metadata = argv[input_arg++]; + else throw std::invalid_argument("unknown clip option: " + option); + } + if (input_arg == argc) throw std::invalid_argument("missing clip input"); + std::vector inputs(argv + input_arg, argv + argc); + return encode_clip(inputs, argv[2], std::stod(argv[3]), std::stod(argv[4]), + bitrate, speedup, metadata); + } catch (const std::exception &e) { + fprintf(stderr, "clip encoding failed: %s\n", e.what()); + return 1; + } + } +#endif if (!Hardware::PC()) { int ret; ret = util::set_realtime_priority(52); diff --git a/openpilot/system/loggerd/logger.cc b/openpilot/system/loggerd/logger.cc index e8aeb96d02..f8f94c4b71 100644 --- a/openpilot/system/loggerd/logger.cc +++ b/openpilot/system/loggerd/logger.cc @@ -14,7 +14,7 @@ #include "sunnypilot/common/version.h" // ***** log metadata ***** -kj::Array logger_build_init_data() { +kj::Array logger_build_init_data(bool route_log) { uint64_t wall_time = nanos_since_epoch(); MessageBuilder msg; @@ -72,7 +72,7 @@ kj::Array logger_build_init_data() { "df -h", // usage for all filesystems }; - auto hw_logs = Hardware::get_init_logs(); + auto hw_logs = Hardware::get_init_logs(route_log); auto commands = init.initCommands().initEntries(log_commands.size() + hw_logs.size()); for (int i = 0; i < log_commands.size(); i++) { @@ -166,7 +166,7 @@ static void log_sentinel(LoggerState *log, SentinelType type, int exit_signal = LoggerState::LoggerState(const std::string &log_root) { route_name = logger_get_identifier("RouteCount"); route_path = log_root + "/" + route_name; - init_data = logger_build_init_data(); + init_data = logger_build_init_data(true); } LoggerState::~LoggerState() { diff --git a/openpilot/system/loggerd/logger.h b/openpilot/system/loggerd/logger.h index 419becfe5d..17c29d1a02 100644 --- a/openpilot/system/loggerd/logger.h +++ b/openpilot/system/loggerd/logger.h @@ -32,6 +32,6 @@ protected: std::unique_ptr rlog, qlog; }; -kj::Array logger_build_init_data(); +kj::Array logger_build_init_data(bool route_log = false); std::string logger_get_identifier(std::string key); std::string zstd_decompress(const std::string &in); diff --git a/openpilot/system/loggerd/loggerd.cc b/openpilot/system/loggerd/loggerd.cc index 3755929619..af4a5c0cde 100644 --- a/openpilot/system/loggerd/loggerd.cc +++ b/openpilot/system/loggerd/loggerd.cc @@ -31,7 +31,7 @@ void logger_rotate(LoggerdState *s) { void rotate_if_needed(LoggerdState *s) { // all encoders ready, trigger rotation - bool all_ready = s->ready_to_rotate == s->max_waiting; + bool all_ready = s->max_waiting > 0 && s->ready_to_rotate == s->max_waiting; // fallback logic to prevent extremely long segments in the case of camera, encoder, etc. malfunctions bool timed_out = false; @@ -125,6 +125,7 @@ int handle_encoder_msg(LoggerdState *s, Message *msg, std::string &name, struct if (!re.seen_first_packet) { re.seen_first_packet = true; re.encoderd_segment_offset = idx.getSegmentNum(); + ++s->max_waiting; // only count encoders that publish so a disabled/missing camera doesn't stall rotation LOGD("%s: has encoderd offset %d", name.c_str(), re.encoderd_segment_offset); } int offset_segment_num = idx.getSegmentNum() - re.encoderd_segment_offset; @@ -246,7 +247,7 @@ void loggerd_thread() { .counter = 0, .freq = it.decimation, .encoder = encoder, - .preserve_segment = (it.name == "userBookmark") || (it.name == "audioFeedback"), + .preserve_segment = it.name == "userBookmark", .record_audio = record_audio, }; } @@ -262,7 +263,6 @@ void loggerd_thread() { for (const auto &cam : cameras_logged) { for (const auto &encoder_info : cam.encoder_infos) { encoder_infos_dict[encoder_info.publish_name] = encoder_info; - s.max_waiting++; } } diff --git a/openpilot/system/loggerd/loggerd.h b/openpilot/system/loggerd/loggerd.h index 110dbe4fd2..beabd7d791 100644 --- a/openpilot/system/loggerd/loggerd.h +++ b/openpilot/system/loggerd/loggerd.h @@ -5,6 +5,7 @@ #include "openpilot/cereal/messaging/messaging.h" #include "openpilot/cereal/services.h" +#include "openpilot/cereal/visionstream.h" #include "msgq/visionipc/visionipc_client.h" #include "common/hardware/hw.h" #include "common/params.h" @@ -25,6 +26,22 @@ const auto MAIN_ENCODE_TYPE = Hardware::PC() ? cereal::EncodeIndex::Type::BIG_BO const bool LOGGERD_TEST = getenv("LOGGERD_TEST"); const int SEGMENT_LENGTH = LOGGERD_TEST ? atoi(getenv("LOGGERD_SEGMENT_LENGTH")) : 60; +inline int livestream_width() { + switch (Hardware::get_device_type()) { + case cereal::InitData::DeviceType::TIZI: return 1152; + case cereal::InitData::DeviceType::MICI: return 1280; + default: return -1; + } +} + +inline int livestream_height() { + switch (Hardware::get_device_type()) { + case cereal::InitData::DeviceType::TIZI: + case cereal::InitData::DeviceType::MICI: return 720; + default: return -1; + } +} + constexpr char PRESERVE_ATTR_NAME[] = "user.preserve"; constexpr char PRESERVE_ATTR_VALUE = '1'; @@ -79,11 +96,11 @@ public: }; const EncoderInfo main_road_encoder_info = { - .publish_name = "roadEncodeData", + .publish_name = "narrowRoadEncodeData", .thumbnail_name = "thumbnail", .filename = "fcamera.hevc", .get_settings = [](int in_width){return EncoderSettings::MainEncoderSettings(in_width);}, - INIT_ENCODE_FUNCTIONS(RoadEncode), + INIT_ENCODE_FUNCTIONS(NarrowRoadEncode), }; const EncoderInfo main_wide_road_encoder_info = { @@ -93,52 +110,58 @@ const EncoderInfo main_wide_road_encoder_info = { INIT_ENCODE_FUNCTIONS(WideRoadEncode), }; -const EncoderInfo main_driver_encoder_info = { - .publish_name = "driverEncodeData", +const EncoderInfo main_cabin_encoder_info = { + .publish_name = "cabinEncodeData", .filename = "dcamera.hevc", .record = Params().getBool("RecordFront"), .get_settings = [](int in_width){return EncoderSettings::MainEncoderSettings(in_width);}, - INIT_ENCODE_FUNCTIONS(DriverEncode), + INIT_ENCODE_FUNCTIONS(CabinEncode), }; const EncoderInfo stream_road_encoder_info = { - .publish_name = "livestreamRoadEncodeData", + .publish_name = "livestreamNarrowRoadEncodeData", //.thumbnail_name = "thumbnail", .record = false, .is_live = true, + .frame_width = livestream_width(), + .frame_height = livestream_height(), .get_settings = [](int){return EncoderSettings::StreamEncoderSettings();}, - INIT_ENCODE_FUNCTIONS(LivestreamRoadEncode), + INIT_ENCODE_FUNCTIONS(LivestreamNarrowRoadEncode), }; const EncoderInfo stream_wide_road_encoder_info = { .publish_name = "livestreamWideRoadEncodeData", .record = false, .is_live = true, + .frame_width = livestream_width(), + .frame_height = livestream_height(), .get_settings = [](int){return EncoderSettings::StreamEncoderSettings();}, INIT_ENCODE_FUNCTIONS(LivestreamWideRoadEncode), }; -const EncoderInfo stream_driver_encoder_info = { - .publish_name = "livestreamDriverEncodeData", +const EncoderInfo stream_cabin_encoder_info = { + .publish_name = "livestreamCabinEncodeData", .record = false, .is_live = true, + .frame_width = livestream_width(), + .frame_height = livestream_height(), .get_settings = [](int){return EncoderSettings::StreamEncoderSettings();}, - INIT_ENCODE_FUNCTIONS(LivestreamDriverEncode), + INIT_ENCODE_FUNCTIONS(LivestreamCabinEncode), }; const EncoderInfo qcam_encoder_info = { - .publish_name = "qRoadEncodeData", + .publish_name = "qNarrowRoadEncodeData", .filename = "qcamera.ts", .include_audio = Params().getBool("RecordAudio"), .frame_width = 526, .frame_height = 330, .get_settings = [](int){return EncoderSettings::QcamEncoderSettings();}, - INIT_ENCODE_FUNCTIONS(QRoadEncode), + INIT_ENCODE_FUNCTIONS(QNarrowRoadEncode), }; -const LogCameraInfo road_camera_info{ - .thread_name = "road_cam_encoder", - .stream_type = VISION_STREAM_ROAD, +const LogCameraInfo narrow_road_camera_info{ + .thread_name = "narrow_road_cam_encoder", + .stream_type = VISION_STREAM_NARROW_ROAD, .encoder_infos = {main_road_encoder_info, qcam_encoder_info} }; @@ -148,15 +171,15 @@ const LogCameraInfo wide_road_camera_info{ .encoder_infos = {main_wide_road_encoder_info} }; -const LogCameraInfo driver_camera_info{ - .thread_name = "driver_cam_encoder", - .stream_type = VISION_STREAM_DRIVER, - .encoder_infos = {main_driver_encoder_info} +const LogCameraInfo cabin_camera_info{ + .thread_name = "cabin_cam_encoder", + .stream_type = VISION_STREAM_CABIN, + .encoder_infos = {main_cabin_encoder_info} }; const LogCameraInfo stream_road_camera_info{ - .thread_name = "road_cam_encoder", - .stream_type = VISION_STREAM_ROAD, + .thread_name = "narrow_road_cam_encoder", + .stream_type = VISION_STREAM_NARROW_ROAD, .encoder_infos = {stream_road_encoder_info}, }; @@ -166,11 +189,11 @@ const LogCameraInfo stream_wide_road_camera_info{ .encoder_infos = {stream_wide_road_encoder_info}, }; -const LogCameraInfo stream_driver_camera_info{ - .thread_name = "driver_cam_encoder", - .stream_type = VISION_STREAM_DRIVER, - .encoder_infos = {stream_driver_encoder_info}, +const LogCameraInfo stream_cabin_camera_info{ + .thread_name = "cabin_cam_encoder", + .stream_type = VISION_STREAM_CABIN, + .encoder_infos = {stream_cabin_encoder_info}, }; -const LogCameraInfo cameras_logged[] = {road_camera_info, wide_road_camera_info, driver_camera_info}; -const LogCameraInfo stream_cameras_logged[] = {stream_road_camera_info, stream_wide_road_camera_info, stream_driver_camera_info}; +const LogCameraInfo cameras_logged[] = {narrow_road_camera_info, wide_road_camera_info, cabin_camera_info}; +const LogCameraInfo stream_cameras_logged[] = {stream_road_camera_info, stream_wide_road_camera_info, stream_cabin_camera_info}; diff --git a/openpilot/system/loggerd/tests/loggerd_tests_common.py b/openpilot/system/loggerd/tests/loggerd_tests_common.py index 757b61d17e..689d2c27c0 100644 --- a/openpilot/system/loggerd/tests/loggerd_tests_common.py +++ b/openpilot/system/loggerd/tests/loggerd_tests_common.py @@ -3,6 +3,7 @@ import random from pathlib import Path +from openpilot.common.test import OpenpilotTestCase import openpilot.system.loggerd.deleter as deleter import openpilot.system.loggerd.uploader as uploader from openpilot.common.params import Params @@ -53,7 +54,7 @@ class MockApiIgnore: def get_token(self): return "fake-token" -class UploaderTestCase: +class UploaderTestCase(OpenpilotTestCase): f_type = "UNKNOWN" root: Path @@ -63,10 +64,10 @@ class UploaderTestCase: seg_dir: str def set_ignore(self): - uploader.Api = MockApiIgnore + uploader.Api = MockApiIgnore # ty: ignore[invalid-assignment] # test double def setup_method(self): - uploader.Api = MockApi + uploader.Api = MockApi # ty: ignore[invalid-assignment] # test double uploader.fake_upload = True uploader.force_wifi = True uploader.allow_sleep = False diff --git a/openpilot/system/loggerd/tests/test_deleter.py b/openpilot/system/loggerd/tests/test_deleter.py index 6222ea253b..6fe1b22579 100644 --- a/openpilot/system/loggerd/tests/test_deleter.py +++ b/openpilot/system/loggerd/tests/test_deleter.py @@ -1,66 +1,39 @@ -import time -import threading from collections import namedtuple from pathlib import Path from collections.abc import Sequence import openpilot.system.loggerd.deleter as deleter -from openpilot.common.timeout import Timeout, TimeoutException from openpilot.system.loggerd.tests.loggerd_tests_common import UploaderTestCase Stats = namedtuple("Stats", ['f_bavail', 'f_blocks', 'f_frsize']) class TestDeleter(UploaderTestCase): + # Deletion behavior is independent of file size; use smaller files to keep these tests fast. + def make_file_with_data(self, f_dir: str, fn: str, size_mb: float = .001, lock: bool = False, + upload_xattr: bytes | None = None, preserve_xattr: bytes | None = None) -> Path: + return super().make_file_with_data(f_dir, fn, size_mb, lock, upload_xattr, preserve_xattr) + def fake_statvfs(self, d): return self.fake_stats def setup_method(self): self.f_type = "fcamera.hevc" - super().setup_method() + super().openpilot_setup_method() self.fake_stats = Stats(f_bavail=0, f_blocks=10, f_frsize=4096) - deleter.os.statvfs = self.fake_statvfs - - def start_thread(self): - self.end_event = threading.Event() - self.del_thread = threading.Thread(target=deleter.deleter_thread, args=[self.end_event]) - self.del_thread.daemon = True - self.del_thread.start() - - def join_thread(self): - self.end_event.set() - self.del_thread.join() + deleter.os.statvfs = self.fake_statvfs # ty: ignore[invalid-assignment] # test double def test_delete(self): - f_path = self.make_file_with_data(self.seg_dir, self.f_type, 1) + f_path = self.make_file_with_data(self.seg_dir, self.f_type) + assert deleter.deleter_step() == (True, str(f_path.parent)) + assert not f_path.exists() - self.start_thread() - - try: - with Timeout(2, "Timeout waiting for file to be deleted"): - while f_path.exists(): - time.sleep(0.01) - finally: - self.join_thread() - - def assertDeleteOrder(self, f_paths: Sequence[Path], timeout: int = 5) -> None: + def assertDeleteOrder(self, f_paths: Sequence[Path]) -> None: deleted_order = [] - - self.start_thread() - try: - with Timeout(timeout, "Timeout waiting for files to be deleted"): - while True: - for f in f_paths: - if not f.exists() and f not in deleted_order: - deleted_order.append(f) - if len(deleted_order) == len(f_paths): - break - time.sleep(0.01) - except TimeoutException: - print("Not deleted:", [f for f in f_paths if f not in deleted_order]) - raise - finally: - self.join_thread() + for _ in f_paths: + out_of_space, deleted_path = deleter.deleter_step() + assert out_of_space and deleted_path is not None + deleted_order.append(next(f for f in f_paths if f.parent == Path(deleted_path))) assert deleted_order == f_paths, "Files not deleted in expected order" @@ -97,21 +70,11 @@ class TestDeleter(UploaderTestCase): available = (10 * 1024 * 1024 * 1024) / block_size # 10GB free self.fake_stats = Stats(f_bavail=available, f_blocks=10, f_frsize=block_size) - self.start_thread() - start_time = time.monotonic() - while f_path.exists() and time.monotonic() - start_time < 2: - time.sleep(0.01) - self.join_thread() - + assert deleter.deleter_step() == (False, None) assert f_path.exists(), "File deleted with available space" def test_no_delete_with_lock_file(self): f_path = self.make_file_with_data(self.seg_dir, self.f_type, lock=True) - self.start_thread() - start_time = time.monotonic() - while f_path.exists() and time.monotonic() - start_time < 2: - time.sleep(0.01) - self.join_thread() - + assert deleter.deleter_step() == (True, None) assert f_path.exists(), "File deleted when locked" diff --git a/openpilot/system/loggerd/tests/test_encoder.py b/openpilot/system/loggerd/tests/test_encoder.py old mode 100644 new mode 100755 index 05bc211cbe..10cd586f35 --- a/openpilot/system/loggerd/tests/test_encoder.py +++ b/openpilot/system/loggerd/tests/test_encoder.py @@ -1,16 +1,19 @@ +#!/usr/bin/env python3 + import math import os -import pytest import shutil import subprocess import time +import unittest from pathlib import Path from tqdm import trange +from openpilot.common.test import OpenpilotTestCase from openpilot.common.params import Params from openpilot.common.timeout import Timeout -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.system.manager.process_config import managed_processes from openpilot.tools.lib.logreader import LogReader from openpilot.common.hardware.hw import Paths @@ -19,8 +22,8 @@ SEGMENT_LENGTH = 2 FULL_SIZE = 2507572 def hevc_size(w): return FULL_SIZE // 2 if w <= 1344 else FULL_SIZE CAMERAS = [ - ("fcamera.hevc", 20, hevc_size, "roadEncodeIdx"), - ("dcamera.hevc", 20, hevc_size, "driverEncodeIdx"), + ("fcamera.hevc", 20, hevc_size, "narrowRoadEncodeIdx"), + ("dcamera.hevc", 20, hevc_size, "cabinEncodeIdx"), ("ecamera.hevc", 20, hevc_size, "wideRoadEncodeIdx"), ("qcamera.ts", 20, lambda x: 130000, None), ] @@ -29,8 +32,8 @@ CAMERAS = [ FILE_SIZE_TOLERANCE = 0.7 -@pytest.mark.tici # TODO: all of loggerd should work on PC -class TestEncoder: +class TestEncoder(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True def setup_method(self): self._clear_logs() @@ -83,7 +86,7 @@ class TestEncoder: # TODO: this ffprobe call is really slow # get width and check frame count cmd = f"ffprobe -v error -select_streams v:0 -count_packets -show_entries stream=nb_read_packets,width -of csv=p=0 {file_path}" - if TICI: + if COMMA_HARDWARE: cmd = "LD_LIBRARY_PATH=/usr/local/lib " + cmd expected_frames = fps * SEGMENT_LENGTH @@ -127,7 +130,7 @@ class TestEncoder: assert 1 == len(set(first_frames)) - if TICI: + if COMMA_HARDWARE: expected_frames = fps * SEGMENT_LENGTH assert min(counts) == expected_frames shutil.rmtree(f"{route_prefix_path}--{i}") @@ -144,3 +147,7 @@ class TestEncoder: managed_processes['encoderd'].stop() managed_processes['camerad'].stop() managed_processes['sensord'].stop() + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/system/loggerd/tests/test_logger.cc b/openpilot/system/loggerd/tests/test_logger.cc deleted file mode 100644 index 61509c256c..0000000000 --- a/openpilot/system/loggerd/tests/test_logger.cc +++ /dev/null @@ -1,75 +0,0 @@ -#include "catch2/catch.hpp" -#include "system/loggerd/logger.h" - -typedef cereal::Sentinel::SentinelType SentinelType; - -void verify_segment(const std::string &route_path, int segment, int max_segment, int required_event_cnt) { - const std::string segment_path = route_path + "--" + std::to_string(segment); - SentinelType begin_sentinel = segment == 0 ? SentinelType::START_OF_ROUTE : SentinelType::START_OF_SEGMENT; - SentinelType end_sentinel = segment == max_segment - 1 ? SentinelType::END_OF_ROUTE : SentinelType::END_OF_SEGMENT; - - REQUIRE(!util::file_exists(segment_path + "/rlog.lock")); - for (const char *fn : {"/rlog.zst", "/qlog.zst"}) { - const std::string log_file = segment_path + fn; - std::string log = util::read_file(log_file); - REQUIRE(!log.empty()); - std::string decompressed_log = zstd_decompress(log); - int event_cnt = 0, i = 0; - kj::ArrayPtr words((capnp::word *)decompressed_log.data(), decompressed_log.size() / sizeof(capnp::word)); - while (words.size() > 0) { - try { - capnp::FlatArrayMessageReader reader(words); - auto event = reader.getRoot(); - words = kj::arrayPtr(reader.getEnd(), words.end()); - if (i == 0) { - REQUIRE(event.which() == cereal::Event::INIT_DATA); - } else if (i == 1) { - REQUIRE(event.which() == cereal::Event::SENTINEL); - REQUIRE(event.getSentinel().getType() == begin_sentinel); - REQUIRE(event.getSentinel().getSignal() == 0); - } else if (words.size() > 0) { - REQUIRE(event.which() == cereal::Event::CLOCKS); - ++event_cnt; - } else { - // the last event must be SENTINEL - REQUIRE(event.which() == cereal::Event::SENTINEL); - REQUIRE(event.getSentinel().getType() == end_sentinel); - REQUIRE(event.getSentinel().getSignal() == (end_sentinel == SentinelType::END_OF_ROUTE ? 1 : 0)); - } - ++i; - } catch (const kj::Exception &ex) { - INFO("failed parse " << i << " exception :" << ex.getDescription()); - REQUIRE(0); - break; - } - } - REQUIRE(event_cnt == required_event_cnt); - } -} - -void write_msg(LoggerState *logger) { - MessageBuilder msg; - msg.initEvent().initClocks(); - logger->write(msg.toBytes(), true); -} - -TEST_CASE("logger") { - const int segment_cnt = 100; - const std::string log_root = "/tmp/test_logger"; - REQUIRE(system(("rm " + log_root + " -rf").c_str()) == 0); - std::string route_name; - { - LoggerState logger(log_root); - route_name = logger.routeName(); - for (int i = 0; i < segment_cnt; ++i) { - REQUIRE(logger.next()); - REQUIRE(util::file_exists(logger.segmentPath() + "/rlog.lock")); - REQUIRE(logger.segment() == i); - write_msg(&logger); - } - logger.setExitSignal(1); - } - for (int i = 0; i < segment_cnt; ++i) { - verify_segment(log_root + "/" + route_name, i, segment_cnt, 1); - } -} diff --git a/openpilot/system/loggerd/tests/test_loggerd.py b/openpilot/system/loggerd/tests/test_loggerd.py index 5d8f635d96..de91cae1db 100644 --- a/openpilot/system/loggerd/tests/test_loggerd.py +++ b/openpilot/system/loggerd/tests/test_loggerd.py @@ -5,10 +5,12 @@ import random import string import subprocess import time +from collections.abc import Collection from collections import defaultdict from pathlib import Path -import pytest +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.cereal import log from openpilot.cereal.services import SERVICE_LIST @@ -16,14 +18,15 @@ from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.timeout import Timeout from openpilot.common.hardware.hw import Paths -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.system.loggerd.xattr_cache import getxattr from openpilot.system.loggerd.deleter import PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE from openpilot.system.manager.process_config import managed_processes from openpilot.common.version import get_version from openpilot.tools.lib.helpers import RE from openpilot.tools.lib.logreader import LogReader -from msgq.visionipc import VisionIpcServer, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcServer SentinelType = log.Sentinel.SentinelType @@ -31,7 +34,7 @@ CEREAL_SERVICES = [f for f in log.Event.schema.union_fields if f in SERVICE_LIST and SERVICE_LIST[f].should_log and "encode" not in f.lower()] -class TestLoggerd: +class TestLoggerd(OpenpilotTestCase): def _get_latest_log_dir(self): log_dirs = sorted(Path(Paths.log_root()).iterdir(), key=lambda f: f.stat().st_mtime) return log_dirs[-1] @@ -74,8 +77,8 @@ class TestLoggerd: end_type = SentinelType.endOfRoute if route else SentinelType.endOfSegment assert msgs[-1].sentinel.type == end_type - def _publish_random_messages(self, services: list[str]) -> dict[str, list]: - pm = messaging.PubMaster(services) + def _publish_random_messages(self, services: Collection[str]) -> dict[str, list]: + pm = messaging.PubMaster(list(services)) managed_processes["loggerd"].start() for s in services: @@ -109,12 +112,12 @@ class TestLoggerd: w, h = 320, 240 frame_spec = (w, h, w * h * 3 // 2, w, w * h) streams = [ - (VisionStreamType.VISION_STREAM_ROAD, frame_spec, "roadCameraState"), - (VisionStreamType.VISION_STREAM_DRIVER, frame_spec, "driverCameraState"), + (VisionStreamType.VISION_STREAM_NARROW_ROAD, frame_spec, "narrowRoadCameraState"), + (VisionStreamType.VISION_STREAM_CABIN, frame_spec, "cabinCameraState"), (VisionStreamType.VISION_STREAM_WIDE_ROAD, frame_spec, "wideRoadCameraState"), ] - sm = messaging.SubMaster(["roadEncodeData"]) + sm = messaging.SubMaster(["narrowRoadEncodeData"]) pm = messaging.PubMaster([s for _, _, s in streams] + ["rawAudioData"]) vipc_server = VisionIpcServer("camerad") for stream_type, frame_spec, _ in streams: @@ -125,7 +128,7 @@ class TestLoggerd: os.environ["LOGGERD_SEGMENT_LENGTH"] = str(segment_length) managed_processes["loggerd"].start() managed_processes["encoderd"].start() - assert pm.wait_for_readers_to_update("roadCameraState", timeout=5) + assert pm.wait_for_readers_to_update("narrowRoadCameraState", timeout=5) fps = 20 for n in range(1, int(num_segs * segment_length * fps) + 1): @@ -192,7 +195,6 @@ class TestLoggerd: assert getattr(initData, initData_key) == v assert logged_params[param_key].decode() == v - @pytest.mark.xdist_group("camera_encoder_tests") # setting xdist group ensures tests are run in same worker, prevents encoderd from crashing def test_rotation(self): Params().put("RecordFront", True, block=True) @@ -233,7 +235,7 @@ class TestLoggerd: assert abs(boot.wallTimeNanos - time.time_ns()) < 5*1e9 # within 5s assert boot.launchLog == launch_log - if TICI: + if COMMA_HARDWARE: for fn in ["console-ramoops", "pmsg-ramoops-0"]: path = Path(os.path.join("/sys/fs/pstore/", fn)) if path.is_file(): @@ -277,7 +279,9 @@ class TestLoggerd: assert recv_cnt == 0, f"got {recv_cnt} {s} msgs in qlog" else: # check logged message count matches decimation - expected_cnt = (len(msgs) - 1) // SERVICE_LIST[s].decimation + 1 + decimation = SERVICE_LIST[s].decimation + assert decimation is not None + expected_cnt = (len(msgs) - 1) // decimation + 1 assert recv_cnt == expected_cnt, f"expected {expected_cnt} msgs for {s}, got {recv_cnt}" def test_rlog(self): @@ -305,25 +309,23 @@ class TestLoggerd: assert getxattr(segment_dir, PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE def test_not_preserving_nonbookmarked_segments(self): - services = set(random.sample(CEREAL_SERVICES, random.randint(5, 10))) - {"userBookmark", "audioFeedback"} + services = set(random.sample(CEREAL_SERVICES, random.randint(5, 10))) - {"userBookmark"} self._publish_random_messages(services) segment_dir = self._get_latest_log_dir() assert getxattr(segment_dir, PRESERVE_ATTR_NAME) is None - @pytest.mark.xdist_group("camera_encoder_tests") # setting xdist group ensures tests are run in same worker, prevents encoderd from crashing - @pytest.mark.parametrize("record_front", [True, False]) + @parameterized.expand([True, False]) def test_record_front(self, record_front): params = Params() params.put_bool("RecordFront", record_front, block=True) self._publish_camera_and_audio_messages() - dcamera_hevc_exists = os.path.exists(os.path.join(self._get_latest_log_dir(), 'dcamera.hevc')) - assert dcamera_hevc_exists == record_front + cabin_hevc_exists = os.path.exists(os.path.join(self._get_latest_log_dir(), 'dcamera.hevc')) + assert cabin_hevc_exists == record_front - @pytest.mark.xdist_group("camera_encoder_tests") # setting xdist group ensures tests are run in same worker, prevents encoderd from crashing - @pytest.mark.parametrize("record_audio", [True, False]) + @parameterized.expand([True, False]) def test_record_audio(self, record_audio): params = Params() params.put_bool("RecordAudio", record_audio, block=True) diff --git a/openpilot/system/loggerd/tests/test_runner.cc b/openpilot/system/loggerd/tests/test_runner.cc deleted file mode 100644 index 62bf7476a1..0000000000 --- a/openpilot/system/loggerd/tests/test_runner.cc +++ /dev/null @@ -1,2 +0,0 @@ -#define CATCH_CONFIG_MAIN -#include "catch2/catch.hpp" diff --git a/openpilot/system/loggerd/tests/test_uploader.py b/openpilot/system/loggerd/tests/test_uploader.py index eec85eda2e..b83b3569e2 100644 --- a/openpilot/system/loggerd/tests/test_uploader.py +++ b/openpilot/system/loggerd/tests/test_uploader.py @@ -1,5 +1,4 @@ import os -import time import threading import logging import json @@ -7,7 +6,7 @@ from pathlib import Path from openpilot.common.hardware.hw import Paths from openpilot.common.swaglog import cloudlog -from openpilot.system.loggerd.uploader import main, UPLOAD_ATTR_NAME, UPLOAD_ATTR_VALUE +from openpilot.system.loggerd.uploader import clear_locks, main, Uploader, UPLOAD_ATTR_NAME, UPLOAD_ATTR_VALUE from openpilot.system.loggerd.tests.loggerd_tests_common import UploaderTestCase @@ -15,29 +14,38 @@ from openpilot.system.loggerd.tests.loggerd_tests_common import UploaderTestCase class FakeLogHandler(logging.Handler): def __init__(self): logging.Handler.__init__(self) + self.condition = threading.Condition() self.reset() def reset(self): - self.upload_order = list() - self.upload_ignored = list() + with self.condition: + self.upload_order = [] + self.upload_ignored = [] def emit(self, record): try: j = json.loads(record.getMessage()) - if j["event"] == "upload_success": - self.upload_order.append(j["key"]) - if j["event"] == "upload_ignored": - self.upload_ignored.append(j["key"]) + with self.condition: + if j["event"] == "upload_success": + self.upload_order.append(j["key"]) + if j["event"] == "upload_ignored": + self.upload_ignored.append(j["key"]) + self.condition.notify_all() except Exception: pass + def wait_for_uploads(self, count: int, ignored: bool = False): + uploads = self.upload_ignored if ignored else self.upload_order + with self.condition: + assert self.condition.wait_for(lambda: len(uploads) >= count, timeout=1), "Uploader did not process all files" + log_handler = FakeLogHandler() cloudlog.addHandler(log_handler) class TestUploader(UploaderTestCase): def setup_method(self): - super().setup_method() + super().openpilot_setup_method() log_handler.reset() def start_thread(self): @@ -70,14 +78,12 @@ class TestUploader(UploaderTestCase): def test_upload(self): self.gen_files(lock=False) + exp_order = self.gen_order([self.seg_num], []) self.start_thread() - # allow enough time that files could upload twice if there is a bug in the logic - time.sleep(1) + log_handler.wait_for_uploads(len(exp_order)) self.join_thread() - exp_order = self.gen_order([self.seg_num], []) - assert len(log_handler.upload_ignored) == 0, "Some files were ignored" assert not len(log_handler.upload_order) < len(exp_order), "Some files failed to upload" assert not len(log_handler.upload_order) > len(exp_order), "Some files were uploaded twice" @@ -88,14 +94,12 @@ class TestUploader(UploaderTestCase): def test_upload_with_wrong_xattr(self): self.gen_files(lock=False, xattr=b'0') + exp_order = self.gen_order([self.seg_num], []) self.start_thread() - # allow enough time that files could upload twice if there is a bug in the logic - time.sleep(1) + log_handler.wait_for_uploads(len(exp_order)) self.join_thread() - exp_order = self.gen_order([self.seg_num], []) - assert len(log_handler.upload_ignored) == 0, "Some files were ignored" assert not len(log_handler.upload_order) < len(exp_order), "Some files failed to upload" assert not len(log_handler.upload_order) > len(exp_order), "Some files were uploaded twice" @@ -107,14 +111,12 @@ class TestUploader(UploaderTestCase): def test_upload_ignored(self): self.set_ignore() self.gen_files(lock=False) + exp_order = self.gen_order([self.seg_num], []) self.start_thread() - # allow enough time that files could upload twice if there is a bug in the logic - time.sleep(1) + log_handler.wait_for_uploads(len(exp_order), ignored=True) self.join_thread() - exp_order = self.gen_order([self.seg_num], []) - assert len(log_handler.upload_order) == 0, "Some files were not ignored" assert not len(log_handler.upload_ignored) < len(exp_order), "Some files failed to ignore" assert not len(log_handler.upload_ignored) > len(exp_order), "Some files were ignored twice" @@ -136,8 +138,7 @@ class TestUploader(UploaderTestCase): exp_order = self.gen_order(seg1_nums, seg2_nums, boot=False) self.start_thread() - # allow enough time that files could upload twice if there is a bug in the logic - time.sleep(1) + log_handler.wait_for_uploads(len(exp_order)) self.join_thread() assert len(log_handler.upload_ignored) == 0, "Some files were ignored" @@ -149,34 +150,30 @@ class TestUploader(UploaderTestCase): assert log_handler.upload_order == exp_order, "Files uploaded in wrong order" def test_no_upload_with_lock_file(self): - self.start_thread() - - time.sleep(0.25) f_paths = self.gen_files(lock=True, boot=False) - - # allow enough time that files should have been uploaded if they would be uploaded - time.sleep(1) - self.join_thread() + uploader = Uploader("0000000000000000", Paths.log_root()) for f_path in f_paths: fn = f_path.with_suffix(f_path.suffix.replace(".zst", "")) - uploaded = UPLOAD_ATTR_NAME in os.listxattr(fn) and os.getxattr(fn, UPLOAD_ATTR_NAME) == UPLOAD_ATTR_VALUE - assert not uploaded, "File upload when locked" + assert all(candidate[2] != str(fn) for candidate in uploader.list_upload_files(metered=False)), "Locked file selected for upload" def test_no_upload_with_xattr(self): - self.gen_files(lock=False, xattr=UPLOAD_ATTR_VALUE) + f_paths = self.gen_files(lock=False, xattr=UPLOAD_ATTR_VALUE) + uploader = Uploader("0000000000000000", Paths.log_root()) + upload_candidates = {candidate[2] for candidate in uploader.list_upload_files(metered=False)} + assert upload_candidates.isdisjoint(map(str, f_paths)), "Uploaded file selected again" - self.start_thread() - # allow enough time that files could upload twice if there is a bug in the logic - time.sleep(1) - self.join_thread() - - assert len(log_handler.upload_order) == 0, "File uploaded again" - - def test_clear_locks_on_startup(self): + def test_clear_locks_on_startup(self, mocker): f_paths = self.gen_files(lock=True, boot=False) + locks_cleared = threading.Event() + + def clear_locks_and_signal(root): + clear_locks(root) + locks_cleared.set() + + mocker.patch("openpilot.system.loggerd.uploader.clear_locks", side_effect=clear_locks_and_signal) self.start_thread() - time.sleep(0.25) + assert locks_cleared.wait(timeout=1), "Uploader did not clear locks on startup" self.join_thread() for f_path in f_paths: diff --git a/openpilot/system/loggerd/tests/test_zstd_writer.cc b/openpilot/system/loggerd/tests/test_zstd_writer.cc deleted file mode 100644 index 479e866a14..0000000000 --- a/openpilot/system/loggerd/tests/test_zstd_writer.cc +++ /dev/null @@ -1,44 +0,0 @@ -#include - -#include -#include -#include - -#include "common/util.h" -#include "system/loggerd/logger.h" -#include "system/loggerd/zstd_writer.h" - -TEST_CASE("ZstdFileWriter writes and compresses data correctly in loops", "[ZstdFileWriter]") { - const std::string filename = "test_zstd_file.zst"; - const int iterations = 100; - const size_t dataSize = 1024; - - std::string totalTestData; - - // Step 1: Write compressed data to file in a loop - { - ZstdFileWriter writer(filename, LOG_COMPRESSION_LEVEL); - // Write various data sizes including edge cases - std::vector testSizes = {dataSize, 1, 0, dataSize * 2}; // Normal, minimal, empty, large - for (int i = 0; i < iterations; ++i) { - size_t currentSize = testSizes[i % testSizes.size()]; - std::string testData = util::random_string(currentSize); - totalTestData.append(testData); - - writer.write((void *)testData.c_str(), testData.size()); - } - } - - // Step 2: Decompress the file and verify the data - auto compressedContent = util::read_file(filename); - REQUIRE(compressedContent.size() > 0); - REQUIRE(compressedContent.size() < totalTestData.size()); - std::string decompressedData = zstd_decompress(compressedContent); - - // Step 3: Verify that the decompressed data matches the original accumulated data - REQUIRE(decompressedData.size() == totalTestData.size()); - REQUIRE(std::memcmp(decompressedData.data(), totalTestData.c_str(), totalTestData.size()) == 0); - - // Clean up the test file - std::remove(filename.c_str()); -} diff --git a/openpilot/system/loggerd/uploader.py b/openpilot/system/loggerd/uploader.py index e36b12ed6a..81f8ed1ba3 100755 --- a/openpilot/system/loggerd/uploader.py +++ b/openpilot/system/loggerd/uploader.py @@ -46,9 +46,7 @@ class FakeResponse: def get_directory_sort(d: str) -> list[str]: - # ensure old format is sorted sooner - o = ["0", ] if d.startswith("2024-") else ["1", ] - return o + [s.rjust(10, '0') for s in d.rsplit('--', 1)] + return [s.rjust(10, '0') for s in d.rsplit('--', 1)] def listdir_by_creation(d: str) -> list[str]: if not os.path.isdir(d): diff --git a/openpilot/system/loggerd/video_writer.cc b/openpilot/system/loggerd/video_writer.cc index 1b47a8fceb..70f0199299 100644 --- a/openpilot/system/loggerd/video_writer.cc +++ b/openpilot/system/loggerd/video_writer.cc @@ -49,6 +49,11 @@ VideoWriter::VideoWriter(const char *path, const char *filename, bool remuxing, } } +void VideoWriter::set_metadata(const char *key, const char *value) { + assert(remuxing && !header_written); + av_dict_set(&ofmt_ctx->metadata, key, value, 0); +} + void VideoWriter::initialize_audio(int sample_rate) { assert(this->ofmt_ctx->oformat->audio_codec != AV_CODEC_ID_NONE); // check output format supports audio streams const AVCodec *audio_avcodec = avcodec_find_encoder(AV_CODEC_ID_AAC); @@ -106,7 +111,10 @@ void VideoWriter::write(uint8_t *data, int len, long long timestamp, bool codecc int err = avcodec_parameters_from_context(out_stream->codecpar, codec_ctx); assert(err >= 0); // if there is an audio stream, it must be initialized before this point - err = avformat_write_header(ofmt_ctx, NULL); + AVDictionary *options = nullptr; + if (ofmt_ctx->metadata) av_dict_set(&options, "movflags", "+faststart+use_metadata_tags", 0); + err = avformat_write_header(ofmt_ctx, &options); + av_dict_free(&options); assert(err >= 0); header_written = true; } else { diff --git a/openpilot/system/loggerd/video_writer.h b/openpilot/system/loggerd/video_writer.h index fdec606058..1120d94a78 100644 --- a/openpilot/system/loggerd/video_writer.h +++ b/openpilot/system/loggerd/video_writer.h @@ -13,6 +13,7 @@ extern "C" { class VideoWriter { public: VideoWriter(const char *path, const char *filename, bool remuxing, int width, int height, int fps, cereal::EncodeIndex::Type codec); + void set_metadata(const char *key, const char *value); void write(uint8_t *data, int len, long long timestamp, bool codecconfig, bool keyframe); void write_audio(uint8_t *data, int len, long long timestamp, int sample_rate); diff --git a/openpilot/system/loggerd/xattr_cache.py b/openpilot/system/loggerd/xattr_cache.py index 39bb172059..e0f6ba9588 100644 --- a/openpilot/system/loggerd/xattr_cache.py +++ b/openpilot/system/loggerd/xattr_cache.py @@ -1,6 +1,53 @@ +import ctypes import errno +import os +import sys -import xattr + +if sys.platform == "darwin": + _libc = ctypes.CDLL(None, use_errno=True) + _libc.getxattr.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint32, ctypes.c_int] + _libc.getxattr.restype = ctypes.c_ssize_t + _libc.setxattr.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint32, ctypes.c_int] + _libc.setxattr.restype = ctypes.c_int + + +def _raise_os_error(path: str) -> None: + error = ctypes.get_errno() + raise OSError(error, os.strerror(error), path) + + +def _getxattr(path: str, attr_name: str) -> bytes: + if sys.platform != "darwin": + return os.getxattr(path, attr_name) + + encoded_path = os.fsencode(path) + encoded_attr_name = os.fsencode(attr_name) + while True: + size = _libc.getxattr(encoded_path, encoded_attr_name, None, 0, 0, 0) + if size == -1: + _raise_os_error(path) + if size == 0: + return b"" + + value = ctypes.create_string_buffer(size) + result = _libc.getxattr(encoded_path, encoded_attr_name, value, size, 0, 0) + if result != -1: + return value.raw[:result] + if ctypes.get_errno() != errno.ERANGE: + _raise_os_error(path) + + +def _setxattr(path: str, attr_name: str, attr_value: bytes) -> None: + if sys.platform != "darwin": + os.setxattr(path, attr_name, attr_value) + return + + encoded_path = os.fsencode(path) + encoded_attr_name = os.fsencode(attr_name) + value = ctypes.create_string_buffer(attr_value) + if _libc.setxattr(encoded_path, encoded_attr_name, value, len(attr_value), 0, 0) == -1: + _raise_os_error(path) _cached_attributes: dict[tuple, bytes | None] = {} @@ -8,7 +55,7 @@ def getxattr(path: str, attr_name: str) -> bytes | None: key = (path, attr_name) if key not in _cached_attributes: try: - response = xattr.getxattr(path, attr_name) + response = _getxattr(path, attr_name) except OSError as e: # ENODATA (Linux) or ENOATTR (macOS) means attribute hasn't been set if e.errno == errno.ENODATA or (hasattr(errno, 'ENOATTR') and e.errno == errno.ENOATTR): @@ -20,4 +67,4 @@ def getxattr(path: str, attr_name: str) -> bytes | None: def setxattr(path: str, attr_name: str, attr_value: bytes) -> None: _cached_attributes.pop((path, attr_name), None) - xattr.setxattr(path, attr_name, attr_value) + _setxattr(path, attr_name, attr_value) diff --git a/openpilot/system/manager/build.py b/openpilot/system/manager/build.py index 75a7dc63eb..c5fdd6d92a 100755 --- a/openpilot/system/manager/build.py +++ b/openpilot/system/manager/build.py @@ -50,6 +50,8 @@ def build() -> None: if scons.returncode == 0: break + os.sync() + if scons.returncode != 0: # Build failed log errors error_s = b"\n".join(compile_output).decode('utf8', 'replace') diff --git a/openpilot/system/manager/github_runner.sh b/openpilot/system/manager/github_runner.sh deleted file mode 100755 index f2170cfc70..0000000000 --- a/openpilot/system/manager/github_runner.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash - -# Define the service name -SERVICE_NAME="actions.runner.sunnypilot.$(uname -n)" - -# Function to control the service -control_service() { - local action=$1 # Store the function argument in a local variable - sudo systemctl $action ${SERVICE_NAME} -} - -service_exists_and_is_loaded() { - sudo systemctl status ${SERVICE_NAME} &>/dev/null - if [[ $? -ne 4 ]]; then - return 0 # Service is known to systemd (i.e., loaded) - else - return 1 # Service is unknown to systemd (i.e., not loaded) - fi -} - -# Check for required argument -if [[ -z $1 ]] || { [[ $1 != "start" ]] && [[ $1 != "stop" ]]; }; then - echo "Usage: $0 {start|stop}" - exit 1 -fi - -# Store the script argument in a descriptive variable -ACTION=$1 - -# Trap EXIT signal (Ctrl+C) and stop the service -trap 'control_service stop ; exit' SIGINT SIGKILL EXIT - -# Enter the main loop -while true; do - # Check if the service is actually present on the system - if service_exists_and_is_loaded; then - control_service $ACTION # Call the function with the specified action - fi - sleep 1 # Pause before the next iteration -done \ No newline at end of file diff --git a/openpilot/system/manager/manager.py b/openpilot/system/manager/manager.py index 88cb7f6b14..7befb7f4ae 100755 --- a/openpilot/system/manager/manager.py +++ b/openpilot/system/manager/manager.py @@ -104,11 +104,6 @@ def manager_init() -> None: dirty=build_metadata.openpilot.is_dirty, device=HARDWARE.get_device_type()) - # preimport all processes - for p in managed_processes.values(): - p.prepare() - - def manager_cleanup() -> None: # send signals to kill all procs for p in managed_processes.values(): diff --git a/openpilot/system/manager/process.py b/openpilot/system/manager/process.py index a0b878d0f2..43ea3f7306 100644 --- a/openpilot/system/manager/process.py +++ b/openpilot/system/manager/process.py @@ -68,20 +68,11 @@ class ManagerProcess(ABC): enabled = True name = "" shutting_down = False - restart_if_crash = False - - @abstractmethod - def prepare(self) -> None: - pass @abstractmethod def start(self) -> None: pass - def restart(self) -> None: - self.stop(sig=signal.SIGKILL) - self.start() - def stop(self, retry: bool = True, block: bool = True, sig: signal.Signals | None = None) -> int | None: if self.proc is None: return None @@ -150,9 +141,6 @@ class NativeProcess(ManagerProcess): self.sigkill = sigkill self.launcher = nativelauncher - def prepare(self) -> None: - pass - def start(self) -> None: # In case we only tried a non blocking stop we need to stop it before restarting if self.shutting_down: @@ -169,19 +157,13 @@ class NativeProcess(ManagerProcess): class PythonProcess(ManagerProcess): - def __init__(self, name, module, should_run, enabled=True, sigkill=False, restart_if_crash=False): + def __init__(self, name, module, should_run, enabled=True, sigkill=False): self.name = name self.module = module self.should_run = should_run self.enabled = enabled self.sigkill = sigkill self.launcher = launcher - self.restart_if_crash = restart_if_crash - - def prepare(self) -> None: - if self.enabled: - cloudlog.info(f"preimporting {self.module}") - importlib.import_module(self.module) def start(self) -> None: # In case we only tried a non blocking stop we need to stop it before restarting @@ -211,9 +193,6 @@ class DaemonProcess(ManagerProcess): def should_run(started, params, CP): return True - def prepare(self) -> None: - pass - def start(self) -> None: if self.params is None: self.params = Params() @@ -243,7 +222,7 @@ class DaemonProcess(ManagerProcess): pass -def ensure_running(procs: ValuesView[ManagerProcess], started: bool, params=None, CP: car.CarParams=None, +def ensure_running(procs: ValuesView[ManagerProcess], started: bool, params: Params, CP: car.CarParams, not_run: list[str] | None=None) -> list[ManagerProcess]: if not_run is None: not_run = [] @@ -251,9 +230,6 @@ def ensure_running(procs: ValuesView[ManagerProcess], started: bool, params=None running = [] for p in procs: if p.enabled and p.name not in not_run and p.should_run(started, params, CP): - if p.restart_if_crash and p.proc is not None and not p.proc.is_alive(): - cloudlog.error(f'Restarting {p.name} (exitcode {p.proc.exitcode})') - p.restart() running.append(p) else: p.stop(block=False) diff --git a/openpilot/system/manager/process_config.py b/openpilot/system/manager/process_config.py index a72675385d..f776a85923 100644 --- a/openpilot/system/manager/process_config.py +++ b/openpilot/system/manager/process_config.py @@ -5,7 +5,7 @@ import platform from opendbc.car.structs import car from openpilot.cereal import custom from openpilot.common.params import Params -from openpilot.common.hardware import PC, TICI +from openpilot.common.hardware import PC, COMMA_HARDWARE from openpilot.system.manager.process import PythonProcess, NativeProcess, DaemonProcess from openpilot.common.hardware.hw import Paths @@ -68,10 +68,6 @@ def only_offroad(started: bool, params: Params, CP: car.CarParams) -> bool: def livestream(started: bool, params: Params, CP: car.CarParams) -> bool: return params.get_bool("IsLiveStreaming") -def use_github_runner(started, params, CP: car.CarParams) -> bool: - return not PC and params.get_bool("EnableGithubRunner") and ( - not params.get_bool("NetworkMetered") and not params.get_bool("GithubRunnerSufficientVoltage")) - def use_copyparty(started, params, CP: car.CarParams) -> bool: return bool(params.get_bool("EnableCopyparty")) @@ -88,7 +84,7 @@ def use_sunnylink_uploader_shim(started, params, CP: car.CarParams) -> bool: return use_sunnylink_uploader(params) def is_tinygrad_model(started, params, CP: car.CarParams) -> bool: - """Check if the active model runner is SNPE.""" + """Check if the active model runner is tinygrad.""" return bool(get_active_model_runner(params, not started) == custom.ModelManagerSP.Runner.tinygrad) def is_stock_model(started, params, CP: car.CarParams) -> bool: @@ -110,15 +106,12 @@ def or_(*fns): def and_(*fns): return lambda *args: operator.and_(*(fn(*args) for fn in fns)) -def not_(*fns): - return lambda *args: operator.not_(*(fn(*args) for fn in fns)) - procs = [ DaemonProcess("manage_athenad", "openpilot.system.athena.manage_athenad", "AthenadPid"), NativeProcess("loggerd", "openpilot/system/loggerd", ["./loggerd"], logging), NativeProcess("encoderd", "openpilot/system/loggerd", ["./encoderd"], only_onroad), - NativeProcess("stream_encoderd", "openpilot/system/loggerd", ["./encoderd", "--stream"], or_(and_(livestream, not_(iscar)), notcar)), + NativeProcess("stream_encoderd", "openpilot/system/loggerd", ["./encoderd", "--stream"], or_(livestream, notcar)), PythonProcess("logmessaged", "openpilot.system.logmessaged", always_run), NativeProcess("camerad", "openpilot/system/camerad", ["./camerad"], or_(driverview, livestream), enabled=not WEBCAM), @@ -132,7 +125,7 @@ procs = [ PythonProcess("dmonitoringmodeld", "openpilot.selfdrive.modeld.dmonitoringmodeld", driverview, enabled=(WEBCAM or not PC)), PythonProcess("sensord", "openpilot.system.sensord.sensord", only_onroad, enabled=not PC), - PythonProcess("ui", "openpilot.selfdrive.ui.ui", always_run, restart_if_crash=True), + PythonProcess("ui", "openpilot.selfdrive.ui.ui", always_run), PythonProcess("soundd", "openpilot.selfdrive.ui.soundd", driverview), PythonProcess("locationd", "openpilot.selfdrive.locationd.locationd", only_onroad), NativeProcess("_pandad", "openpilot/selfdrive/pandad", ["./pandad"], always_run, enabled=False), @@ -144,27 +137,26 @@ procs = [ PythonProcess("card", "openpilot.selfdrive.car.card", only_onroad), PythonProcess("deleter", "openpilot.system.loggerd.deleter", always_run), PythonProcess("dmonitoringd", "openpilot.selfdrive.monitoring.dmonitoringd", driverview, enabled=(WEBCAM or not PC)), - PythonProcess("qcomgpsd", "openpilot.system.qcomgpsd.qcomgpsd", qcomgps, enabled=TICI), + PythonProcess("qcomgpsd", "openpilot.system.qcomgpsd.qcomgpsd", qcomgps, enabled=COMMA_HARDWARE), PythonProcess("pandad", "openpilot.selfdrive.pandad.pandad", always_run), PythonProcess("paramsd", "openpilot.selfdrive.locationd.paramsd", only_onroad), PythonProcess("lagd", "openpilot.selfdrive.locationd.lagd", only_onroad), - PythonProcess("ubloxd", "openpilot.system.ubloxd.ubloxd", ublox, enabled=TICI), - PythonProcess("pigeond", "openpilot.system.ubloxd.pigeond", ublox, enabled=TICI), + PythonProcess("ubloxd", "openpilot.system.ubloxd.ubloxd", ublox, enabled=COMMA_HARDWARE), + PythonProcess("pigeond", "openpilot.system.ubloxd.pigeond", ublox, enabled=COMMA_HARDWARE), PythonProcess("plannerd", "openpilot.selfdrive.controls.plannerd", not_long_maneuver), PythonProcess("maneuversd", "openpilot.tools.longitudinal_maneuvers.maneuversd", long_maneuver), PythonProcess("lateral_maneuversd", "openpilot.tools.lateral_maneuvers.lateral_maneuversd", lat_maneuver), PythonProcess("radard", "openpilot.selfdrive.controls.radard", only_onroad), PythonProcess("hardwared", "openpilot.system.hardware.hardwared", always_run), - PythonProcess("modem", "openpilot.common.hardware.tici.modem", always_run, enabled=TICI), + PythonProcess("modem", "openpilot.common.hardware.comma.modem", always_run, enabled=COMMA_HARDWARE), PythonProcess("tombstoned", "openpilot.system.tombstoned", always_run, enabled=not PC), PythonProcess("updated", "openpilot.system.updated.updated", only_offroad, enabled=not PC), PythonProcess("uploader", "openpilot.system.loggerd.uploader", uploader_ready), PythonProcess("statsd", "openpilot.sunnypilot.system.statsd", always_run), - PythonProcess("feedbackd", "openpilot.selfdrive.ui.feedback.feedbackd", only_onroad), # debug procs NativeProcess("bridge", "openpilot/cereal/messaging", ["./bridge"], notcar), - PythonProcess("webrtcd", "openpilot.system.webrtc.webrtcd", or_(and_(livestream, not_(iscar)), notcar)), + PythonProcess("webrtcd", "openpilot.system.webrtc.webrtcd", or_(livestream, notcar)), PythonProcess("joystick", "openpilot.tools.joystick.joystick_control", and_(joystick, iscar)), # sunnylink <3 @@ -190,10 +182,6 @@ procs += [ NativeProcess("locationd_llk", "openpilot/sunnypilot/selfdrive/locationd", ["./locationd"], only_onroad), ] -if os.path.exists("./github_runner.sh"): - procs += [NativeProcess("github_runner_start", "openpilot/system/manager", - ["./github_runner.sh", "start"], and_(only_offroad, use_github_runner), sigkill=False)] - if os.path.exists("../../sunnypilot/sunnylink/uploader.py"): procs += [PythonProcess("sunnylink_uploader", "openpilot.sunnypilot.sunnylink.uploader", use_sunnylink_uploader_shim)] diff --git a/openpilot/system/manager/test/test_manager.py b/openpilot/system/manager/test/test_manager.py old mode 100644 new mode 100755 index a3808bf29f..54bdaec6fe --- a/openpilot/system/manager/test/test_manager.py +++ b/openpilot/system/manager/test/test_manager.py @@ -1,9 +1,12 @@ +#!/usr/bin/env python3 + import os -import pytest +import unittest import signal import time from opendbc.car.structs import car +from openpilot.common.test import OpenpilotTestCase from openpilot.common.params import Params import openpilot.system.manager.manager as manager from openpilot.system.manager.process import ensure_running @@ -16,7 +19,7 @@ MAX_STARTUP_TIME = 3 BLACKLIST_PROCS = ['manage_athenad', 'pandad', 'pigeond'] -class TestManager: +class TestManager(OpenpilotTestCase): def setup_method(self): HARDWARE.set_power_save(False) @@ -47,7 +50,7 @@ class TestManager: assert params.get("OpenpilotEnabledToggle") assert params.get("RouteCount") == 0 - @pytest.mark.skip("this test is flaky the way it's currently written, should be moved to test_onroad") + @unittest.skip("this test is flaky the way it's currently written, should be moved to test_onroad") def test_clean_exit(self, subtests): """ Ensure all processes exit cleanly when stopped. @@ -75,3 +78,7 @@ class TestManager: if p.sigkill: exit_codes = [-signal.SIGKILL] assert exit_code in exit_codes, f"{p.name} died with {exit_code}" + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/system/micd.py b/openpilot/system/micd.py index a3a93c8c1a..7167aad137 100755 --- a/openpilot/system/micd.py +++ b/openpilot/system/micd.py @@ -15,6 +15,14 @@ SAMPLE_RATE = 16000 SAMPLE_BUFFER = 800 # 50ms +def patch_sounddevice(sd): + # TODO: remove once sounddevice uses np.reshape internally. + def sounddevice_array(buffer, channels, dtype): + return np.frombuffer(buffer, dtype=dtype).reshape(-1, channels) + + sd._array = sounddevice_array + + @cache def get_a_weighting_filter(): # Calculate the A-weighting filter @@ -104,6 +112,7 @@ class Mic: def micd_thread(self): # sounddevice must be imported after forking processes import sounddevice as sd + patch_sounddevice(sd) with self.get_stream(sd) as stream: cloudlog.info(f"micd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}") diff --git a/openpilot/system/qcomgpsd/nmeaport.py b/openpilot/system/qcomgpsd/nmeaport.py index 0e695b65e5..e270e23a3a 100644 --- a/openpilot/system/qcomgpsd/nmeaport.py +++ b/openpilot/system/qcomgpsd/nmeaport.py @@ -1,6 +1,6 @@ import os import sys -from dataclasses import dataclass, fields +from dataclasses import dataclass from subprocess import check_output, CalledProcessError from time import sleep from typing import NoReturn @@ -17,26 +17,27 @@ class GnssClockNmeaPort: # 0x10 = bias_uncertainty_ns valid # 0x20 = drift_nsps valid # 0x40 = drift_uncertainty_nsps valid - flags: int - leap_seconds: int - time_ns: int - time_uncertainty_ns: int # 1-sigma - full_bias_ns: int - bias_ns: float - bias_uncertainty_ns: float # 1-sigma - drift_nsps: float - drift_uncertainty_nsps: float # 1-sigma + flags: int | None + leap_seconds: int | None + time_ns: int | None + time_uncertainty_ns: int | None # 1-sigma + full_bias_ns: int | None + bias_ns: float | None + bias_uncertainty_ns: float | None # 1-sigma + drift_nsps: float | None + drift_uncertainty_nsps: float | None # 1-sigma - def __post_init__(self): - for field in fields(self): - val = getattr(self, field.name) - setattr(self, field.name, field.type(val) if val else None) + @classmethod + def from_fields(cls, values: list[str]) -> 'GnssClockNmeaPort': + ints = [int(value) if value else None for value in values[:5]] + floats = [float(value) if value else None for value in values[5:9]] + return cls(*ints, *floats) @dataclass class GnssMeasNmeaPort: - messageCount: int - messageNum: int - svCount: int + messageCount: int | None + messageNum: int | None + svCount: int | None # constellation enum: # 1 = GPS # 2 = SBAS @@ -44,10 +45,10 @@ class GnssMeasNmeaPort: # 4 = QZSS # 5 = BEIDOU # 6 = GALILEO - constellation: int - svId: int - flags: int # always zero - time_offset_ns: int + constellation: int | None + svId: int | None + flags: int | None # always zero + time_offset_ns: int | None # state bit mask: # 0x0001 = CODE LOCK # 0x0002 = BIT SYNC @@ -63,17 +64,18 @@ class GnssMeasNmeaPort: # 0x0800 = GALILEO E1C 2ND CODE LOCK # 0x1000 = GALILEO E1B PAGE SYNC # 0x2000 = GALILEO E1B PAGE SYNC - state: int - time_of_week_ns: int - time_of_week_uncertainty_ns: int # 1-sigma - carrier_to_noise_ratio: float - pseudorange_rate: float - pseudorange_rate_uncertainty: float # 1-sigma + state: int | None + time_of_week_ns: int | None + time_of_week_uncertainty_ns: int | None # 1-sigma + carrier_to_noise_ratio: float | None + pseudorange_rate: float | None + pseudorange_rate_uncertainty: float | None # 1-sigma - def __post_init__(self): - for field in fields(self): - val = getattr(self, field.name) - setattr(self, field.name, field.type(val) if val else None) + @classmethod + def from_fields(cls, values: list[str]) -> 'GnssMeasNmeaPort': + ints = [int(value) if value else None for value in values[:10]] + floats = [float(value) if value else None for value in values[10:13]] + return cls(*ints, *floats) def nmea_checksum_ok(s): checksum = 0 @@ -107,11 +109,11 @@ def process_nmea_port_messages(device:str="/dev/ttyUSB1") -> NoReturn: match fields[0]: case "$GNCLK": # fields at end are reserved (not used) - gnss_clock = GnssClockNmeaPort(*fields[1:10]) + gnss_clock = GnssClockNmeaPort.from_fields(fields[1:10]) print(gnss_clock) case "$GNMEAS": # fields at end are reserved (not used) - gnss_meas = GnssMeasNmeaPort(*fields[1:14]) + gnss_meas = GnssMeasNmeaPort.from_fields(fields[1:14]) print(gnss_meas) except Exception as e: print(e) @@ -119,7 +121,7 @@ def process_nmea_port_messages(device:str="/dev/ttyUSB1") -> NoReturn: def main() -> NoReturn: from openpilot.common.gpio import gpio_init, gpio_set - from openpilot.common.hardware.tici.pins import GPIO + from openpilot.common.hardware.comma.pins import GPIO from openpilot.system.qcomgpsd.qcomgpsd import at_cmd try: diff --git a/openpilot/system/qcomgpsd/qcomgpsd.py b/openpilot/system/qcomgpsd/qcomgpsd.py index 4e52154419..6261b18d56 100755 --- a/openpilot/system/qcomgpsd/qcomgpsd.py +++ b/openpilot/system/qcomgpsd/qcomgpsd.py @@ -15,7 +15,7 @@ import openpilot.cereal.messaging as messaging from openpilot.common.gpio import gpio_init, gpio_set from openpilot.common.utils import retry from openpilot.common.time_helpers import system_time_valid -from openpilot.common.hardware.tici.pins import GPIO +from openpilot.common.hardware.comma.pins import GPIO from openpilot.common.serial import Serial from openpilot.common.swaglog import cloudlog from openpilot.system.qcomgpsd.modemdiag import ModemDiag, DIAG_LOG_F, setup_logs, send_recv diff --git a/openpilot/system/sensord/tests/test_sensord.py b/openpilot/system/sensord/tests/test_sensord.py old mode 100644 new mode 100755 index fc4e3061bc..ddbef7895b --- a/openpilot/system/sensord/tests/test_sensord.py +++ b/openpilot/system/sensord/tests/test_sensord.py @@ -1,10 +1,13 @@ +#!/usr/bin/env python3 + import os import subprocess -import pytest import time +import unittest import numpy as np from collections import namedtuple, defaultdict +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.cereal.services import SERVICE_LIST from openpilot.common.gpio import get_irqs_for_action @@ -20,7 +23,7 @@ SENSOR_CONFIGS = ( ) SENSOR_CONFIGS_BY_MEASUREMENT = {config.measurement: config for config in SENSOR_CONFIGS} -def get_irq_count(irq: int): +def get_irq_count(irq: str): with open(f"/sys/kernel/irq/{irq}/per_cpu_count") as f: per_cpu = map(int, f.read().split(",")) return sum(per_cpu) @@ -54,8 +57,8 @@ def iter_measurements(events): for measurement in msgs: yield measurement, getattr(measurement, measurement.which()) -@pytest.mark.tici -class TestSensord: +class TestSensord(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True @classmethod def setup_class(cls): # enable LSM self test @@ -183,3 +186,7 @@ class TestSensord: time.sleep(1) state_two = get_irq_count(self.sensord_irq) assert state_one == state_two, "Interrupts received after sensord stop!" + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/system/tests/test_logmessaged.py b/openpilot/system/tests/test_logmessaged.py index e2637fb0e6..247bfd8a42 100644 --- a/openpilot/system/tests/test_logmessaged.py +++ b/openpilot/system/tests/test_logmessaged.py @@ -2,13 +2,14 @@ import glob import os import time +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.system.manager.process_config import managed_processes from openpilot.common.hardware.hw import Paths from openpilot.common.swaglog import cloudlog, ipchandler -class TestLogmessaged: +class TestLogmessaged(OpenpilotTestCase): def setup_method(self): # clear the IPC buffer in case some other tests used cloudlog and filled it ipchandler.close() @@ -52,4 +53,3 @@ class TestLogmessaged: logsize = sum([os.path.getsize(f) for f in self._get_log_files()]) assert (n*len(msg)) < logsize < (n*(len(msg)+1024)) - diff --git a/openpilot/system/timed.py b/openpilot/system/timed.py index 0413f645e3..6e5200d10e 100755 --- a/openpilot/system/timed.py +++ b/openpilot/system/timed.py @@ -12,7 +12,7 @@ from openpilot.common.gps import get_gps_location_service def set_time(new_time): - diff = datetime.datetime.now() - new_time + diff = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - new_time if abs(diff) < datetime.timedelta(seconds=10): cloudlog.debug(f"Time diff too small: {diff}") return @@ -47,7 +47,7 @@ def main() -> NoReturn: pm.send('clocks', msg) gps = sm[gps_location_service] - gps_time = datetime.datetime.fromtimestamp(gps.unixTimestampMillis / 1000.) + gps_time = datetime.datetime.fromtimestamp(gps.unixTimestampMillis / 1000., datetime.UTC).replace(tzinfo=None) if not sm.updated[gps_location_service] or (time.monotonic() - sm.logMonoTime[gps_location_service] / 1e9) > 2.0: continue if not gps.hasFix: diff --git a/openpilot/system/ubloxd/binary_struct.py b/openpilot/system/ubloxd/binary_struct.py index c144bd5696..5bc05094f3 100644 --- a/openpilot/system/ubloxd/binary_struct.py +++ b/openpilot/system/ubloxd/binary_struct.py @@ -184,7 +184,7 @@ class BinaryStruct: setattr(obj, name, value) return obj - cls._read = _read + cls._read = _read # ty: ignore[invalid-assignment] # installed dynamically for each subclass @classmethod def from_bytes(cls: type[T], data: bytes) -> T: diff --git a/openpilot/system/ubloxd/pigeond.py b/openpilot/system/ubloxd/pigeond.py index 9891285465..08ed568d43 100755 --- a/openpilot/system/ubloxd/pigeond.py +++ b/openpilot/system/ubloxd/pigeond.py @@ -3,18 +3,20 @@ import sys import time import signal import struct +import threading import requests import urllib.parse from datetime import datetime, UTC -from openpilot.cereal import messaging +from openpilot.cereal import log, messaging +from openpilot.common.api import Api from openpilot.common.time_helpers import system_time_valid from openpilot.common.params import Params from openpilot.common.serial import Serial from openpilot.common.swaglog import cloudlog -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.common.gpio import gpio_init, gpio_set -from openpilot.common.hardware.tici.pins import GPIO +from openpilot.common.hardware.comma.pins import GPIO UBLOX_TTY = "/dev/ttyHS0" @@ -41,15 +43,23 @@ def add_ubx_checksum(msg: bytes) -> bytes: B = (B + A) % 256 return msg + bytes([A, B]) -def get_assistnow_messages(token: str) -> list[bytes]: - # make request - # TODO: implement adding the last known location - r = requests.get("https://online-live2.services.u-blox.com/GetOnlineData.ashx", params=urllib.parse.urlencode({ - 'token': token, - 'gnss': 'gps,glo', - 'datatype': 'eph,alm,aux', - }, safe=':,'), timeout=5) - assert r.status_code == 200, "Got invalid status code" +def get_assistnow_messages() -> list[bytes]: + params = Params() + if token := params.get('AssistNowToken'): + cloudlog.warning("Downloading AssistNow data directly from u-blox") + r = requests.get("https://online-live2.services.u-blox.com/GetOnlineData.ashx", params=urllib.parse.urlencode({ + 'token': token, + 'gnss': 'gps,glo', + 'datatype': 'eph,alm,aux', + }, safe=':,'), timeout=5) + elif dongle_id := params.get('DongleId'): + cloudlog.warning("Downloading AssistNow data from comma's AGPS proxy") + api = Api(dongle_id) + r = api.get(f"v1/{dongle_id}/assist", access_token=api.get_token(), timeout=5) + else: + raise RuntimeError("Neither AssistNowToken nor DongleId is configured") + + r.raise_for_status() dat = r.content # split up messages @@ -230,16 +240,6 @@ def init_pigeon(pigeon: TTYPigeon) -> bool: )) pigeon.send_with_ack(msg, ack=UBLOX_ASSIST_ACK) - # try getting AssistNow if we have a token - token = Params().get('AssistNowToken') - if token is not None: - try: - for msg in get_assistnow_messages(token): - pigeon.send_with_ack(msg, ack=UBLOX_ASSIST_ACK) - cloudlog.warning("AssistNow messages sent") - except Exception: - cloudlog.warning("failed to get AssistNow messages") - cloudlog.warning("Pigeon GPS on!") break except TimeoutError: @@ -279,12 +279,38 @@ def run_receiving(duration: int = 0): start_time = time.monotonic() last_almanac_save = time.monotonic() + assist_attempted = False + assist_messages = None + + def download_assistnow() -> None: + nonlocal assist_messages + sm = messaging.SubMaster(['deviceState']) + while assist_messages is None: + sm.update(1000) + if system_time_valid() and sm['deviceState'].networkType != log.DeviceState.NetworkType.none: + try: + assist_messages = get_assistnow_messages() + except Exception: + cloudlog.warning("failed to get AssistNow messages") + time.sleep(10.) + threading.Thread(target=download_assistnow, daemon=True).start() + while (duration == 0) or (time.monotonic() - start_time < duration): + if assist_messages is not None and not assist_attempted: + assist_attempted = True + try: + for msg in assist_messages: + pigeon.send_with_ack(msg, ack=UBLOX_ASSIST_ACK) + cloudlog.warning("AssistNow messages sent") + except Exception: + cloudlog.warning("failed to send AssistNow messages") + dat = pigeon.receive() if len(dat) > 0: if dat[0] == 0x00: cloudlog.warning("received invalid data from ublox, re-initing!") init(pigeon) + assist_attempted = False continue # send out to socket @@ -302,7 +328,7 @@ def run_receiving(duration: int = 0): def main(): - assert TICI, "unsupported hardware for pigeond" + assert COMMA_HARDWARE, "unsupported hardware for pigeond" run_receiving() if __name__ == "__main__": diff --git a/openpilot/system/ubloxd/tests/test_pigeond.py b/openpilot/system/ubloxd/tests/test_pigeond.py index b894ed718b..0d2f5b00e7 100644 --- a/openpilot/system/ubloxd/tests/test_pigeond.py +++ b/openpilot/system/ubloxd/tests/test_pigeond.py @@ -1,17 +1,17 @@ -import pytest import time +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.cereal.services import SERVICE_LIST from openpilot.common.gpio import gpio_read from openpilot.selfdrive.test.helpers import with_processes from openpilot.system.manager.process_config import managed_processes -from openpilot.common.hardware.tici.pins import GPIO +from openpilot.common.hardware.comma.pins import GPIO # TODO: test TTFF when we have good A-GNSS -@pytest.mark.tici -class TestPigeond: +class TestPigeond(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True def teardown_method(self): managed_processes['pigeond'].stop() diff --git a/openpilot/system/ui/lib/application.py b/openpilot/system/ui/lib/application.py index 44bfa63b08..0db4878d94 100644 --- a/openpilot/system/ui/lib/application.py +++ b/openpilot/system/ui/lib/application.py @@ -19,7 +19,7 @@ from typing import NamedTuple from importlib.resources import as_file, files from openpilot.common.swaglog import cloudlog from openpilot.common.hardware import HARDWARE, PC -from openpilot.system.ui.lib.multilang import multilang +from openpilot.system.ui.lib.multilang import FONT_FALLBACK_LANGUAGES, TRANSLATIONS_DIR, multilang from openpilot.common.realtime import Ratekeeper from openpilot.system.ui.sunnypilot.lib.application import GuiApplicationExt @@ -94,26 +94,34 @@ FONT_SCALE = 1.242 if BIG_UI else 1.16 ASSETS_DIR = files("openpilot.selfdrive").joinpath("assets") FONT_DIR = ASSETS_DIR.joinpath("fonts") +EXTRA_FONT_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥" +NOTO_FONTS = { + "ja": "NotoSansCJKjp-Regular.otf", + "ko": "NotoSansCJKkr-Regular.otf", + "th": "NotoSansThai-Regular.ttf", + "zh-CHS": "NotoSansCJKsc-Regular.otf", + "zh-CHT": "NotoSansCJKtc-Regular.otf", +} class FontWeight(StrEnum): - NORMAL = "Inter-Regular.fnt" if BIG_UI else "Inter-Medium.fnt" - MEDIUM = "Inter-Medium.fnt" - BOLD = "Inter-Bold.fnt" - SEMI_BOLD = "Inter-SemiBold.fnt" - UNIFONT = "unifont.fnt" - AUDIOWIDE = "Audiowide-Regular.fnt" + NORMAL = "Inter-Regular.ttf" if BIG_UI else "Inter-Medium.ttf" + MEDIUM = "Inter-Medium.ttf" + BOLD = "Inter-Bold.ttf" + SEMI_BOLD = "Inter-SemiBold.ttf" + UNIFONT = "unifont.otf" + AUDIOWIDE = "Audiowide-Regular.ttf" # Small UI fonts - DISPLAY_REGULAR = "Inter-Regular.fnt" - ROMAN = "Inter-Regular.fnt" - DISPLAY = "Inter-Bold.fnt" + DISPLAY_REGULAR = "Inter-Regular.ttf" + ROMAN = "Inter-Regular.ttf" + DISPLAY = "Inter-Bold.ttf" def font_fallback(font: rl.Font) -> rl.Font: - """Fall back to unifont for languages that require it.""" - if multilang.requires_unifont(): - return gui_app.font(FontWeight.UNIFONT) + """Use a Noto fallback for languages not covered by Inter.""" + if multilang.requires_font_fallback(): + return gui_app.fallback_font() return font @@ -201,6 +209,7 @@ class GuiApplication(GuiApplicationExt): self._set_log_callback() self._fonts: dict[FontWeight, rl.Font] = {} + self._fallback_fonts: dict[str, rl.Font] = {} self._width = width if width is not None else GuiApplication._default_width() self._height = height if height is not None else GuiApplication._default_height() @@ -558,6 +567,9 @@ class GuiApplication(GuiApplicationExt): for font in self._fonts.values(): rl.unload_font(font) self._fonts = {} + for font in self._fallback_fonts.values(): + rl.unload_font(font) + self._fallback_fonts = {} if self._render_texture is not None: rl.unload_render_texture(self._render_texture) @@ -683,6 +695,21 @@ class GuiApplication(GuiApplicationExt): def font(self, font_weight: FontWeight = FontWeight.NORMAL) -> rl.Font: return self._fonts[font_weight] + def fallback_font(self) -> rl.Font: + language = multilang.language + if language not in self._fallback_fonts: + chars = set(map(chr, range(32, 127))) | set(EXTRA_FONT_CHARS) + chars.update(TRANSLATIONS_DIR.joinpath(f"app_{language}.po").read_text(encoding="utf-8")) + codepoints = sorted(map(ord, chars)) + codepoint_buffer = rl.ffi.new("int[]", codepoints) + with as_file(FONT_DIR) as fspath: + font = rl.load_font_ex((fspath / NOTO_FONTS[language]).as_posix(), 48, + rl.ffi.cast("int *", codepoint_buffer), len(codepoints)) + rl.gen_texture_mipmaps(font.texture) + rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR) + self._fallback_fonts[language] = font + return self._fallback_fonts[language] + @property def width(self): return self._width @@ -692,14 +719,26 @@ class GuiApplication(GuiApplicationExt): return self._height def _load_fonts(self): + base_chars = set(map(chr, range(32, 127))) | set(EXTRA_FONT_CHARS) + unifont_chars = set(base_chars) + for language, code in multilang.languages.items(): + unifont_chars.update(language) + if code not in FONT_FALLBACK_LANGUAGES: + base_chars.update(TRANSLATIONS_DIR.joinpath(f"app_{code}.po").read_text(encoding="utf-8")) + for font_weight_file in FontWeight: with as_file(FONT_DIR) as fspath: - fnt_path = fspath / font_weight_file - font = rl.load_font(fnt_path.as_posix()) + unifont = font_weight_file == FontWeight.UNIFONT + codepoints = sorted(map(ord, unifont_chars if unifont else base_chars)) + codepoint_buffer = rl.ffi.new("int[]", codepoints) + font = rl.load_font_ex((fspath / font_weight_file).as_posix(), 16 if unifont else 200, + rl.ffi.cast("int *", codepoint_buffer), len(codepoints)) if font_weight_file != FontWeight.UNIFONT: rl.gen_texture_mipmaps(font.texture) rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR) 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): @@ -831,6 +870,7 @@ class GuiApplication(GuiApplicationExt): import pstats self._render_profiler.disable() + assert self._render_profile_start_time is not None elapsed_ms = (time.monotonic() - self._render_profile_start_time) * 1e3 avg_frame_time = elapsed_ms / self._frame if self._frame > 0 else 0 diff --git a/openpilot/system/ui/lib/emoji.py b/openpilot/system/ui/lib/emoji.py deleted file mode 100644 index ad4c272c8d..0000000000 --- a/openpilot/system/ui/lib/emoji.py +++ /dev/null @@ -1,55 +0,0 @@ -import io -import re -import functools -from importlib.resources import as_file - -from PIL import Image, ImageDraw, ImageFont -import pyray as rl - -from openpilot.system.ui.lib.application import FONT_DIR - -_cache: dict[str, rl.Texture] = {} - -EMOJI_REGEX = re.compile( -"""[\U0001F600-\U0001F64F -\U0001F300-\U0001F5FF -\U0001F680-\U0001F6FF -\U0001F1E0-\U0001F1FF -\U00002700-\U000027BF -\U0001F900-\U0001F9FF -\U00002600-\U000026FF -\U00002300-\U000023FF -\U00002B00-\U00002BFF -\U0001FA70-\U0001FAFF -\U0001F700-\U0001F77F -\u2640-\u2642 -\u2600-\u2B55 -\u200d -\u23cf -\u23e9 -\u231a -\ufe0f -\u3030 -]+""".replace("\n", ""), - flags=re.UNICODE -) - -@functools.cache -def _load_emoji_font() -> ImageFont.FreeTypeFont: - with as_file(FONT_DIR.joinpath("NotoColorEmoji.ttf")) as font_path: - return ImageFont.truetype(io.BytesIO(font_path.read_bytes()), 109) - -def find_emoji(text): - return [(m.start(), m.end(), m.group()) for m in EMOJI_REGEX.finditer(text)] - -def emoji_tex(emoji): - if emoji not in _cache: - img = Image.new("RGBA", (128, 128), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - draw.text((0, 0), emoji, font=_load_emoji_font(), embedded_color=True) - with io.BytesIO() as buffer: - img.save(buffer, format="PNG") - l = buffer.tell() - buffer.seek(0) - _cache[emoji] = rl.load_texture_from_image(rl.load_image_from_memory(".png", buffer.getvalue(), l)) - return _cache[emoji] diff --git a/openpilot/system/ui/lib/multilang.py b/openpilot/system/ui/lib/multilang.py index 8d79ec3eea..85e2621275 100644 --- a/openpilot/system/ui/lib/multilang.py +++ b/openpilot/system/ui/lib/multilang.py @@ -2,20 +2,24 @@ from importlib.resources import files import json import os import re +from typing import TYPE_CHECKING from openpilot.common.basedir import BASEDIR from openpilot.common.swaglog import cloudlog -try: +if TYPE_CHECKING: from openpilot.common.params import Params -except ImportError: - Params = None +else: + try: + from openpilot.common.params import Params + except (ImportError, OSError): + Params = None SYSTEM_UI_DIR = os.path.join(BASEDIR, "openpilot/system", "ui") UI_DIR = files("openpilot.selfdrive.ui") TRANSLATIONS_DIR = UI_DIR.joinpath("translations") LANGUAGES_FILE = TRANSLATIONS_DIR.joinpath("languages.json") -UNIFONT_LANGUAGES = [ +FONT_FALLBACK_LANGUAGES = [ "th", "zh-CHT", "zh-CHS", @@ -161,9 +165,8 @@ class Multilang: def language(self) -> str: return self._language - def requires_unifont(self) -> bool: - """Certain languages require unifont to render their glyphs.""" - return self._language in UNIFONT_LANGUAGES + def requires_font_fallback(self) -> bool: + return self._language in FONT_FALLBACK_LANGUAGES def setup(self): try: diff --git a/openpilot/system/ui/lib/scroll_panel2.py b/openpilot/system/ui/lib/scroll_panel2.py index b6193672c4..19faaa542d 100644 --- a/openpilot/system/ui/lib/scroll_panel2.py +++ b/openpilot/system/ui/lib/scroll_panel2.py @@ -5,7 +5,7 @@ from collections.abc import Callable from enum import Enum from typing import cast from openpilot.system.ui.lib.application import gui_app, MouseEvent -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from collections import deque MIN_VELOCITY = 10 # px/s, changes from auto scroll to steady state @@ -52,7 +52,7 @@ class GuiScrollPanel2: self._initial_click_event: MouseEvent | None = None self._previous_mouse_event: MouseEvent | None = None self._velocity = 0.0 # pixels per second - self._velocity_buffer: deque[float] = deque(maxlen=12 if TICI else 6) + self._velocity_buffer: deque[float] = deque(maxlen=12 if COMMA_HARDWARE else 6) self._enabled: bool | Callable[[], bool] = True def set_enabled(self, enabled: bool | Callable[[], bool]) -> None: diff --git a/openpilot/system/ui/lib/shader_polygon.py b/openpilot/system/ui/lib/shader_polygon.py index 94af35e157..de729d6aae 100644 --- a/openpilot/system/ui/lib/shader_polygon.py +++ b/openpilot/system/ui/lib/shader_polygon.py @@ -152,7 +152,7 @@ class ShaderState: self.initialized = False -def _configure_shader_color(state: ShaderState, color: Optional[rl.Color], +def _configure_shader_color(state: ShaderState, color: Optional[rl.Color], # noqa: UP045 # rl.Color is a function, so `rl.Color | None` fails gradient: Gradient | None, origin_rect: rl.Rectangle): assert (color is not None) != (gradient is not None), "Either color or gradient must be provided" @@ -204,7 +204,7 @@ def triangulate(pts: np.ndarray) -> list[tuple[float, float]]: def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray, - color: Optional[rl.Color] = None, gradient: Gradient | None = None): + color: Optional[rl.Color] = None, gradient: Gradient | None = None): # noqa: UP045 # rl.Color is a function, so `rl.Color | None` fails """ Draw a ribbon polygon (two chains) with a triangle strip and gradient. diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 69aae6fdf3..a7a33834cd 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -3,15 +3,16 @@ Tests the state machine in isolation by constructing a WifiManager with mocked DBus, then calling _handle_state_change directly with NM state transitions. """ -import pytest +import unittest from jeepney.low_level import MessageType -from pytest_mock import MockerFixture +from openpilot.common.parameterized import parameterized +from openpilot.common.test import Mocker, OpenpilotTestCase from openpilot.system.ui.lib.networkmanager import NMDeviceState, NMDeviceStateReason from openpilot.system.ui.lib.wifi_manager import WifiManager, WifiState, ConnectStatus -def _make_wm(mocker: MockerFixture, connections=None): +def _make_wm(mocker: Mocker, connections=None): """Create a WifiManager with only the fields _handle_state_change touches.""" mocker.patch.object(WifiManager, '_initialize') wm = WifiManager.__new__(WifiManager) @@ -50,7 +51,7 @@ def fire_wpa_connect(wm: WifiManager) -> None: # Basic transitions # --------------------------------------------------------------------------- -class TestDisconnected: +class TestDisconnected(OpenpilotTestCase): def test_generic_disconnect_clears_state(self, mocker): wm = _make_wm(mocker) wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED) @@ -92,7 +93,7 @@ class TestDisconnected: assert wm._wifi_state.status == ConnectStatus.DISCONNECTED -class TestDeactivating: +class TestDeactivating(OpenpilotTestCase): def test_deactivating_noop_for_non_connection_removed(self, mocker): """DEACTIVATING with non-CONNECTION_REMOVED reason is a no-op.""" wm = _make_wm(mocker) @@ -103,10 +104,10 @@ class TestDeactivating: assert wm._wifi_state.ssid == "Net" assert wm._wifi_state.status == ConnectStatus.CONNECTED - @pytest.mark.parametrize("status, expected_clears", [ + @parameterized.expand([ (ConnectStatus.CONNECTED, True), (ConnectStatus.CONNECTING, False), - ]) + ], names=("status", "expected_clears")) def test_deactivating_connection_removed(self, mocker, status, expected_clears): """DEACTIVATING(CONNECTION_REMOVED) clears CONNECTED but preserves CONNECTING. @@ -130,7 +131,7 @@ class TestDeactivating: assert wm._wifi_state.status == ConnectStatus.CONNECTING -class TestPrepareConfig: +class TestPrepareConfig(OpenpilotTestCase): def test_user_initiated_skips_dbus_lookup(self, mocker): """User called _set_connecting('B') — PREPARE must not overwrite via DBus. @@ -148,7 +149,7 @@ class TestPrepareConfig: assert wm._wifi_state.status == ConnectStatus.CONNECTING wm._get_active_wifi_connection.assert_not_called() - @pytest.mark.parametrize("state", [NMDeviceState.PREPARE, NMDeviceState.CONFIG]) + @parameterized.expand([NMDeviceState.PREPARE, NMDeviceState.CONFIG], names=("state",)) def test_auto_connect_looks_up_ssid(self, mocker, state): """Auto-connection (ssid=None): PREPARE and CONFIG must look up ssid from NM.""" wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"}) @@ -179,7 +180,7 @@ class TestPrepareConfig: assert wm._wifi_state.status == ConnectStatus.CONNECTING -class TestNeedAuth: +class TestNeedAuth(OpenpilotTestCase): def test_wrong_password_fires_callback(self, mocker): """NEED_AUTH+SUPPLICANT_DISCONNECT from CONFIG = real wrong password.""" wm = _make_wm(mocker) @@ -272,16 +273,16 @@ class TestNeedAuth: assert len(wm._callback_queue) == 0 -class TestPassthroughStates: +class TestPassthroughStates(OpenpilotTestCase): """NEED_AUTH (generic), IP_CONFIG, IP_CHECK, SECONDARIES, FAILED (generic) are no-ops.""" - @pytest.mark.parametrize("state", [ + @parameterized.expand([ NMDeviceState.NEED_AUTH, NMDeviceState.IP_CONFIG, NMDeviceState.IP_CHECK, NMDeviceState.SECONDARIES, NMDeviceState.FAILED, - ]) + ], names=("state",)) def test_passthrough_is_noop(self, mocker, state): wm = _make_wm(mocker) wm._set_connecting("Net") @@ -293,7 +294,7 @@ class TestPassthroughStates: assert len(wm._callback_queue) == 0 -class TestActivated: +class TestActivated(OpenpilotTestCase): def test_sets_connected(self, mocker): """ACTIVATED sets status to CONNECTED and fires callback.""" wm = _make_wm(mocker, connections={"MyNet": "/path/mynet"}) @@ -344,7 +345,7 @@ class TestActivated: # guard) shrink these race windows significantly. The epoch counter closes the # remaining gaps. -class TestThreadRaces: +class TestThreadRaces(OpenpilotTestCase): def test_prepare_race_user_tap_during_dbus(self, mocker): """User taps B while PREPARE's DBus call is in flight for auto-connect. @@ -416,7 +417,7 @@ class TestThreadRaces: # Full sequences (NM signal order from real devices) # --------------------------------------------------------------------------- -class TestFullSequences: +class TestFullSequences(OpenpilotTestCase): def test_normal_connect(self, mocker): """User connects to saved network: full happy path. @@ -771,7 +772,7 @@ class TestFullSequences: wm.process_callbacks() cb.assert_called_once_with("Hotspot") - @pytest.mark.xfail(reason="TODO: FAILED(SSID_NOT_FOUND) should emit error for UI") + @unittest.expectedFailure # "TODO: FAILED(SSID_NOT_FOUND) should emit error for UI" def test_ssid_not_found(self, mocker): """Network drops off while connected — hotspot turned off. @@ -843,7 +844,7 @@ class TestFullSequences: # Verified on device: when ActivateConnection returns UnknownConnection error, # NM emits no state signals. The worker error path is the only recovery point. -class TestWorkerErrorRecovery: +class TestWorkerErrorRecovery(OpenpilotTestCase): """Worker threads re-sync with NM via _init_wifi_state on DBus errors, preserving actual NM state instead of blindly clearing to DISCONNECTED.""" diff --git a/openpilot/system/ui/lib/text_measure.py b/openpilot/system/ui/lib/text_measure.py index dee4b419ff..60945b7f05 100644 --- a/openpilot/system/ui/lib/text_measure.py +++ b/openpilot/system/ui/lib/text_measure.py @@ -1,6 +1,5 @@ import pyray as rl from openpilot.system.ui.lib.application import FONT_SCALE, font_fallback -from openpilot.system.ui.lib.emoji import find_emoji _cache: dict[int, rl.Vector2] = {} @@ -13,24 +12,7 @@ def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: float if key in _cache: return _cache[key] - # Measure normal characters without emojis, then add standard width for each found emoji - emoji = find_emoji(text) - if emoji: - non_emoji_text = "" - last_index = 0 - for start, end, _ in emoji: - non_emoji_text += text[last_index:start] - last_index = end - non_emoji_text += text[last_index:] - else: - non_emoji_text = text - - result = rl.measure_text_ex(font, non_emoji_text, font_size * FONT_SCALE, spacing) # noqa: TID251 - if emoji: - result.x += len(emoji) * font_size * FONT_SCALE - # If just emoji assume a single line height - if result.y == 0: - result.y = font_size * FONT_SCALE + result = rl.measure_text_ex(font, text, font_size * FONT_SCALE, spacing) # noqa: TID251 _cache[key] = result return result diff --git a/openpilot/system/ui/lib/utils.py b/openpilot/system/ui/lib/utils.py index 77035d0da0..e97b3ba9d9 100644 --- a/openpilot/system/ui/lib/utils.py +++ b/openpilot/system/ui/lib/utils.py @@ -1,8 +1,9 @@ import pyray as rl +from collections.abc import Sequence class GuiStyleContext: - def __init__(self, styles: list[tuple[int, int, int]]): + 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]] = [] diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 87f0ebda83..5e774a84d7 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -6,7 +6,7 @@ import subprocess from collections.abc import Callable from dataclasses import dataclass, replace from enum import IntEnum -from typing import Any +from typing import TYPE_CHECKING, Any from jeepney import DBusAddress, new_method_call from jeepney.bus_messages import MatchRule, message_bus @@ -26,10 +26,13 @@ from openpilot.system.ui.lib.networkmanager import (NM, NM_WIRELESS_IFACE, NM_80 NM_DEVICE_TYPE_WIFI, NM_ACTIVE_CONNECTION_IFACE, NM_IP4_CONFIG_IFACE, NM_PROPERTIES_IFACE, NMDeviceState, NMDeviceStateReason) -try: +if TYPE_CHECKING: from openpilot.common.params import Params -except Exception: - Params = None +else: + try: + from openpilot.common.params import Params + except (ImportError, OSError): + Params = None TETHERING_IP_ADDRESS = "192.168.43.1" DEFAULT_TETHERING_PASSWORD = "swagswagcomma" @@ -257,7 +260,7 @@ class WifiManager: def add_callbacks(self, need_auth: Callable[[str], None] | None = None, activated: Callable[[], None] | None = None, - forgotten: Callable[[str], None] | None = None, + forgotten: Callable[[str | None], None] | None = None, networks_updated: Callable[[list[Network]], None] | None = None, disconnected: Callable[[], None] | None = None): if need_auth is not None: @@ -512,8 +515,11 @@ class WifiManager: def _get_adapter(self, adapter_type: int) -> str | None: # Return the first NetworkManager device path matching adapter_type try: - device_paths = self._router_main.send_and_get_reply(new_method_call(self._nm, 'GetDevices')).body[0] - for device_path in device_paths: + reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'GetDevices')) + if reply.header.message_type == MessageType.error: + # NetworkManager is not available, body holds an error string instead of device paths + return None + for device_path in reply.body[0]: dev_addr = DBusAddress(device_path, bus_name=NM, interface=NM_DEVICE_IFACE) dev_type = self._router_main.send_and_get_reply(Properties(dev_addr).get('DeviceType')).body[0][1] if dev_type == adapter_type: diff --git a/openpilot/system/ui/mici_setup.py b/openpilot/system/ui/mici_setup.py index e446697cfe..efec377dd5 100755 --- a/openpilot/system/ui/mici_setup.py +++ b/openpilot/system/ui/mici_setup.py @@ -13,7 +13,7 @@ import pyray as rl from openpilot.cereal import log from openpilot.common.filter_simple import BounceFilter -from openpilot.common.hardware import HARDWARE, TICI +from openpilot.common.hardware import HARDWARE, COMMA_HARDWARE 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 @@ -570,7 +570,7 @@ class Setup(Widget): def main(): config_realtime_process(0, 51) # attempt to affine. AGNOS will start setup with all cores, should only fail when manually launching with screen off - if TICI: + if COMMA_HARDWARE: try: set_core_affinity([5]) except OSError: diff --git a/openpilot/system/ui/mici_updater.py b/openpilot/system/ui/mici_updater.py index a072354cf0..d9009b8259 100755 --- a/openpilot/system/ui/mici_updater.py +++ b/openpilot/system/ui/mici_updater.py @@ -5,7 +5,7 @@ import threading import pyray as rl from openpilot.common.realtime import config_realtime_process, set_core_affinity -from openpilot.common.hardware import HARDWARE, TICI +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.widgets.nav_widget import NavWidget @@ -152,7 +152,7 @@ class Updater(Scroller): def main(): config_realtime_process(0, 51) # attempt to affine. AGNOS will start setup with all cores, should only fail when manually launching with screen off - if TICI: + if COMMA_HARDWARE: try: set_core_affinity([5]) except OSError: diff --git a/openpilot/system/ui/sunnypilot/lib/utils.py b/openpilot/system/ui/sunnypilot/lib/utils.py index ecaba86f4b..6ae30d13ae 100644 --- a/openpilot/system/ui/sunnypilot/lib/utils.py +++ b/openpilot/system/ui/sunnypilot/lib/utils.py @@ -4,7 +4,29 @@ 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 collections.abc import Callable + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.sunnypilot.lib.styles import style from openpilot.system.ui.sunnypilot.widgets.list_view import ButtonActionSP +from openpilot.system.ui.widgets.label import 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): @@ -12,6 +34,36 @@ class NoElideButtonAction(ButtonActionSP): return super().get_width_hint() + 1 +class ScrollingButtonAction(ButtonActionSP): + 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 = UnifiedLabelSP("", font_size=style.ITEM_TEXT_FONT_SIZE, font_weight=FontWeight.NORMAL, + text_color=self._value_color, scroll=True, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + def set_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR): + if self.value != _resolve_value(value, ""): + self._value_label.reset_scroll() + super().set_value(value, color) + self._value_label.set_text(value) + self._value_label.set_text_color(color) + + def _render(self, rect: rl.Rectangle) -> bool: + """Duplicate of ButtonActionSP._render, with the value drawn by a scrolling label""" + self._button.set_text(self.text) + self._button.set_enabled(_resolve_value(self.enabled)) + button_rect = rl.Rectangle(rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT) + self._button.render(button_rect) + + if self.value: + self._value_label.render(rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height)) + + pressed = self._pressed + self._pressed = False + return pressed + + class AlertFadeAnimator: def __init__(self, target_fps: int, duration_on: float = 0.75, rc: float = 0.05): from openpilot.common.filter_simple import FirstOrderFilter diff --git a/openpilot/system/ui/sunnypilot/widgets/download_status.py b/openpilot/system/ui/sunnypilot/widgets/download_status.py new file mode 100644 index 0000000000..135bd151a5 --- /dev/null +++ b/openpilot/system/ui/sunnypilot/widgets/download_status.py @@ -0,0 +1,211 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import math + +import numpy as np +import pyray as rl + +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.sunnypilot.lib.styles import style +from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.sunnypilot.lib.utils import UnifiedLabelSP +from openpilot.system.ui.widgets.list_view import ItemAction + +FONT_SIZE = style.ITEM_TEXT_FONT_SIZE +ICON_SIZE = 56 +ICON_PADDING = 12 + +BAR_WIDTH = 1100 +BAR_HEIGHT = 20 +SEGMENT_GAP = 24 +SEGMENT_NAME_MAX_WIDTH = 380 +BAR_GAP = 16 +BAR_RADIUS = BAR_HEIGHT / 2 +CAPSULE_POINTS = 24 + +RAIL_COLOR = rl.Color(60, 60, 60, 255) +FILL_COLOR = rl.Color(30, 121, 232, 255) +# rl.WHITE is a tuple; the shimmer path reads .a off the color +TEXT_COLOR = rl.Color(255, 255, 255, 255) + +SWEEP_SPEED = 550.0 # px/s +SWEEP_BAND = 240.0 # highlight half-width, px +SWEEP_DIM = 0.65 + + +class DownloadStatusAction(ItemAction): + """Model download row: a name + percent over a progress rail while downloading, a name + icon otherwise.""" + + def __init__(self): + super().__init__(width=BAR_WIDTH) + self.name = "" + self.status_text = "" + self.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 + self.icon_color: rl.Color | None = None + self._font = gui_app.font(FontWeight.NORMAL) + # raw progress arrives in steps, one per 128KB chunk the manager publishes + self._progress = FirstOrderFilter(0.0, 0.5, 1 / gui_app.target_fps) + # integrated per frame; (t * speed) % span jumps whenever the fill width changes + self._sweep = 0.0 + + self._name_label = UnifiedLabel("", font_size=FONT_SIZE, font_weight=FontWeight.NORMAL, text_color=TEXT_COLOR, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + self._percent_label = UnifiedLabel("", font_size=FONT_SIZE, font_weight=FontWeight.NORMAL, text_color=TEXT_COLOR, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + def update(self, name, downloading=False, progress=0.0, status_text="", text_color=rl.GRAY, icon=None, icon_color=None, segments=None): + self.segments = segments + if downloading and not self.downloading: + self._name_label.reset_shimmer() + self._progress.x = progress + self._sweep = 0.0 + self.name = name + self.downloading = downloading + self.status_text = status_text + self.text_color = text_color + self.icon = icon + self.icon_color = icon_color + self._name_label._shimmer = downloading + if downloading: + self._progress.update(progress) + self._sweep += SWEEP_SPEED / gui_app.target_fps + + @property + def _idle_text(self) -> str: + return f"{self.name} - {self.status_text}" if self.status_text else self.name + + def get_width_hint(self) -> float: + if self.downloading: + return BAR_WIDTH + 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) + else: + self._render_idle(rect) + + def _sweep_gradient(self, width: float) -> Gradient: + # clearance at both ends keeps the wrap offscreen + center = (self._sweep % (width + 2 * SWEEP_BAND)) - SWEEP_BAND + + def band(x: float) -> float: + return max(0.0, 1.0 - abs(x - center) / SWEEP_BAND) + + # sampling the corners is exact for a piecewise linear band + xs = sorted({0.0, width} | {min(max(center + o, 0.0), width) for o in (-SWEEP_BAND, 0.0, SWEEP_BAND)}, reverse=True) + # the gradient axis runs right-to-left in screen space + stops = [1.0 - x / width for x in xs] + # alpha here is the lift over the SWEEP_DIM base, not the final opacity + colors = [rl.Color(FILL_COLOR.r, FILL_COLOR.g, FILL_COLOR.b, int(255 * band(x))) for x in xs] + return Gradient(start=(0.0, 0.0), end=(1.0, 0.0), colors=colors, stops=stops) + + @staticmethod + def _capsule(rect: rl.Rectangle) -> np.ndarray: + """Rounded-end ribbon so the gradient covers the caps.""" + r = rect.height / 2 + cy = rect.y + r + top, bottom = [], [] + for i in range(CAPSULE_POINTS): + x = rect.x + rect.width * i / (CAPSULE_POINTS - 1) + d = min(x - rect.x, rect.x + rect.width - x, r) + h = math.sqrt(max(r * r - (r - d) ** 2, 0.0)) + top.append((x, cy - h)) + bottom.append((x, cy + h)) + return np.array(top + bottom[::-1], dtype=np.float32) + + def _draw_fill(self, rail: rl.Rectangle, fill_width: float): + if fill_width <= 0: + return + fill = rl.Rectangle(rail.x, rail.y, fill_width, rail.height) + rl.draw_rectangle_rounded(fill, 1.0, 10, rl.Color(FILL_COLOR.r, FILL_COLOR.g, FILL_COLOR.b, int(255 * SWEEP_DIM))) + draw_polygon(fill, self._capsule(fill), gradient=self._sweep_gradient(fill_width)) + + def _render_downloading(self, rect: rl.Rectangle): + percent = f"{int(self._progress.x)}%" + if self.status_text: + percent = f"{self.status_text} {percent}" + text_height = measure_text_cached(self._font, percent, FONT_SIZE).y + top = rect.y + (rect.height - (text_height + BAR_GAP + BAR_HEIGHT)) / 2 + + text_rect = rl.Rectangle(rect.x, top, rect.width, text_height) + self._name_label.set_text(self.name) + self._name_label.render(text_rect) + self._percent_label.set_text(percent) + self._percent_label.render(text_rect) + + rail = rl.Rectangle(rect.x, top + text_height + BAR_GAP, rect.width, BAR_HEIGHT) + rl.draw_rectangle_rounded(rail, 1.0, 10, RAIL_COLOR) + self._draw_fill(rail, max(0.0, min(rect.width, rect.width * (self._progress.x / 100.0)))) + + def _render_idle(self, rect: rl.Rectangle): + 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 + + if self.icon: + texture = gui_app.texture(self.icon, ICON_SIZE, ICON_SIZE, keep_aspect_ratio=True) + rl.draw_texture_v(texture, rl.Vector2(right - texture.width, rect.y + (rect.height - texture.height) / 2), + self.icon_color or self.text_color) + right -= texture.width + ICON_PADDING + + rl.draw_text_ex(self._font, text, rl.Vector2(right - text_size.x, rect.y + (rect.height - text_size.y) / 2), + FONT_SIZE, 0, self.text_color) + + def _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 89342572d1..ff4b8c13dd 100644 --- a/openpilot/system/ui/sunnypilot/widgets/list_view.py +++ b/openpilot/system/ui/sunnypilot/widgets/list_view.py @@ -4,7 +4,7 @@ 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 collections.abc import Callable +from collections.abc import Callable, Sequence import pyray as rl from openpilot.common.params import Params @@ -126,7 +126,7 @@ class DualButtonActionSP(DualButtonAction): class MultipleButtonActionSP(MultipleButtonAction): - def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable | None = None, + def __init__(self, buttons: Sequence[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable | None = None, param: str | None = None): MultipleButtonAction.__init__(self, buttons, button_width, selected_index, callback) self.param_key = param @@ -366,7 +366,7 @@ def toggle_item_sp(title: str | Callable[[], str], description: str | Callable[[ return ListItemSP(title=title, description=description, action_item=action, icon=icon) -def multiple_button_item_sp(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]], +def multiple_button_item_sp(title: str | Callable[[], str], description: str | Callable[[], str], buttons: Sequence[str | Callable[[], str]], selected_index: int = 0, button_width: int = style.BUTTON_ACTION_WIDTH, callback: Callable | None = None, icon: str = "", param: str | None = None, inline: bool = False) -> ListItemSP: action = MultipleButtonActionSP(buttons, button_width, selected_index, callback=callback, param=param) diff --git a/openpilot/system/ui/sunnypilot/widgets/screen_saver.py b/openpilot/system/ui/sunnypilot/widgets/screen_saver.py new file mode 100644 index 0000000000..bf218306d8 --- /dev/null +++ b/openpilot/system/ui/sunnypilot/widgets/screen_saver.py @@ -0,0 +1,118 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import os +import time + +import pyray as rl + +from openpilot.common.hardware import HARDWARE +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget + + +class ScreenSaverSP(Widget): + def __init__(self, params: Params | None = None): + super().__init__() + self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + self._params = params or Params() + self._is_mici = HARDWARE.get_device_type() == 'mici' or (HARDWARE.get_device_type() == "pc" and os.getenv("BIG") != "1") + + self.x = 0.0 + self.y = 100.0 + self.vx = 120.0 if self._is_mici else 300.0 + self.vy = 70.0 if self._is_mici else 200.0 + self._hue = 150 + self.color = rl.color_from_hsv(self._hue, 1, 1) + + self.text = "sunnypilot" + self.font_size = 50 if self._is_mici else 200 + self._start_time = None + self._dismiss = False + self._screensaver_timeout = 300 + self._hit_last_frame = False + + @property + def is_active(self) -> bool: + return self._start_time is not None and not self._dismiss + + @property + def was_dismissed(self) -> bool: + return self._dismiss + + def initialize(self): + self._screensaver_timeout = self._params.get("ScreenSaverTimeout", return_default=True) + if self._start_time is None: + self._start_time = time.monotonic() + self._dismiss = False + + def hide_event(self): + super().hide_event() + self._dismiss = False + self._start_time = None + + def _handle_mouse_release(self, mouse_pos): + self._dismiss = True + self._start_time = None + gui_app.pop_widget() + return super()._handle_mouse_release(mouse_pos) + + def _update_state(self): + super()._update_state() + + self.font = gui_app.font(FontWeight.AUDIOWIDE) + text_size = measure_text_cached(self.font, self.text, self.font_size, 0) + self.logo_width = text_size.x + self.logo_height = text_size.y + + if self._start_time and time.monotonic() - self._start_time > self._screensaver_timeout: + self._dismiss = True + self._start_time = None + + dt = rl.get_frame_time() + + self.x += self.vx * dt + self.y += self.vy * dt + + hit_x = hit_y = False + if self.x + self.logo_width > self.rect.width: + self.vx *= -1 + self.x = self.rect.width - self.logo_width + hit_x = True + elif self.x < 0: + self.vx *= -1 + self.x = 0 + hit_x = True + + if self.y + self.logo_height > self.rect.height: + self.vy *= -1 + self.y = self.rect.height - self.logo_height + hit_y = True + elif self.y < 0: + self.vy *= -1 + self.y = 0 + hit_y = True + + hit = hit_x or hit_y + if hit and not self._hit_last_frame: + while self._hue_dist((new_hue := rl.get_random_value(0, 360)), self._hue) < 120: + pass + self._hue = new_hue + self.color = rl.color_from_hsv(self._hue, 1, 1) + self._hit_last_frame = hit + + @staticmethod + def _hue_dist(a, b): + d = abs(a - b) + return min(d, 360 - d) + + def _render(self, rect: rl.Rectangle): + self.set_rect(rect) + rl.clear_background(rl.BLACK) + rl.draw_text_ex(self.font, self.text, rl.Vector2(int(self.x), int(self.y)), self.font_size, 0, self.color) + return -1 diff --git a/openpilot/system/ui/sunnypilot/widgets/tree_dialog.py b/openpilot/system/ui/sunnypilot/widgets/tree_dialog.py index 69233b803d..97150e1028 100644 --- a/openpilot/system/ui/sunnypilot/widgets/tree_dialog.py +++ b/openpilot/system/ui/sunnypilot/widgets/tree_dialog.py @@ -48,9 +48,9 @@ class TreeItemWidget(Button): self.border_radius = 10 self.is_expanded = is_expanded - def _render(self, rect): + def _render(self, _): indent = 60 * self.indent_level - self._rect = rl.Rectangle(rect.x + indent, rect.y, rect.width - indent, rect.height) + self._rect = rl.Rectangle(_.x + indent, _.y, _.width - indent, _.height) if self.is_pressed: color = BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style] elif self.selected and self.ref != "search_bar": diff --git a/openpilot/system/ui/tici_reset.py b/openpilot/system/ui/tici_reset.py index 569faf45fe..5c175dbfa6 100755 --- a/openpilot/system/ui/tici_reset.py +++ b/openpilot/system/ui/tici_reset.py @@ -37,7 +37,11 @@ class Reset(Widget): self._reset_state = ResetState.NONE 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", lambda: subprocess.run("sudo reboot", shell=True)) + self._reboot_button = Button("Reboot", self._reboot) + + @staticmethod + def _reboot() -> None: + subprocess.run("sudo reboot", shell=True) def _do_erase(self): if PC: diff --git a/openpilot/system/ui/tici_setup.py b/openpilot/system/ui/tici_setup.py index 2b2c1c39f8..36f47ccf1b 100755 --- a/openpilot/system/ui/tici_setup.py +++ b/openpilot/system/ui/tici_setup.py @@ -107,13 +107,20 @@ class Setup(Widget): self._custom_software_warning_title_label = Label("WARNING: Custom Software", 81, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_color=rl.Color(255, 89, 79, 255), text_padding=60) - self._custom_software_warning_body_label = Label("Use caution when installing third-party software.\n\n" - + "⚠️ It has not been tested by comma.\n\n" - + "⚠️ It may not comply with relevant safety standards.\n\n" - + "⚠️ It may cause damage to your device and/or vehicle.\n\n" - + "If you'd like to proceed, use https://flash.comma.ai " - + "to restore your device to a factory state later.", - 68, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, 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, + 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), + ("It has not been tested by comma.", True), + ("It may not comply with relevant safety standards.", True), + ("It may cause damage to your device and/or vehicle.", True), + ("If you'd like to proceed, use https://flash.comma.ai to restore your device to a factory state later.", False) + ] + ] + self._custom_software_warning_body_scroll_panel = GuiScrollPanel() self._downloading_body_label = Label("Downloading...", TITLE_FONT_SIZE, FontWeight.MEDIUM, text_padding=20) @@ -295,7 +302,7 @@ class Setup(Widget): self._download_failed_startover_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT)) def render_custom_software_warning(self, rect: rl.Rectangle): - warn_rect = rl.Rectangle(rect.x, rect.y, rect.width, 1500) + warn_rect = rl.Rectangle(rect.x, rect.y, rect.width, 1550) offset = self._custom_software_warning_body_scroll_panel.update(rect, warn_rect) button_width = (rect.width - MARGIN * 3) / 2 @@ -304,7 +311,12 @@ class Setup(Widget): rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(button_y - BODY_FONT_SIZE * FONT_SCALE)) y_offset = rect.y + offset self._custom_software_warning_title_label.render(rl.Rectangle(rect.x + 50, y_offset + 150, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE)) - self._custom_software_warning_body_label.render(rl.Rectangle(rect.x + 50, y_offset + 400, rect.width - 50, BODY_FONT_SIZE * FONT_SCALE * 3)) + + y = y_offset + 300 + for label in self._custom_software_warning_body_labels: + label.render(rl.Rectangle(rect.x + 50, y, rect.width - 50, BODY_FONT_SIZE)) + y += 160 + rl.end_scissor_mode() self._custom_software_warning_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT)) diff --git a/openpilot/system/ui/widgets/__init__.py b/openpilot/system/ui/widgets/__init__.py index 4ce1c1b694..4e13920d60 100644 --- a/openpilot/system/ui/widgets/__init__.py +++ b/openpilot/system/ui/widgets/__init__.py @@ -3,16 +3,26 @@ from __future__ import annotations import abc import pyray as rl from enum import IntEnum -from typing import TypeVar +from typing import Protocol, TypeVar from collections.abc import Callable from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS, MouseEvent -try: - from openpilot.selfdrive.ui.ui_state import device -except ImportError: - class Device: - awake = True - device = Device() +class DeviceLike(Protocol): + @property + def awake(self) -> bool: ... + + +def _get_device() -> DeviceLike: + try: + from openpilot.selfdrive.ui.ui_state import device + return device + except (ImportError, OSError): + class Device: + awake = True + return Device() + + +device = _get_device() W = TypeVar('W', bound='Widget') @@ -185,16 +195,16 @@ class Widget(abc.ABC): """Optionally update the widget's non-layout state. This is called before rendering.""" @abc.abstractmethod - def _render(self, rect: rl.Rectangle) -> bool | int | None: + def _render(self, rect: rl.Rectangle, /) -> bool | int | None: """Render the widget within the given rectangle.""" def _update_layout_rects(self) -> None: """Optionally update any layout rects on Widget rect change.""" - def _handle_mouse_press(self, mouse_pos: MousePos) -> None: + def _handle_mouse_press(self, mouse_pos: MousePos, /) -> None: """Optionally handle mouse press events.""" - def _handle_mouse_release(self, mouse_pos: MousePos) -> None: + def _handle_mouse_release(self, mouse_pos: MousePos, /) -> None: """Optionally handle mouse release events.""" if self._click_delay is not None: self._click_release_time = rl.get_time() + self._click_delay diff --git a/openpilot/system/ui/widgets/label.py b/openpilot/system/ui/widgets/label.py index 7fe25ab51d..a3e827321c 100644 --- a/openpilot/system/ui/widgets/label.py +++ b/openpilot/system/ui/widgets/label.py @@ -1,7 +1,6 @@ import math from enum import IntEnum from collections.abc import Callable -from itertools import zip_longest from typing import Union import pyray as rl @@ -9,7 +8,6 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEX 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.emoji import find_emoji, emoji_tex from openpilot.system.ui.lib.wrap_text import wrap_text ICON_PADDING = 15 @@ -104,7 +102,7 @@ def gui_text_box( rl.gui_set_font(gui_app.font(FontWeight.NORMAL)) -# Non-interactive text area. Can render emojis and an optional specified icon. +# Non-interactive text area. Can render an optional specified icon. class Label(Widget): def __init__(self, text: str | Callable[[], str], @@ -146,7 +144,6 @@ class Label(Widget): self._update_text(self._text) def _update_text(self, text): - self._emojis = [] self._text_size = [] text = _resolve_value(text) @@ -176,7 +173,6 @@ class Label(Widget): self._text_wrapped = wrap_text(self._font, text, self._font_size, round(self._rect.width - (self._text_padding * 2))) for t in self._text_wrapped: - self._emojis.append(find_emoji(t)) self._text_size.append(measure_text_cached(self._font, t, self._font_size)) def _render(self, _): @@ -188,6 +184,9 @@ class Label(Widget): if self._text_alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_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: + 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: text_pos = rl.Vector2(self._rect.x, self._rect.y) @@ -196,18 +195,18 @@ class Label(Widget): if len(self._text_wrapped) > 0: if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: icon_x = self._rect.x + self._text_padding - text_pos.x = self._icon.width + ICON_PADDING + text_pos.x = self._rect.x + self._icon.width + ICON_PADDING elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_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._icon.width + ICON_PADDING + text_pos.x = self._rect.x + self._icon.width + ICON_PADDING else: icon_x = (self._rect.x + self._rect.width - text_size.x - self._text_padding) - ICON_PADDING - self._icon.width else: icon_x = self._rect.x + (self._rect.width - self._icon.width) / 2 rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE) - for text, text_size, emojis in zip_longest(self._text_wrapped, self._text_size, self._emojis, fillvalue=[]): + 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: line_pos.x += self._text_padding @@ -216,18 +215,7 @@ class Label(Widget): elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: line_pos.x += self._rect.width - text_size.x - self._text_padding - prev_index = 0 - for start, end, emoji in emojis: - text_before = text[prev_index:start] - width_before = measure_text_cached(self._font, text_before, self._font_size) - rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, 0, self._text_color) - line_pos.x += width_before.x - - tex = emoji_tex(emoji) - rl.draw_texture_ex(tex, line_pos, 0.0, self._font_size / tex.height * FONT_SCALE, self._text_color) - line_pos.x += self._font_size * FONT_SCALE - prev_index = end - rl.draw_text_ex(self._font, text[prev_index:], line_pos, self._font_size, 0, self._text_color) + rl.draw_text_ex(self._font, text, line_pos, self._font_size, 0, self._text_color) text_pos.y += (text_size.y or self._font_size * FONT_SCALE) * self._line_scale @@ -236,7 +224,6 @@ class UnifiedLabel(Widget): Unified label widget that combines functionality from gui_label, gui_text_box, and Label. Supports: - - Emoji rendering - Text wrapping - Automatic eliding (single-line or multiline) - Proper multiline vertical alignment @@ -301,7 +288,6 @@ class UnifiedLabel(Widget): self._cached_text: str | None = None self._cached_wrapped_lines: list[str] = [] self._cached_line_sizes: list[rl.Vector2] = [] - self._cached_line_emojis: list[list[tuple[int, int, str]]] = [] self._cached_total_height: float | None = None self._cached_width: int = -1 @@ -428,13 +414,10 @@ class UnifiedLabel(Widget): if self._scroll: self._cached_wrapped_lines = self._cached_wrapped_lines[:1] # Only first line for scrolling - # Process each line: measure and find emojis + # Process each line: measure self._cached_line_sizes = [] - self._cached_line_emojis = [] for line in self._cached_wrapped_lines: - emojis = find_emoji(line) - self._cached_line_emojis.append(emojis) # Empty lines should still have height (use font size as line height) if not line: size = rl.Vector2(0, self._font_size * FONT_SCALE) @@ -523,14 +506,12 @@ class UnifiedLabel(Widget): # Calculate which lines fit in the available height visible_lines: list[str] = [] visible_sizes: list[rl.Vector2] = [] - visible_emojis: list[list[tuple[int, int, str]]] = [] current_height = 0.0 broke_early = False - for line, size, emojis in zip( + for line, size in zip( self._cached_wrapped_lines, self._cached_line_sizes, - self._cached_line_emojis, strict=True): # Calculate height needed for this line @@ -551,7 +532,6 @@ class UnifiedLabel(Widget): visible_lines.append(line) visible_sizes.append(size) - visible_emojis.append(emojis) current_height += line_height_needed @@ -595,7 +575,7 @@ class UnifiedLabel(Widget): # Render each line current_y = start_y - for idx, (line, size, emojis) in enumerate(zip(visible_lines, visible_sizes, visible_emojis, strict=True)): + for idx, (line, size) in enumerate(zip(visible_lines, visible_sizes, strict=True)): if self._needs_scroll: if self._scroll_state == ScrollState.STARTING: if self._scroll_pause_t is None: @@ -614,12 +594,12 @@ class UnifiedLabel(Widget): else: self.reset_scroll() - self._render_line(line, size, emojis, current_y) + self._render_line(line, size, current_y) # Draw 2nd instance for scrolling if self._needs_scroll and self._scroll_state != ScrollState.STARTING: text2_scroll_offset = size.x + self._rect.width / 3 - self._render_line(line, size, emojis, current_y, text2_scroll_offset) + self._render_line(line, size, current_y, text2_scroll_offset) # Move to next line (if not last line) if idx < len(visible_lines) - 1: @@ -658,7 +638,7 @@ class UnifiedLabel(Widget): shimmer = math.exp(-0.5 * d * d / (sigma * sigma)) return self.SHIMMER_LOW_OPACITY + (1.0 - self.SHIMMER_LOW_OPACITY) * shimmer - def _render_line(self, line, size, emojis, current_y, x_offset=0.0): + def _render_line(self, line, size, current_y, x_offset=0.0): # Calculate horizontal position if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: line_x = self._rect.x + self._text_padding @@ -673,33 +653,11 @@ class UnifiedLabel(Widget): if self._shimmer: self._render_line_shimmer(line, line_x, current_y) else: - # Render line with emojis - self._render_line_normal(line, emojis, line_x, current_y) + self._render_line_normal(line, line_x, current_y) - def _render_line_normal(self, line, emojis, line_x, current_y): + def _render_line_normal(self, line, line_x, current_y): line_pos = rl.Vector2(line_x, current_y) - prev_index = 0 - - for start, end, emoji in emojis: - # Draw text before emoji - text_before = line[prev_index:start] - if text_before: - rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, self._spacing_pixels, self._text_color) - width_before = measure_text_cached(self._font, text_before, self._font_size, self._spacing_pixels) - line_pos.x += width_before.x - - # Draw emoji - tex = emoji_tex(emoji) - emoji_scale = self._font_size / tex.height * FONT_SCALE - rl.draw_texture_ex(tex, line_pos, 0.0, emoji_scale, self._text_color) - # Emoji width is font_size * FONT_SCALE (as per measure_text_cached) - line_pos.x += self._font_size * FONT_SCALE - prev_index = end - - # Draw remaining text after last emoji - text_after = line[prev_index:] - if text_after: - rl.draw_text_ex(self._font, text_after, line_pos, self._font_size, self._spacing_pixels, self._text_color) + rl.draw_text_ex(self._font, line, line_pos, self._font_size, self._spacing_pixels, self._text_color) def _render_line_shimmer(self, line, line_x, current_y): # Shimmer range based on widest line so sweep is even across all lines diff --git a/openpilot/system/ui/widgets/list_view.py b/openpilot/system/ui/widgets/list_view.py index 82613c37c8..61c77b7600 100644 --- a/openpilot/system/ui/widgets/list_view.py +++ b/openpilot/system/ui/widgets/list_view.py @@ -1,6 +1,7 @@ +import math import os import pyray as rl -from collections.abc import Callable +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.multilang import tr @@ -104,7 +105,9 @@ class ButtonAction(ItemAction): value_text = self.value if value_text: text_width = measure_text_cached(self._font, value_text, ITEM_TEXT_FONT_SIZE).x - return text_width + BUTTON_WIDTH + TEXT_PADDING + # round up so the width survives float32 storage in rl.Rectangle, otherwise the + # value label can come out a fraction of a pixel too narrow and get elided + return math.ceil(text_width) + BUTTON_WIDTH + TEXT_PADDING else: return BUTTON_WIDTH @@ -207,7 +210,7 @@ class DualButtonAction(ItemAction): class MultipleButtonAction(ItemAction): - def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable | None = None): + def __init__(self, buttons: Sequence[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable | None = None): super().__init__(width=len(buttons) * button_width + (len(buttons) - 1) * RIGHT_ITEM_PADDING, enabled=True) self.buttons = buttons self.button_width = button_width diff --git a/openpilot/system/ui/widgets/nav_widget.py b/openpilot/system/ui/widgets/nav_widget.py index 11770bbe5d..58ff8123bb 100644 --- a/openpilot/system/ui/widgets/nav_widget.py +++ b/openpilot/system/ui/widgets/nav_widget.py @@ -78,7 +78,7 @@ class NavWidget(Widget, abc.ABC): # the top of a vertical scroll panel to prevent erroneous swipes return True - def set_back_callback(self, callback: Callable[[], None]) -> None: + def set_back_callback(self, callback: Callable[[], None] | None) -> None: self._back_callback = callback def set_shown_callback(self, callback: Callable[[], None] | None) -> None: diff --git a/openpilot/system/ui/widgets/network.py b/openpilot/system/ui/widgets/network.py index c2fc427c42..fb999c0441 100644 --- a/openpilot/system/ui/widgets/network.py +++ b/openpilot/system/ui/widgets/network.py @@ -1,6 +1,6 @@ from enum import IntEnum from functools import partial -from typing import cast +from typing import Any, cast import pyray as rl from openpilot.system.ui.lib.application import gui_app @@ -27,9 +27,9 @@ try: from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.lib.prime_state import PrimeType except Exception: - Params = None - ui_state = None - PrimeType = None + Params: Any = None + ui_state: Any = None + PrimeType: Any = None NM_DEVICE_STATE_NEED_AUTH = 60 MIN_PASSWORD_LENGTH = 8 @@ -111,10 +111,16 @@ class NetworkUI(Widget): class AdvancedNetworkSettings(Widget): def __init__(self, wifi_manager: WifiManager): + # AdvancedNetworkSettings needs the full openpilot environment, standalone apps just use WifiManagerUI + from openpilot.common.params import Params + from openpilot.selfdrive.ui.ui_state import ui_state + from openpilot.selfdrive.ui.lib.prime_state import PrimeType super().__init__() self._wifi_manager = wifi_manager self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated) self._params = Params() + self._prime_state = ui_state.prime_state + self._cell_prime_types = (PrimeType.NONE, PrimeType.LITE) self._keyboard = Keyboard(max_text_size=MAX_PASSWORD_LENGTH, min_text_size=MIN_PASSWORD_LENGTH, show_password_toggle=True) @@ -259,7 +265,7 @@ class AdvancedNetworkSettings(Widget): self._wifi_manager.process_callbacks() # If not using prime SIM, show GSM settings and enable IPv4 forwarding - show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE) + show_cell_settings = self._prime_state.get_type() in self._cell_prime_types self._wifi_manager.set_ipv4_forward(show_cell_settings) self._roaming_btn.set_visible(show_cell_settings) self._apn_btn.set_visible(show_cell_settings) diff --git a/openpilot/system/ui/widgets/scroller.py b/openpilot/system/ui/widgets/scroller.py index b7b6bf5932..55195b30fb 100644 --- a/openpilot/system/ui/widgets/scroller.py +++ b/openpilot/system/ui/widgets/scroller.py @@ -1,6 +1,6 @@ import pyray as rl import numpy as np -from collections.abc import Callable +from collections.abc import Callable, Sequence from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter from openpilot.common.swaglog import cloudlog @@ -40,7 +40,7 @@ class ScrollIndicator(Widget): self._content_size = content_size self._viewport = viewport - def _render(self, _): + def _render(self, _, /): # scale indicator width based on content size indicator_w = float(np.interp(self._content_size, [1000, 3000], [300, 100])) @@ -69,7 +69,7 @@ class ScrollIndicator(Widget): class _Scroller(Widget): """Should use wrapper below to reduce boilerplate""" - def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = False, spacing: int = ITEM_SPACING, + def __init__(self, items: Sequence[Widget], horizontal: bool = True, snap_items: bool = False, spacing: int = ITEM_SPACING, pad: int = ITEM_SPACING, scroll_indicator: bool = True, edge_shadows: bool = True): super().__init__() self._items: list[Widget] = [] @@ -150,7 +150,7 @@ class _Scroller(Widget): and not self.moving_items and (original_touch_valid_callback() if original_touch_valid_callback else True)) - def add_widgets(self, items: list[Widget]) -> None: + def add_widgets(self, items: Sequence[Widget]) -> None: for item in items: self.add_widget(item) @@ -332,7 +332,7 @@ class _Scroller(Widget): else: item.render() - def _render(self, _): + def _render(self, _, /): rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y), int(self._rect.width), int(self._rect.height)) @@ -397,7 +397,7 @@ class Scroller(Widget): # pass down enabled to child widget for nav stack self._scroller.set_enabled(lambda: self.enabled) - def _render(self, _): + def _render(self, _, /): self._scroller.render(self._rect) diff --git a/openpilot/system/ui/widgets/scroller_tici.py b/openpilot/system/ui/widgets/scroller_tici.py index a843010d56..fc81e1b079 100644 --- a/openpilot/system/ui/widgets/scroller_tici.py +++ b/openpilot/system/ui/widgets/scroller_tici.py @@ -1,4 +1,5 @@ import pyray as rl +from collections.abc import Sequence from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.widgets import Widget @@ -23,7 +24,7 @@ class LineSeparator(Widget): class Scroller(Widget): - def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True): + def __init__(self, items: Sequence[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True): super().__init__() self._items: list[Widget] = [] self._spacing = spacing diff --git a/openpilot/system/updated/common.py b/openpilot/system/updated/common.py deleted file mode 100644 index 6bb745f6b0..0000000000 --- a/openpilot/system/updated/common.py +++ /dev/null @@ -1,16 +0,0 @@ -import os -import pathlib - - -def get_consistent_flag(path: str) -> bool: - consistent_file = pathlib.Path(os.path.join(path, ".overlay_consistent")) - return consistent_file.is_file() - -def set_consistent_flag(path: str, consistent: bool) -> None: - os.sync() - consistent_file = pathlib.Path(os.path.join(path, ".overlay_consistent")) - if consistent: - consistent_file.touch() - elif not consistent: - consistent_file.unlink(missing_ok=True) - os.sync() diff --git a/openpilot/system/updated/updated.py b/openpilot/system/updated/updated.py index 99e42a41b3..62fa10ec9e 100755 --- a/openpilot/system/updated/updated.py +++ b/openpilot/system/updated/updated.py @@ -193,7 +193,7 @@ def finalize_update() -> None: def handle_agnos_update() -> None: - from openpilot.common.hardware.tici.agnos import flash_agnos_update, get_target_slot_number + from openpilot.common.hardware.comma.agnos import flash_agnos_update, get_target_slot_number cur_version = HARDWARE.get_os_version() updated_version = run(["bash", "-c", r"unset AGNOS_VERSION && source launch_env.sh && \ @@ -207,13 +207,10 @@ def handle_agnos_update() -> None: set_consistent_flag(False) cloudlog.info(f"Beginning background installation for AGNOS {updated_version}") - set_offroad_alert("Offroad_NeosUpdate", True) - manifest_path = os.path.join(OVERLAY_MERGED, "openpilot/system/hardware/tici/agnos.json") + manifest_path = os.path.join(OVERLAY_MERGED, "openpilot/system/hardware/comma/agnos.json") target_slot_number = get_target_slot_number() flash_agnos_update(manifest_path, target_slot_number, cloudlog) - set_offroad_alert("Offroad_NeosUpdate", False) - class Updater: @@ -342,7 +339,7 @@ class Updater: setup_git_options(OVERLAY_MERGED) output = run(["git", "ls-remote", "--heads"], OVERLAY_MERGED) - self.branches = defaultdict(lambda: None) + self.branches.clear() for line in output.split('\n'): ls_remotes_re = r'(?P\b[0-9a-f]{5,40}\b)(\s+)(refs\/heads\/)(?P.*$)' x = re.fullmatch(ls_remotes_re, line.strip()) diff --git a/openpilot/system/webrtc/device/video.py b/openpilot/system/webrtc/device/video.py index 3c8a7b93c2..cb85dff73e 100644 --- a/openpilot/system/webrtc/device/video.py +++ b/openpilot/system/webrtc/device/video.py @@ -1,10 +1,9 @@ import asyncio +from dataclasses import dataclass import struct import time -import av from teleoprtc.tracks import TiciVideoStreamTrack -from aiortc import MediaStreamError from openpilot.cereal import messaging from openpilot.common.realtime import DT_MDL @@ -22,11 +21,20 @@ TIMING_SEI_UUID = bytes([ _SEI_PREFIX = b'\x00\x00\x00\x01\x06\x05\x30' + TIMING_SEI_UUID +@dataclass(frozen=True) +class EncodedVideoFrame: + data: bytes + pts: int + + def __bytes__(self) -> bytes: + return self.data + + class LiveStreamVideoStreamTrack(TiciVideoStreamTrack): camera_to_sock_mapping = { - "driver": "livestreamDriverEncodeData", + "driver": "livestreamCabinEncodeData", "wideRoad": "livestreamWideRoadEncodeData", - "road": "livestreamRoadEncodeData", + "road": "livestreamNarrowRoadEncodeData", } def __init__(self, camera_type: str, video_enabled: bool = True): @@ -55,6 +63,9 @@ class LiveStreamVideoStreamTrack(TiciVideoStreamTrack): if not enabled: self._seen_keyframe = False + def request_keyframe(self) -> None: + self.params.put("LivestreamRequestKeyframe", True, block=False) + def _build_frame_data(self, msg) -> bytes: encode_data = getattr(msg, msg.which()) if not self.timing_sei_enabled: @@ -71,9 +82,6 @@ class LiveStreamVideoStreamTrack(TiciVideoStreamTrack): async def recv(self): while True: - if self.readyState != "live": - raise MediaStreamError - # while video is disabled, pause here without returning if not self.video_enabled: await asyncio.sleep(0.005) @@ -87,14 +95,7 @@ class LiveStreamVideoStreamTrack(TiciVideoStreamTrack): break await asyncio.sleep(0.005) - packet = av.Packet(self._build_frame_data(msg)) - packet.time_base = self._time_base - self._pts = ((time.monotonic_ns() - self._t0_ns) * self._clock_rate) // 1_000_000_000 - packet.pts = self._pts self.log_debug("track sending frame %d", self._pts) - return packet - - def codec_preference(self) -> str | None: - return "H264" + return EncodedVideoFrame(self._build_frame_data(msg), self._pts) diff --git a/openpilot/system/webrtc/helpers.py b/openpilot/system/webrtc/helpers.py index 776b64573e..87fe20eb20 100644 --- a/openpilot/system/webrtc/helpers.py +++ b/openpilot/system/webrtc/helpers.py @@ -8,7 +8,7 @@ WEBRTCD_PORT = 5001 @dataclass class StreamRequestBody: sdp: str - init_camera: str + cameras: list[str] enabled: bool bridge_services_in: list[str] = field(default_factory=list) bridge_services_out: list[str] = field(default_factory=list) @@ -23,9 +23,9 @@ def post_stream_request(body: StreamRequestBody) -> dict: ret["time"] = (t_end - t_start) * 1000 return ret except requests.ConnectTimeout as e: - raise Exception("webrtc took too long to respond.") from e + raise Exception("device took too long to respond.") from e except requests.ConnectionError as e: - raise Exception("webrtc server on device is not running.") from e + raise Exception("turn car ignition off to use livestreaming.") from e def wait_for_webrtcd(max_retries: float = 10) -> None: @@ -37,4 +37,4 @@ def wait_for_webrtcd(max_retries: float = 10) -> None: except requests.ConnectionError: attempts += 1 time.sleep(0.5) - raise TimeoutError("webrtcd did not initialize in time.") + raise TimeoutError("livestreaming service did not initialize in time.") diff --git a/openpilot/system/webrtc/tests/test_stream_session.py b/openpilot/system/webrtc/tests/test_stream_session.py index e03932f7b2..03d540c3c2 100644 --- a/openpilot/system/webrtc/tests/test_stream_session.py +++ b/openpilot/system/webrtc/tests/test_stream_session.py @@ -1,21 +1,17 @@ import asyncio import json import time -# for aiortc and its dependencies -import warnings -warnings.filterwarnings("ignore", category=DeprecationWarning) -warnings.filterwarnings("ignore", category=RuntimeWarning) # TODO: remove this when google-crc32c publish a python3.12 wheel -from aiortc import RTCDataChannel -from aiortc.mediastreams import VIDEO_CLOCK_RATE, VIDEO_TIME_BASE import capnp +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import messaging, log +from teleoprtc.tracks import VIDEO_CLOCK_RATE -from openpilot.system.webrtc.webrtcd import CerealOutgoingMessageProxy, CerealIncomingMessageProxy +from openpilot.system.webrtc.webrtcd import CerealOutgoingMessageProxy, CerealIncomingMessageProxy, ServerState, handle_get_stream from openpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack -class TestStreamSession: +class TestStreamSession(OpenpilotTestCase): def setup_method(self): self.loop = asyncio.new_event_loop() @@ -31,7 +27,8 @@ class TestStreamSession: expected_dict = {"type": "customReservedRawData0", "logMonoTime": 123, "valid": True, "data": "test"} expected_json = json.dumps(expected_dict).encode() - channel = mocker.Mock(spec=RTCDataChannel) + channel = mocker.Mock() + channel.is_open.return_value = True proxy = CerealOutgoingMessageProxy(["customReservedRawData0"]) def mocked_update(t): proxy.sm.update_msgs(0, [test_msg]) @@ -59,28 +56,32 @@ class TestStreamSession: mocked_pubmaster.send.assert_called_once() mt, md = mocked_pubmaster.send.call_args.args - assert mt == msg["type"] + msg_type = msg["type"] + assert isinstance(msg_type, str) + assert mt == msg_type assert isinstance(md, capnp._DynamicStructBuilder) - assert hasattr(md, msg["type"]) + assert hasattr(md, msg_type) mocked_pubmaster.reset_mock() def test_livestream_track(self, mocker): - fake_msg = messaging.new_message("livestreamDriverEncodeData") + fake_msg = messaging.new_message("livestreamCabinEncodeData") config = {"receive.return_value": fake_msg.to_bytes()} mocker.patch("msgq.SubSocket", spec=True, **config) track = LiveStreamVideoStreamTrack("driver") assert track.id.startswith("driver") - assert track.codec_preference() == "H264" for i in range(5): packet = self.loop.run_until_complete(track.recv()) - assert packet.time_base == VIDEO_TIME_BASE if i == 0: start_ns = time.monotonic_ns() start_pts = packet.pts assert abs(i + packet.pts - (start_pts + (((time.monotonic_ns() - start_ns) * VIDEO_CLOCK_RATE) // 1_000_000_000))) < 450 #5ms - assert packet.size == 0 + assert bytes(packet) == b"" + def test_stream_rejects_non_json_content_type(self): + response = self.loop.run_until_complete(handle_get_stream(ServerState(), b"{}", "text/plain")) + + assert response == (415, b'{"error": "unsupported media type"}', "application/json; charset=utf-8") diff --git a/openpilot/system/webrtc/webrtcd.py b/openpilot/system/webrtc/webrtcd.py index bf96cd04ba..9481e077ab 100755 --- a/openpilot/system/webrtc/webrtcd.py +++ b/openpilot/system/webrtc/webrtcd.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 from abc import abstractmethod +from collections.abc import Callable import os import socket import time +import capnp import argparse import asyncio import contextlib @@ -14,23 +16,20 @@ import signal import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs -from typing import Any, TYPE_CHECKING - -# aiortc and its dependencies have lots of internal warnings :( -import warnings -warnings.filterwarnings("ignore", category=DeprecationWarning) -warnings.filterwarnings("ignore", category=RuntimeWarning) # TODO: remove this when google-crc32c publish a python3.12 wheel - -import capnp -if TYPE_CHECKING: - from aiortc.rtcdatachannel import RTCDataChannel -import aioice.ice +from typing import Any from openpilot.system.webrtc.helpers import StreamRequestBody from openpilot.system.webrtc.schema import generate_field from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog from openpilot.cereal import messaging, log +SESSION_TIMEOUT_SECONDS = 300 + + +# ice candidate parser for logging +def _ice_candidates(sdp: str) -> list[str]: + return [line.removeprefix("a=") for line in sdp.splitlines() if line.startswith("a=candidate:")] # socket trick: route lookup for 8.8.8.8 (nothing is sent or actually connected to) # return the source interfaces IP which is the default interface of the device @@ -44,20 +43,8 @@ def _default_route_ip() -> str | None: finally: s.close() -# aioice patch: gather ICE candidates only on the default-route interface -_get_host_addresses = aioice.ice.get_host_addresses -def _primary_host_addresses(use_ipv4: bool, use_ipv6: bool) -> list[str]: - addresses = _get_host_addresses(use_ipv4, use_ipv6) - primary = _default_route_ip() - if primary not in addresses: - return addresses - return [primary, ] -aioice.ice.get_host_addresses = _primary_host_addresses - - class AsyncTaskRunner: def __init__(self): - self.is_running = False self.task = None self.logger = logging.getLogger("webrtcd") @@ -86,10 +73,10 @@ class CerealOutgoingMessageProxy(AsyncTaskRunner): super().__init__() self.services = list(services) self.sm = messaging.SubMaster(self.services) - self.channels: list[RTCDataChannel] = [] + self.channels = [] self._enabled = enabled - def add_channel(self, channel: 'RTCDataChannel'): + def add_channel(self, channel): self.channels.append(channel) def enable(self, enable: bool): @@ -118,20 +105,17 @@ class CerealOutgoingMessageProxy(AsyncTaskRunner): outgoing_msg = {"type": service, "logMonoTime": mono_time, "valid": valid, "data": msg_dict} encoded_msg = json.dumps(outgoing_msg).encode() for channel in self.channels: + if not channel.is_open(): + continue channel.send(encoded_msg) async def run(self): - from aiortc.exceptions import InvalidStateError - while True: if not self._enabled: await asyncio.sleep(0.01) continue try: self.update() - except InvalidStateError: - self.logger.warning("Cereal outgoing proxy invalid state (connection closed)") - break except Exception: self.logger.exception("Cereal outgoing proxy failure") await asyncio.sleep(0.01) @@ -172,17 +156,17 @@ class LivestreamBitrateController(AsyncTaskRunner): high_level = 0.1 # drop immediately med_level = 0.05 # drop after # of samples low_level = 0 # raise after # of samples - down_samples = 5 # 1s + down_samples = 5 param_name = "LivestreamEncoderBitrate" - def __init__(self, peer_connection: Any, params: Params, enabled: bool = True): + def __init__(self, get_stats: Callable[[], dict[str, Any]], params: Params, enabled: bool = True): super().__init__() - self.pc = peer_connection + self.get_stats = get_stats self.params = params self.level = 2 self._publish(self.bitrates[self.level]) - self.prev_lost, self.prev_sent = None, None + self.prev_stats: tuple[Any, ...] | None = None self.counter = 0 self.up_samples = 5 # 1s self._auto = True @@ -199,7 +183,7 @@ class LivestreamBitrateController(AsyncTaskRunner): if not self._auto: continue - loss_rate = await self._sample() + loss_rate = self._sample() if loss_rate is None: continue if loss_rate >= self.med_level and self.level > 0: @@ -216,22 +200,18 @@ class LivestreamBitrateController(AsyncTaskRunner): self.counter = 0 self._publish(self.bitrates[self.level]) - async def _sample(self) -> float | None: - report = await self.pc.getStats() - packets_lost = packets_sent = 0 - for s in report.values(): - if s.type == "remote-inbound-rtp": - packets_lost += s.packetsLost - elif s.type == "outbound-rtp": - packets_sent += s.packetsSent - - if self.prev_lost is None: - self.prev_lost, self.prev_sent = packets_lost, packets_sent + def _sample(self) -> float | None: + report = next(iter(self.get_stats().values()), None) + if report is None: return None - lost_delta = max(0, packets_lost - self.prev_lost) - sent_delta = max(0, packets_sent - self.prev_sent) - self.prev_lost, self.prev_sent = packets_lost, packets_sent - return lost_delta / sent_delta if sent_delta else 0.0 + + current = (report.ssrc, report.fraction_lost, report.packets_lost, report.highest_seq_no, report.jitter, report.lsr, report.dlsr) + if self.prev_stats == current: + return None + self.prev_stats = current + + loss_rate = report.fraction_lost / 256 + return loss_rate def _publish(self, bitrate: float): self.params.put(self.param_name, bitrate) @@ -247,21 +227,24 @@ class LivestreamBitrateController(AsyncTaskRunner): class StreamSession: shared_pub_master = DynamicPubMaster([]) - def __init__(self, body: StreamRequestBody, debug_mode: bool = False): - if debug_mode: - from aiortc.mediastreams import VideoStreamTrack + def __init__(self, body: StreamRequestBody): from openpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack from teleoprtc.builder import WebRTCAnswerBuilder self.identifier = str(uuid.uuid4()) self.params = Params() - builder = WebRTCAnswerBuilder(body.sdp) + builder = WebRTCAnswerBuilder(body.sdp, bind_address=_default_route_ip()) self.enabled = body.enabled - self.video_track = LiveStreamVideoStreamTrack(body.init_camera, self.enabled) if not debug_mode else VideoStreamTrack() - builder.add_video_stream(body.init_camera, self.video_track) + self.video_tracks = [] + for camera in body.cameras: + track = LiveStreamVideoStreamTrack(camera, self.enabled) + self.video_tracks.append(track) + builder.add_video_stream(camera, track) self.stream = builder.stream() + self.is_body = "testJoystick" in body.bridge_services_in + self.incoming_bridge: CerealIncomingMessageProxy | None = None self.incoming_bridge_services = body.bridge_services_in self.outgoing_bridge: CerealOutgoingMessageProxy | None = None @@ -270,15 +253,15 @@ class StreamSession: self.incoming_bridge = CerealIncomingMessageProxy(self.shared_pub_master) if len(body.bridge_services_out) > 0: self.outgoing_bridge = CerealOutgoingMessageProxy(body.bridge_services_out, self.enabled) - self.bitrate_controller = LivestreamBitrateController(self.stream.peer_connection, self.params, self.enabled) + self.bitrate_controller = LivestreamBitrateController(self.stream.get_receiver_report_stats, self.params, self.enabled) self.run_task: asyncio.Task | None = None self._cleanup_lock = asyncio.Lock() self._cleanup_done = False self.logger = logging.getLogger("webrtcd") - self.logger.info( - "New stream session (%s), init camera %s, video enabled %s, incoming services %s, outgoing services %s", - self.identifier, body.init_camera, body.enabled, body.bridge_services_in, body.bridge_services_out, + cloudlog.warning( + "New stream session (%s), video cameras %s, video enabled %s, incoming services %s, outgoing services %s", + self.identifier, [t.id for t in self.video_tracks], body.enabled, body.bridge_services_in, body.bridge_services_out, ) def start(self): @@ -303,15 +286,21 @@ class StreamSession: match msg_type: case "livestreamCameraSwitch": - self.video_track.switch_camera(payload["data"]["camera"]) + # only needed for 1 track stream + if len(self.video_tracks) == 1: + self.video_tracks[0].switch_camera(payload["data"]["camera"]) case "livestreamSettings": - self.bitrate_controller.set_quality(payload["data"]["quality"]) + if self.bitrate_controller is not None: + self.bitrate_controller.set_quality(payload["data"]["quality"]) case "livestreamVideoEnable": enabled = payload["data"]["enabled"] self.enabled = enabled - self.video_track.enable(enabled) - self.outgoing_bridge.enable(enabled) - self.bitrate_controller.enable(enabled) + for track in self.video_tracks: + track.enable(enabled) + if self.outgoing_bridge is not None: + self.outgoing_bridge.enable(enabled) + if self.bitrate_controller is not None: + self.bitrate_controller.enable(enabled) if not enabled: self.params.put("LivestreamRequestKeyframe", True) case "clockSync": @@ -320,34 +309,59 @@ class StreamSession: }}) self.stream.get_messaging_channel().send(pong) case "enableTimingSei": - if hasattr(self.video_track, 'timing_sei_enabled'): - self.video_track.timing_sei_enabled = bool(payload["data"]["enabled"]) + for track in self.video_tracks: + track.timing_sei_enabled = bool(payload["data"]["enabled"]) case _: - if payload.get("type") not in self.incoming_bridge_services: + if msg_type not in self.incoming_bridge_services: return - self.incoming_bridge.send(message) + if self.incoming_bridge is not None: + self.incoming_bridge.send(message) except Exception: self.logger.exception("Cereal incoming proxy failure") + async def run_normal_session(self): + try: + await asyncio.wait_for(self.stream.wait_for_disconnection(), timeout=SESSION_TIMEOUT_SECONDS) + except TimeoutError: + self.logger.warning("Stream session (%s) timed out after %d s", self.identifier, SESSION_TIMEOUT_SECONDS) + try: + self.stream.get_messaging_channel().send(json.dumps({"type": "disconnect", "data": "Session timed out"})) + except Exception: + pass + + async def run_body_session(self): + await self.stream.wait_for_disconnection() + async def run(self): try: self.params.put("LivestreamRequestKeyframe", True) + + # avoid datachannel race by adding messange_handler immediately + self.stream.set_message_handler(self.message_handler) + await asyncio.wait_for(self.stream.wait_for_connection(), timeout=15) if self.stream.has_messaging_channel(): - self.stream.set_message_handler(self.message_handler) if self.incoming_bridge is not None: await self.shared_pub_master.add_services_if_needed(self.incoming_bridge_services) if self.outgoing_bridge is not None: channel = self.stream.get_messaging_channel() self.outgoing_bridge.add_channel(channel) self.outgoing_bridge.start() - self.bitrate_controller.start() + if self.bitrate_controller is not None: + self.bitrate_controller.start() - self.logger.info("Stream session (%s) connected", self.identifier) - await self.stream.wait_for_disconnection() - self.logger.info("Stream session (%s) ended", self.identifier) + with cloudlog.ctx(session_id=self.identifier): + cloudlog.warning("webrtcd.session.connected") + if self.is_body: + await self.run_body_session() + else: + await self.run_normal_session() + with cloudlog.ctx(session_id=self.identifier): + cloudlog.warning("webrtcd.session.ended") except Exception: self.logger.exception("Stream session failure") + with cloudlog.ctx(session_id=self.identifier): + cloudlog.exception("webrtcd.session.exception") finally: await self.post_run_cleanup() @@ -357,20 +371,20 @@ class StreamSession: return self._cleanup_done = True self.params.put("LivestreamRequestKeyframe", False) - await self.bitrate_controller.stop() + if self.bitrate_controller is not None: + await self.bitrate_controller.stop() if self.outgoing_bridge is not None: await self.outgoing_bridge.stop() - if self.video_track is not None: - self.video_track.stop() - self.video_track = None + for track in self.video_tracks: + track.stop() + self.video_tracks.clear() await self.stream.stop() class ServerState: - def __init__(self, debug: bool): + def __init__(self): self.streams: dict[str, StreamSession] = {} self.stream_lock = asyncio.Lock() - self.debug = debug self.teardown: asyncio.TimerHandle | None = None @@ -394,8 +408,11 @@ def _text_response(text: str, status: int = 200) -> tuple[int, bytes, str]: return (status, text.encode(), "text/plain; charset=utf-8") -async def handle_get_stream(state: ServerState, raw_body: bytes) -> tuple[int, bytes, str]: - stream_dict, debug_mode = state.streams, state.debug +async def handle_get_stream(state: ServerState, raw_body: bytes, content_type: str) -> tuple[int, bytes, str]: + if content_type != "application/json": + return _json_response({"error": "unsupported media type"}, status=415) + + stream_dict = state.streams body = StreamRequestBody(**json.loads(raw_body)) async with state.stream_lock: @@ -414,14 +431,29 @@ async def handle_get_stream(state: ServerState, raw_body: bytes) -> tuple[int, b await s.stop() stream_dict.pop(sid, None) - session = StreamSession(body, debug_mode) + session = StreamSession(body) stream_dict[session.identifier] = session try: - answer = await session.get_answer() + answer = await asyncio.wait_for(session.get_answer(), timeout=30) + cloudlog.event( + "webrtcd.session.ice_candidates", + session_id=session.identifier, + offer_candidates=_ice_candidates(body.sdp), + answer_candidates=_ice_candidates(answer.sdp), + ) + except TimeoutError: + await session.stop() + stream_dict.pop(session.identifier, None) + logging.getLogger("webrtcd").exception("Timed out creating stream answer") + with cloudlog.ctx(session_id=session.identifier): + cloudlog.warning("webrtcd.session.answer_timeout") + raise except Exception: await session.stop() stream_dict.pop(session.identifier, None) logging.getLogger("webrtcd").exception("Failed to create stream answer") + with cloudlog.ctx(session_id=session.identifier): + cloudlog.exception("webrtcd.session.answer_exception") raise session.start() @@ -502,7 +534,7 @@ class WebrtcdHandler(BaseHTTPRequestHandler): services = parse_qs(parsed.query).get("services", [""])[0] result = self._run(handle_get_schema(self.server.state, services)) elif parsed.path == "/stream": - result = self._run(handle_get_stream(self.server.state, self._read_body())) + result = self._run(handle_get_stream(self.server.state, self._read_body(), self.headers.get_content_type())) else: # /notify try: payload = json.loads(self._read_body()) @@ -537,7 +569,7 @@ class WebrtcdHandler(BaseHTTPRequestHandler): def do_OPTIONS(self) -> None: self._dispatch_request() - def log_message(self, fmt, *args) -> None: + def log_message(self, format: str, *args: object) -> None: # noqa: A002 # stdlib override # silence default access logging; errors are logged explicitly in _dispatch_request pass @@ -557,26 +589,23 @@ async def _shutdown(server: WebrtcdHTTPServer, state: ServerState, loop: asyncio loop.stop() -def prewarm_stream_session_imports(debug_mode: bool = False) -> None: - if debug_mode: - from aiortc.mediastreams import VideoStreamTrack - assert VideoStreamTrack +def prewarm_stream_session_imports() -> None: from openpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack from teleoprtc.builder import WebRTCAnswerBuilder assert LiveStreamVideoStreamTrack assert WebRTCAnswerBuilder -def webrtcd_thread(host: str, port: int, debug: bool): - logging.basicConfig(level=logging.CRITICAL, handlers=[logging.StreamHandler()]) +def webrtcd_thread(host: str, port: int): + logging.basicConfig(level=logging.INFO, handlers=[logging.StreamHandler()]) prewarm_start = time.monotonic() - prewarm_stream_session_imports(debug) + prewarm_stream_session_imports() prewarm_end = time.monotonic() logging.getLogger("webrtcd").info(f"webrtc prewarm finished in {(prewarm_end - prewarm_start) * 1000} ms") loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - state = ServerState(debug) + state = ServerState() server = WebrtcdHTTPServer((host, port), WebrtcdHandler) server.state = state @@ -587,13 +616,14 @@ def webrtcd_thread(host: str, port: int, debug: bool): http_thread.start() shutting_down = False + shutdown_task = None def request_shutdown() -> None: - nonlocal shutting_down + nonlocal shutting_down, shutdown_task if shutting_down: return shutting_down = True - loop.create_task(_shutdown(server, state, loop)) + shutdown_task = loop.create_task(_shutdown(server, state, loop)) for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, request_shutdown) @@ -607,12 +637,11 @@ def webrtcd_thread(host: str, port: int, debug: bool): def main(): parser = argparse.ArgumentParser(description="WebRTC daemon") - parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to listen on") + parser.add_argument("--host", type=str, default="127.0.0.1", help="Host to listen on") parser.add_argument("--port", type=int, default=5001, help="Port to listen on") - parser.add_argument("--debug", action="store_true", help="Enable debug mode") args = parser.parse_args() - webrtcd_thread(args.host, args.port, args.debug) + webrtcd_thread(args.host, args.port) if __name__=="__main__": diff --git a/openpilot/test_native.py b/openpilot/test_native.py new file mode 100644 index 0000000000..eed549f4e4 --- /dev/null +++ b/openpilot/test_native.py @@ -0,0 +1,22 @@ +import os +import subprocess + +from openpilot.common.basedir import BASEDIR +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase + + +NATIVE_TESTS = ( + "openpilot/common/tests/test_swaglog", + "openpilot/selfdrive/pandad/tests/test_pandad_canprotocol", + "openpilot/tools/cabana/tests/test_dbc_core", +) + + +class TestNative(OpenpilotTestCase): + @parameterized.expand(NATIVE_TESTS) + def test_native(self, executable): + path = os.path.join(BASEDIR, executable) + if not os.path.exists(path): + self.skipTest(f"optional native test was not built: {executable}") + subprocess.run([path], check=True) diff --git a/openpilot/tools/cabana/.gitignore b/openpilot/tools/cabana/.gitignore index 927b05e34a..7f9ac0fde0 100644 --- a/openpilot/tools/cabana/.gitignore +++ b/openpilot/tools/cabana/.gitignore @@ -3,7 +3,9 @@ moc_* *.generated.qrc assets.cc +bootstrap_icons.cc _cabana dbc/car_fingerprint_to_dbc.json tests/test_cabana +tests/test_dbc_core diff --git a/openpilot/tools/cabana/README.md b/openpilot/tools/cabana/README.md index b30c29640e..fbdfbb40e5 100644 --- a/openpilot/tools/cabana/README.md +++ b/openpilot/tools/cabana/README.md @@ -15,7 +15,7 @@ Options: --auto Auto load the route from the best available source (no video): internal, openpilotci, comma_api, car_segments, testing_closet --qcam load qcamera - --ecam load wide road camera + --wide-road load wide road camera --msgq read can messages from msgq --panda read can messages from panda --panda-serial read can messages from panda with given serial @@ -55,7 +55,7 @@ Replace "5beb9b58bd12b691/0000010a--a51155e496" with your desired route identifi To run Cabana with multiple cameras, use the following command: ```shell -cabana "5beb9b58bd12b691/0000010a--a51155e496" --dcam --ecam +cabana "5beb9b58bd12b691/0000010a--a51155e496" --cabin --wide-road ``` ### Streaming CAN Messages from a comma Device diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 26c20ddba1..387129709e 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -30,7 +30,7 @@ if arch == "Darwin": ] qt_dirs += [f"{qt_env['QTDIR']}/include/Qt{m}" for m in qt_modules] qt_env["LINKFLAGS"] += ["-F" + os.path.join(qt_env['QTDIR'], "lib")] - qt_env["FRAMEWORKS"] += [f"Qt{m}" for m in qt_modules] + ["OpenGL"] + qt_env["FRAMEWORKS"] += [f"Qt{m}" for m in qt_modules] qt_env.AppendENVPath('PATH', os.path.join(qt_env['QTDIR'], "bin")) else: qt_install_prefix = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_PREFIX'], encoding='utf8').strip() @@ -46,7 +46,7 @@ else: qt_dirs += [f"{qt_install_headers}/QtGui/{qt_gui_dirs[0]}/QtGui", ] if qt_gui_dirs else [] qt_dirs += [f"{qt_install_headers}/Qt{m}" for m in qt_modules] - qt_libs = [f"Qt5{m}" for m in qt_modules] + ["GL"] + qt_libs = [f"Qt5{m}" for m in qt_modules] qt_env['QT3DIR'] = qt_env['QTDIR'] qt_env.Tool('qt3') @@ -67,48 +67,60 @@ base_frameworks = qt_env['FRAMEWORKS'] base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] + qt_env["LIBS"] if arch == "Darwin": - base_frameworks += ['QtCharts', 'CoreFoundation', 'CoreVideo', 'CoreMedia', 'IOKit', 'Security', 'VideoToolbox'] -else: - base_libs.append('Qt5Charts') + base_frameworks += ['CoreFoundation', 'CoreVideo', 'CoreMedia', 'IOKit', 'Security', 'VideoToolbox'] cabana_env = qt_env.Clone() cabana_env['CPPPATH'] += [libusb.INCLUDE_DIR] cabana_env['LIBPATH'] += [libusb.LIB_DIR] -cabana_libs = [cereal, messaging, visionipc, replay_lib] + ffmpeg_libs + ['bz2', 'zstd', 'usb-1.0'] + base_libs +cabana_libs = [cereal, messaging, visionipc, replay_lib] + ffmpeg_libs + ['usb-1.0'] + base_libs opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../../opendbc_repo/opendbc/dbc").abspath) cabana_env['CXXFLAGS'] += [opendbc_path] -def write_assets_qrc(target, source, env): - with open(str(source[0])) as f: - qrc = f.read() - with open(str(target[0]), "w") as f: - f.write(qrc.replace("@BOOTSTRAP_ICONS_SVG@", str(bootstrap_icons.SVG_PATH))) +# embed the bootstrap icons SVG into the binary +def build_bootstrap_icons_src(target, source, env): + data = open(str(source[0]), 'rb').read() + with open(str(target[0]), 'w') as f: + f.write('#include \n') + f.write('extern const unsigned char bootstrap_icons_svg[];\n') + f.write('extern const size_t bootstrap_icons_svg_len;\n') + f.write('const unsigned char bootstrap_icons_svg[] = {\n') + for i in range(0, len(data), 32): + f.write(','.join(str(b) for b in data[i:i+32]) + ',\n') + f.write('};\n') + f.write('const size_t bootstrap_icons_svg_len = sizeof(bootstrap_icons_svg);\n') + return None + +bootstrap_icons_src = cabana_env.Command('assets/bootstrap_icons.cc', str(bootstrap_icons.SVG_PATH), build_bootstrap_icons_src) # build assets assets = "assets/assets.cc" -assets_src = cabana_env.Command( - "assets/assets.generated.qrc", - "assets/assets.qrc", - write_assets_qrc, -) -cabana_env.Command(assets, assets_src, f"rcc $SOURCES -o $TARGET") -cabana_env.Depends(assets_src, str(bootstrap_icons.SVG_PATH)) -cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, assets_src, "assets/assets.o"])) +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', + 'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'dbc/dbcqt.cc', 'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc', 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] if arch != "Darwin": cabana_srcs += ['streams/socketcanstream.cc'] -cabana_lib = cabana_env.Library("cabana_lib", cabana_srcs, LIBS=cabana_libs, FRAMEWORKS=base_frameworks) +cabana_lib = cabana_env.Library("cabana_lib", cabana_srcs + [bootstrap_icons_src], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) cabana_env.Program('_cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) if GetOption('extras'): - cabana_env.Program('tests/test_cabana', ['tests/test_runner.cc', 'tests/test_cabana.cc', cabana_lib], LIBS=[cabana_libs]) + # This target deliberately uses the base environment and links no Qt libraries. + # It prevents Qt dependencies from creeping back into the DBC core. + dbc_core_test_env = env.Clone() + dbc_core_test_env['CXXFLAGS'] += [opendbc_path] + dbc_core_test_objects = [ + dbc_core_test_env.Object('tests/dbc_core_tests', 'tests/test_cabana.cc'), + 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.Program('tests/test_dbc_core', dbc_core_test_objects) 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/assets/assets.qrc b/openpilot/tools/cabana/assets/assets.qrc index f5880e5580..009d63f008 100644 --- a/openpilot/tools/cabana/assets/assets.qrc +++ b/openpilot/tools/cabana/assets/assets.qrc @@ -1,6 +1,5 @@ - @BOOTSTRAP_ICONS_SVG@ cabana-icon.png diff --git a/openpilot/tools/cabana/binaryview.cc b/openpilot/tools/cabana/binaryview.cc index c86b3ebdae..5e919dc6a3 100644 --- a/openpilot/tools/cabana/binaryview.cc +++ b/openpilot/tools/cabana/binaryview.cc @@ -1,8 +1,10 @@ #include "tools/cabana/binaryview.h" +#include "tools/cabana/dbc/dbcqt.h" #include -#include +#include + #include #include #include @@ -34,8 +36,8 @@ BinaryView::BinaryView(QWidget *parent) : QTableView(parent) { setMouseTracking(true); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &BinaryView::refresh); - QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &BinaryView::refresh); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &BinaryView::refresh); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &BinaryView::refresh); addShortcuts(); setWhatsThis(R"( @@ -64,7 +66,7 @@ void BinaryView::addShortcuts() { QObject::connect(shortcut_delete_backspace, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated); QObject::connect(shortcut_delete_x, &QShortcut::activated, [=]{ if (hovered_sig != nullptr) { - UndoStack::push(new RemoveSigCommand(model->msg_id, hovered_sig)); + UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, hovered_sig)); hovered_sig = nullptr; } }); @@ -124,7 +126,7 @@ void BinaryView::highlight(const cabana::Signal *sig) { } void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) { - auto index = indexAt(viewport()->mapFromGlobal(QCursor::pos())); + auto index = indexAt(last_mouse_pos); if (!anchor_index.isValid() || !index.isValid()) return; @@ -139,7 +141,7 @@ void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionF void BinaryView::mousePressEvent(QMouseEvent *event) { resize_sig = nullptr; - if (auto index = indexAt(event->pos()); index.isValid() && index.column() != 8) { + if (auto index = indexAt(last_mouse_pos = event->pos()); index.isValid() && index.column() != 8) { anchor_index = index; auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer(); int bit_pos = get_bit_pos(anchor_index); @@ -156,7 +158,7 @@ void BinaryView::mousePressEvent(QMouseEvent *event) { } void BinaryView::highlightPosition(const QPoint &pos) { - if (auto index = indexAt(viewport()->mapFromGlobal(pos)); index.isValid()) { + if (auto index = indexAt(pos); index.isValid()) { auto item = (BinaryViewModel::Item *)index.internalPointer(); const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); highlight(sig); @@ -164,7 +166,7 @@ void BinaryView::highlightPosition(const QPoint &pos) { } void BinaryView::mouseMoveEvent(QMouseEvent *event) { - highlightPosition(event->globalPos()); + highlightPosition(last_mouse_pos = event->pos()); QTableView::mouseMoveEvent(event); } @@ -177,7 +179,7 @@ void BinaryView::mouseReleaseEvent(QMouseEvent *event) { auto sig = resize_sig ? *resize_sig : cabana::Signal{}; std::tie(sig.start_bit, sig.size, sig.is_little_endian) = getSelection(release_index); resize_sig ? emit editSignal(resize_sig, sig) - : UndoStack::push(new AddSigCommand(model->msg_id, sig)); + : UndoStack::instance()->push(new AddSigCommand(model->msg_id, sig)); } else { auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer(); if (item && item->sigs.size() > 0) @@ -206,7 +208,7 @@ void BinaryView::refresh() { resize_sig = nullptr; hovered_sig = nullptr; model->refresh(); - highlightPosition(QCursor::pos()); + if (underMouse()) highlightPosition(last_mouse_pos); } std::set BinaryView::getOverlappingSignals() const { @@ -259,7 +261,8 @@ void BinaryViewModel::refresh() { int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j; int idx = column_count * (pos / 8) + pos % 8; if (idx >= items.size()) { - qWarning() << "signal " << sig->name.c_str() << "out of bounds.start_bit:" << sig->start_bit << "size:" << sig->size; + fprintf(stderr, "signal %s out of bounds.start_bit: %d size: %d\n", + sig->name.c_str(), sig->start_bit, sig->size); break; } if (j == 0) sig->is_little_endian ? items[idx].is_lsb = true : items[idx].is_msb = true; @@ -334,7 +337,7 @@ void BinaryViewModel::updateState() { color.setAlpha(alpha); updateItem(i, j, bit_val, color); } - updateItem(i, 8, binary[i], last_msg.colors[i]); + updateItem(i, 8, binary[i], toQColor(last_msg.colors[i])); } } @@ -421,14 +424,14 @@ void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op painter->fillRect(option.rect, item->bg_color); } } else if (option.state & QStyle::State_Selected) { - auto color = bin_view->resize_sig ? bin_view->resize_sig->color : option.palette.color(QPalette::Active, QPalette::Highlight); + auto color = bin_view->resize_sig ? toQColor(bin_view->resize_sig->color) : option.palette.color(QPalette::Active, QPalette::Highlight); painter->fillRect(option.rect, color); painter->setPen(option.palette.color(QPalette::BrightText)); } else if (!bin_view->selectionModel()->hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), bin_view->resize_sig) == item->sigs.end()) { // not resizing if (item->sigs.size() > 0) { for (auto &s : item->sigs) { if (s == bin_view->hovered_sig) { - painter->fillRect(option.rect, s->color.darker(125)); // 4/5x brightness + painter->fillRect(option.rect, toQColor(s->color.darker(125))); // 4/5x brightness } else { drawSignalCell(painter, option, index, s); } @@ -483,14 +486,14 @@ void BinaryItemDelegate::drawSignalCell(QPainter *painter, const QStyleOptionVie painter->setClipRegion(QRegion(rc).subtracted(subtract)); auto item = (const BinaryViewModel::Item *)index.internalPointer(); - QColor color = sig->color; + QColor color = toQColor(sig->color); color.setAlpha(item->bg_color.alpha()); // Mixing the signal color with the Base background color to fade it painter->fillRect(rc, option.palette.color(QPalette::Base)); painter->fillRect(rc, color); // Draw edges - color = sig->color.darker(125); + color = toQColor(sig->color.darker(125)); painter->setPen(QPen(color, 1)); if (draw_left) painter->drawLine(rc.topLeft(), rc.bottomLeft()); if (draw_right) painter->drawLine(rc.topRight(), rc.bottomRight()); diff --git a/openpilot/tools/cabana/binaryview.h b/openpilot/tools/cabana/binaryview.h index e568228b37..c49067a1a2 100644 --- a/openpilot/tools/cabana/binaryview.h +++ b/openpilot/tools/cabana/binaryview.h @@ -94,6 +94,7 @@ private: void highlightPosition(const QPoint &pt); QModelIndex anchor_index; + QPoint last_mouse_pos{-1, -1}; BinaryViewModel *model; BinaryItemDelegate *delegate; bool is_message_active = false; diff --git a/openpilot/tools/cabana/cabana.cc b/openpilot/tools/cabana/cabana.cc index db26b4067a..8b21143faf 100644 --- a/openpilot/tools/cabana/cabana.cc +++ b/openpilot/tools/cabana/cabana.cc @@ -1,5 +1,9 @@ +#include +#include +#include +#include + #include -#include #include "tools/cabana/mainwin.h" #include "tools/cabana/streams/devicestream.h" @@ -9,80 +13,175 @@ #include "tools/cabana/streams/socketcanstream.h" #endif +namespace { + +struct CabanaArgs { + bool demo = false; + bool auto_source = false; + bool qcam = false; + bool wide_road = false; + bool cabin = false; + bool msgq = false; + bool panda = false; + bool no_vipc = false; + std::string panda_serial; + std::string socketcan; + std::string zmq; + std::string data_dir; + std::string dbc; + std::string route; +}; + +void printUsage(const char *argv0) { + fprintf(stderr, + "Usage: %s [options] [route]\n" + "\n" + " route the drive to replay. find your drives at connect.comma.ai\n" + "\n" + "Options:\n" + " --help show this help\n" + " --demo use a demo route instead of providing your own\n" + " --auto Auto load the route from the best available source (no video):\n" + " internal, openpilotci, comma_api, car_segments, testing_closet\n" + " --qcam load qcamera\n" + " --wide-road load wide road camera (alias: --ecam)\n" + " --cabin load cabin camera (alias: --dcam)\n" + " --msgq read can messages from the msgq\n" + " --panda read can messages from panda\n" + " --panda-serial read can messages from panda with given serial\n" +#ifdef __linux__ + " --socketcan read can messages from given SocketCAN device\n" +#endif + " --zmq read can messages from zmq at the specified ip-address\n" + " --data_dir local directory with routes\n" + " --no-vipc do not output video\n" + " --dbc dbc file to open\n", + argv0); +} + +// Returns true if value was consumed from argv[i+1]. +bool takeValue(int argc, char *argv[], int &i, std::string &out) { + if (i + 1 >= argc) { + fprintf(stderr, "error: %s requires a value\n", argv[i]); + return false; + } + out = argv[++i]; + return true; +} + +// Returns 0 to continue, or a process exit code (0 for --help, 1 for errors). +int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { + ok = false; + for (int i = 1; i < argc; ++i) { + const char *a = argv[i]; + if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) { + printUsage(argv[0]); + return 0; + } else if (std::strcmp(a, "--demo") == 0) { + args.demo = true; + } else if (std::strcmp(a, "--auto") == 0) { + args.auto_source = true; + } else if (std::strcmp(a, "--qcam") == 0) { + args.qcam = true; + } else if (std::strcmp(a, "--wide-road") == 0 || std::strcmp(a, "--ecam") == 0) { + args.wide_road = true; + } else if (std::strcmp(a, "--cabin") == 0 || std::strcmp(a, "--dcam") == 0) { + args.cabin = true; + } else if (std::strcmp(a, "--msgq") == 0) { + args.msgq = true; + } else if (std::strcmp(a, "--panda") == 0) { + args.panda = true; + } else if (std::strcmp(a, "--panda-serial") == 0) { + if (!takeValue(argc, argv, i, args.panda_serial)) return 1; + args.panda = true; + } else if (std::strcmp(a, "--socketcan") == 0) { + if (!takeValue(argc, argv, i, args.socketcan)) return 1; +#ifdef __linux__ +#else + fprintf(stderr, "error: --socketcan is only supported on Linux\n"); + return 1; +#endif + } else if (std::strcmp(a, "--zmq") == 0) { + if (!takeValue(argc, argv, i, args.zmq)) return 1; + } else if (std::strcmp(a, "--data_dir") == 0) { + if (!takeValue(argc, argv, i, args.data_dir)) return 1; + } else if (std::strcmp(a, "--no-vipc") == 0) { + args.no_vipc = true; + } else if (std::strcmp(a, "--dbc") == 0) { + if (!takeValue(argc, argv, i, args.dbc)) return 1; + } else if (a[0] == '-') { + fprintf(stderr, "error: unknown option %s\n", a); + printUsage(argv[0]); + return 1; + } else if (args.route.empty()) { + args.route = a; + } else { + fprintf(stderr, "error: unexpected argument %s\n", a); + printUsage(argv[0]); + return 1; + } + } + ok = true; + return 0; +} + +} // namespace + int main(int argc, char *argv[]) { QCoreApplication::setApplicationName("Cabana"); - QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts); initApp(argc, argv, false); QApplication app(argc, argv); app.setApplicationDisplayName("Cabana"); - app.setWindowIcon(QIcon(":cabana-icon.png")); + //app.setWindowIcon(QIcon(":cabana-icon.png")); // TODO: do this in imgui UnixSignalHandler signalHandler; utils::setTheme(settings.theme); - QCommandLineParser cmd_parser; - cmd_parser.addHelpOption(); - cmd_parser.addPositionalArgument("route", "the drive to replay. find your drives at connect.comma.ai"); - cmd_parser.addOption({"demo", "use a demo route instead of providing your own"}); - cmd_parser.addOption({"auto", "Auto load the route from the best available source (no video): internal, openpilotci, comma_api, car_segments, testing_closet"}); - cmd_parser.addOption({"qcam", "load qcamera"}); - cmd_parser.addOption({"ecam", "load wide road camera"}); - cmd_parser.addOption({"dcam", "load driver camera"}); - cmd_parser.addOption({"msgq", "read can messages from the msgq"}); - cmd_parser.addOption({"panda", "read can messages from panda"}); - cmd_parser.addOption({"panda-serial", "read can messages from panda with given serial", "panda-serial"}); -#ifdef __linux__ - if (SocketCanStream::available()) { - cmd_parser.addOption({"socketcan", "read can messages from given SocketCAN device", "socketcan"}); + CabanaArgs args; + bool args_ok = false; + if (const int code = parseArgs(argc, argv, args, args_ok); !args_ok) { + return code; } -#endif - cmd_parser.addOption({"zmq", "read can messages from zmq at the specified ip-address", "ip-address"}); - cmd_parser.addOption({"data_dir", "local directory with routes", "data_dir"}); - cmd_parser.addOption({"no-vipc", "do not output video"}); - cmd_parser.addOption({"dbc", "dbc file to open", "dbc"}); - cmd_parser.process(app); AbstractStream *stream = nullptr; - if (cmd_parser.isSet("msgq")) { + if (args.msgq) { stream = new DeviceStream(&app); - } else if (cmd_parser.isSet("zmq")) { - stream = new DeviceStream(&app, cmd_parser.value("zmq")); - } else if (cmd_parser.isSet("panda") || cmd_parser.isSet("panda-serial")) { + } else if (!args.zmq.empty()) { + stream = new DeviceStream(&app, QString::fromStdString(args.zmq)); + } else if (args.panda || !args.panda_serial.empty()) { try { - stream = new PandaStream(&app, {.serial = cmd_parser.value("panda-serial").toStdString()}); + stream = new PandaStream(&app, {.serial = args.panda_serial}); } catch (std::exception &e) { - qWarning() << e.what(); + fprintf(stderr, "%s\n", e.what()); return 0; } #ifdef __linux__ - } else if (SocketCanStream::available() && cmd_parser.isSet("socketcan")) { - stream = new SocketCanStream(&app, {.device = cmd_parser.value("socketcan").toStdString()}); + } else if (SocketCanStream::available() && !args.socketcan.empty()) { + stream = new SocketCanStream(&app, {.device = args.socketcan}); #endif } else { uint32_t replay_flags = REPLAY_FLAG_NONE; - if (cmd_parser.isSet("ecam")) replay_flags |= REPLAY_FLAG_ECAM; - if (cmd_parser.isSet("qcam")) replay_flags |= REPLAY_FLAG_QCAMERA; - if (cmd_parser.isSet("dcam")) replay_flags |= REPLAY_FLAG_DCAM; - if (cmd_parser.isSet("no-vipc")) replay_flags |= REPLAY_FLAG_NO_VIPC; + if (args.wide_road) replay_flags |= REPLAY_FLAG_WIDE_ROAD; + if (args.qcam) replay_flags |= REPLAY_FLAG_QCAMERA; + if (args.cabin) replay_flags |= REPLAY_FLAG_CABIN_CAMERA; + if (args.no_vipc) replay_flags |= REPLAY_FLAG_NO_VIPC; - const QStringList args = cmd_parser.positionalArguments(); QString route; - if (args.size() > 0) { - route = args.first(); - } else if (cmd_parser.isSet("demo")) { + if (!args.route.empty()) { + route = QString::fromStdString(args.route); + } else if (args.demo) { route = DEMO_ROUTE; } if (!route.isEmpty()) { auto replay_stream = std::make_unique(&app); - bool auto_source = cmd_parser.isSet("auto"); - if (!replay_stream->loadRoute(route.toStdString(), cmd_parser.value("data_dir").toStdString(), replay_flags, auto_source)) { + if (!replay_stream->loadRoute(route.toStdString(), args.data_dir, replay_flags, args.auto_source)) { return 0; } stream = replay_stream.release(); } } - MainWindow w(stream, cmd_parser.value("dbc")); + MainWindow w(stream, QString::fromStdString(args.dbc)); return app.exec(); } diff --git a/openpilot/tools/cabana/cameraview.cc b/openpilot/tools/cabana/cameraview.cc index 13c838efd8..9bd6b9be19 100644 --- a/openpilot/tools/cabana/cameraview.cc +++ b/openpilot/tools/cabana/cameraview.cc @@ -1,137 +1,40 @@ #include "tools/cabana/cameraview.h" -#ifdef __APPLE__ -#include -#else -#include -#endif +#include +#include +#include +#include #include +#include -namespace { - -const char frame_vertex_shader[] = -#ifdef __APPLE__ - "#version 330 core\n" -#else - "#version 300 es\n" -#endif - "layout(location = 0) in vec4 aPosition;\n" - "layout(location = 1) in vec2 aTexCoord;\n" - "uniform mat4 uTransform;\n" - "out vec2 vTexCoord;\n" - "void main() {\n" - " gl_Position = uTransform * aPosition;\n" - " vTexCoord = aTexCoord;\n" - "}\n"; - -const char frame_fragment_shader[] = -#ifdef __APPLE__ - "#version 330 core\n" -#else - "#version 300 es\n" - "precision mediump float;\n" -#endif - "uniform sampler2D uTextureY;\n" - "uniform sampler2D uTextureUV;\n" - "in vec2 vTexCoord;\n" - "out vec4 colorOut;\n" - "void main() {\n" - " float y = texture(uTextureY, vTexCoord).r;\n" - " vec2 uv = texture(uTextureUV, vTexCoord).rg - 0.5;\n" - " float r = y + 1.402 * uv.y;\n" - " float g = y - 0.344 * uv.x - 0.714 * uv.y;\n" - " float b = y + 1.772 * uv.x;\n" - " colorOut = vec4(r, g, b, 1.0);\n" - "}\n"; - -} // namespace +#include "common/yuv.h" CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type, QWidget* parent) : - stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QOpenGLWidget(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::vipcThreadConnected, this, &CameraWidget::vipcConnected, Qt::BlockingQueuedConnection); 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); } CameraWidget::~CameraWidget() { - makeCurrent(); stopVipcThread(); - if (isValid()) { - glDeleteVertexArrays(1, &frame_vao); - glDeleteBuffers(1, &frame_vbo); - glDeleteBuffers(1, &frame_ibo); - glDeleteTextures(2, textures); - shader_program_.reset(); - } - doneCurrent(); -} - -void CameraWidget::initializeGL() { - initializeOpenGLFunctions(); - - shader_program_ = std::make_unique(context()); - shader_program_->addShaderFromSourceCode(QOpenGLShader::Vertex, frame_vertex_shader); - shader_program_->addShaderFromSourceCode(QOpenGLShader::Fragment, frame_fragment_shader); - shader_program_->link(); - - GLint frame_pos_loc = shader_program_->attributeLocation("aPosition"); - GLint frame_texcoord_loc = shader_program_->attributeLocation("aTexCoord"); - - auto [x1, x2, y1, y2] = requested_stream_type == VISION_STREAM_DRIVER ? std::tuple(0.f, 1.f, 1.f, 0.f) : std::tuple(1.f, 0.f, 1.f, 0.f); - const uint8_t frame_indicies[] = {0, 1, 2, 0, 2, 3}; - const float frame_coords[4][4] = { - {-1.0, -1.0, x2, y1}, // bl - {-1.0, 1.0, x2, y2}, // tl - { 1.0, 1.0, x1, y2}, // tr - { 1.0, -1.0, x1, y1}, // br - }; - - glGenVertexArrays(1, &frame_vao); - glBindVertexArray(frame_vao); - glGenBuffers(1, &frame_vbo); - glBindBuffer(GL_ARRAY_BUFFER, frame_vbo); - glBufferData(GL_ARRAY_BUFFER, sizeof(frame_coords), frame_coords, GL_STATIC_DRAW); - glEnableVertexAttribArray(frame_pos_loc); - glVertexAttribPointer(frame_pos_loc, 2, GL_FLOAT, GL_FALSE, - sizeof(frame_coords[0]), (const void *)0); - glEnableVertexAttribArray(frame_texcoord_loc); - glVertexAttribPointer(frame_texcoord_loc, 2, GL_FLOAT, GL_FALSE, - sizeof(frame_coords[0]), (const void *)(sizeof(float) * 2)); - glGenBuffers(1, &frame_ibo); - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, frame_ibo); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(frame_indicies), frame_indicies, GL_STATIC_DRAW); - glBindBuffer(GL_ARRAY_BUFFER, 0); - glBindVertexArray(0); - - glGenTextures(2, textures); - - shader_program_->bind(); - shader_program_->setUniformValue("uTextureY", 0); - shader_program_->setUniformValue("uTextureUV", 1); - shader_program_->release(); } void CameraWidget::showEvent(QShowEvent *event) { - if (!vipc_thread) { + if (!vipc_thread.joinable()) { clearFrames(); - vipc_thread = new QThread(); - connect(vipc_thread, &QThread::started, [=]() { vipcThread(); }); - connect(vipc_thread, &QThread::finished, vipc_thread, &QObject::deleteLater); - vipc_thread->start(); + vipc_exit = false; + vipc_thread = std::thread(&CameraWidget::vipcThread, this); } } void CameraWidget::stopVipcThread() { - makeCurrent(); - if (vipc_thread) { - vipc_thread->requestInterruption(); - vipc_thread->quit(); - vipc_thread->wait(); - vipc_thread = nullptr; + vipc_exit = true; + if (vipc_thread.joinable()) { + vipc_thread.join(); } } @@ -139,74 +42,29 @@ void CameraWidget::availableStreamsUpdated(std::set streams) { available_streams = streams; } -void CameraWidget::paintGL() { - glClearColor(bg.redF(), bg.greenF(), bg.blueF(), bg.alphaF()); - glClear(GL_STENCIL_BUFFER_BIT | GL_COLOR_BUFFER_BIT); +void CameraWidget::paintEvent(QPaintEvent *event) { + QPainter p(this); + p.fillRect(rect(), bg); std::lock_guard lk(frame_lock); - if (!current_frame_) return; + if (rgb_frame.isNull()) return; // Scale for aspect ratio float widget_ratio = (float)width() / height(); - float frame_ratio = (float)stream_width / stream_height; - float scale_x = std::min(frame_ratio / widget_ratio, 1.0f); - float scale_y = std::min(widget_ratio / frame_ratio, 1.0f); + float frame_ratio = (float)rgb_frame.width() / rgb_frame.height(); + int w = std::lround(width() * std::min(frame_ratio / widget_ratio, 1.0f)); + int h = std::lround(height() * std::min(widget_ratio / frame_ratio, 1.0f)); + QRect video_rect((width() - w) / 2, (height() - h) / 2, w, h); - glViewport(0, 0, width() * devicePixelRatio(), height() * devicePixelRatio()); - - shader_program_->bind(); - QMatrix4x4 transform; - transform.scale(scale_x, scale_y, 1.0f); - shader_program_->setUniformValue("uTransform", transform); - - glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - - glPixelStorei(GL_UNPACK_ROW_LENGTH, stream_stride); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, textures[0]); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, stream_width, stream_height, GL_RED, GL_UNSIGNED_BYTE, current_frame_->y); - - glPixelStorei(GL_UNPACK_ROW_LENGTH, stream_stride/2); - glActiveTexture(GL_TEXTURE1); - glBindTexture(GL_TEXTURE_2D, textures[1]); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, stream_width/2, stream_height/2, GL_RG, GL_UNSIGNED_BYTE, current_frame_->uv); - - glBindVertexArray(frame_vao); - glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_BYTE, nullptr); - glBindVertexArray(0); - - // Reset both texture units - glActiveTexture(GL_TEXTURE1); - glBindTexture(GL_TEXTURE_2D, 0); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, 0); - glPixelStorei(GL_UNPACK_ALIGNMENT, 4); - glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); - - shader_program_->release(); -} - -void CameraWidget::vipcConnected(VisionIpcClient *vipc_client) { - makeCurrent(); - stream_width = vipc_client->buffers[0].width; - stream_height = vipc_client->buffers[0].height; - stream_stride = vipc_client->buffers[0].stride; - - glBindTexture(GL_TEXTURE_2D, textures[0]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, stream_width, stream_height, 0, GL_RED, GL_UNSIGNED_BYTE, nullptr); - assert(glGetError() == GL_NO_ERROR); - - glBindTexture(GL_TEXTURE_2D, textures[1]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RG8, stream_width/2, stream_height/2, 0, GL_RG, GL_UNSIGNED_BYTE, nullptr); - assert(glGetError() == GL_NO_ERROR); + p.setRenderHint(QPainter::SmoothPixmapTransform); + if (active_stream_type == VISION_STREAM_CABIN) { + // mirror cabin camera horizontally + const qreal cx = video_rect.x() + video_rect.width() / 2.0; + p.translate(cx, 0); + p.scale(-1, 1); + p.translate(-cx, 0); + } + p.drawImage(video_rect, rgb_frame); } void CameraWidget::vipcFrameReceived() { @@ -218,10 +76,11 @@ void CameraWidget::vipcThread() { std::unique_ptr vipc_client; VisionIpcBufExtra frame_meta = {}; - while (!QThread::currentThread()->isInterruptionRequested()) { + while (!vipc_exit) { if (!vipc_client || cur_stream != requested_stream_type) { clearFrames(); - qDebug().nospace() << "connecting to stream " << requested_stream_type << ", was connected to " << cur_stream; + fprintf(stderr, "connecting to stream %d, was connected to %d\n", + (int)requested_stream_type, (int)cur_stream); cur_stream = requested_stream_type; vipc_client.reset(new VisionIpcClient(stream_name, cur_stream, false)); } @@ -231,23 +90,27 @@ void CameraWidget::vipcThread() { clearFrames(); auto streams = VisionIpcClient::getAvailableStreams(stream_name, false); if (streams.empty()) { - QThread::msleep(100); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); continue; } emit vipcAvailableStreamsUpdated(streams); if (!vipc_client->connect(false)) { - QThread::msleep(100); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); continue; } - emit vipcThreadConnected(vipc_client.get()); } if (VisionBuf *buf = vipc_client->recv(&frame_meta, 100)) { + // NV12 -> RGBA once per frame on the receive thread; paint just draws the image + if (rgb_back.width() != (int)buf->width || rgb_back.height() != (int)buf->height) { + rgb_back = QImage(buf->width, buf->height, QImage::Format_RGBA8888); + } + yuv::nv12_to_rgba(buf->y, buf->stride, buf->uv, buf->stride, + rgb_back.bits(), rgb_back.bytesPerLine(), buf->width, buf->height); { std::lock_guard lk(frame_lock); - current_frame_ = buf; - frame_meta_ = frame_meta; + rgb_frame.swap(rgb_back); } emit vipcThreadFrameReceived(); } @@ -256,6 +119,7 @@ void CameraWidget::vipcThread() { void CameraWidget::clearFrames() { std::lock_guard lk(frame_lock); - current_frame_ = nullptr; + rgb_frame = QImage(); + rgb_back = QImage(); available_streams.clear(); } diff --git a/openpilot/tools/cabana/cameraview.h b/openpilot/tools/cabana/cameraview.h index 930b13d82c..3b55dd9ed9 100644 --- a/openpilot/tools/cabana/cameraview.h +++ b/openpilot/tools/cabana/cameraview.h @@ -1,23 +1,22 @@ #pragma once -#include +#include #include #include #include +#include #include -#include -#include -#include -#include +#include +#include +#include "openpilot/cereal/visionstream.h" #include "msgq/visionipc/visionipc_client.h" -class CameraWidget : public QOpenGLWidget, protected QOpenGLFunctions { +class CameraWidget : public QWidget { Q_OBJECT public: - using QOpenGLWidget::QOpenGLWidget; explicit CameraWidget(std::string stream_name, VisionStreamType stream_type, QWidget* parent = nullptr); ~CameraWidget(); void setStreamType(VisionStreamType type) { requested_stream_type = type; } @@ -26,37 +25,30 @@ public: signals: void clicked(); - void vipcThreadConnected(VisionIpcClient *); void vipcThreadFrameReceived(); void vipcAvailableStreamsUpdated(std::set); protected: - void paintGL() override; - void initializeGL() override; + void paintEvent(QPaintEvent *event) override; void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override { stopVipcThread(); } void mouseReleaseEvent(QMouseEvent *event) override { emit clicked(); } void vipcThread(); void clearFrames(); - GLuint frame_vao, frame_vbo, frame_ibo; - GLuint textures[2]; - std::unique_ptr shader_program_; QColor bg = Qt::black; + QImage rgb_frame; // written by vipc thread, drawn by GUI thread; guarded by frame_lock + QImage rgb_back; // vipc thread only std::string stream_name; - int stream_width = 0; - int stream_height = 0; - int stream_stride = 0; std::atomic active_stream_type; std::atomic requested_stream_type; std::set available_streams; - QThread *vipc_thread = nullptr; - std::recursive_mutex frame_lock; - VisionBuf* current_frame_ = nullptr; - VisionIpcBufExtra frame_meta_ = {}; + std::thread vipc_thread; + std::atomic vipc_exit = false; + std::mutex frame_lock; protected slots: - void vipcConnected(VisionIpcClient *vipc_client); void vipcFrameReceived(); void availableStreamsUpdated(std::set streams); }; diff --git a/openpilot/tools/cabana/chart/chart.cc b/openpilot/tools/cabana/chart/chart.cc index 9dfdc595f0..8496c26994 100644 --- a/openpilot/tools/cabana/chart/chart.cc +++ b/openpilot/tools/cabana/chart/chart.cc @@ -1,71 +1,50 @@ #include "tools/cabana/chart/chart.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include +#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include #include "tools/cabana/chart/chartswidget.h" -// ChartAxisElement's padding is 4 (https://codebrowser.dev/qt5/qtcharts/src/charts/axis/chartaxiselement_p.h.html) const int AXIS_X_TOP_MARGIN = 4; -const double MIN_ZOOM_SECONDS = 0.01; // 10ms +const int X_TICK_COUNT = 5; +const double MIN_ZOOM_SECONDS = 0.01; // 10ms // Define a small value of epsilon to compare double values const float EPSILON = 0.000001; static inline bool xLessThan(const QPointF &p, float x) { return p.x() < (x - EPSILON); } +static QMargins layoutMargins(const QStyle *style) { + return { + style->pixelMetric(QStyle::PM_LayoutLeftMargin), + style->pixelMetric(QStyle::PM_LayoutTopMargin), + style->pixelMetric(QStyle::PM_LayoutRightMargin), + style->pixelMetric(QStyle::PM_LayoutBottomMargin), + }; +} + ChartView::ChartView(const std::pair &x_range, ChartsWidget *parent) - : charts_widget(parent), QChartView(parent) { + : x_min(x_range.first), x_max(x_range.second), charts_widget(parent), QWidget(parent) { series_type = (SeriesType)settings.chart_series_type; - chart()->setBackgroundVisible(false); - axis_x = new QValueAxis(this); - axis_y = new QValueAxis(this); - chart()->addAxis(axis_x, Qt::AlignBottom); - chart()->addAxis(axis_y, Qt::AlignLeft); - chart()->legend()->layout()->setContentsMargins(0, 0, 0, 0); - chart()->legend()->setShowToolTips(true); - chart()->setMargins({0, 0, 0, 0}); - - axis_x->setRange(x_range.first, x_range.second); - + align_to = 50; + setMouseTracking(true); tip_label = new TipLabel(this); createToolButtons(); - setRubberBand(QChartView::HorizontalRubberBand); - setMouseTracking(true); - setTheme(utils::isDarkTheme() ? QChart::QChart::ChartThemeDark : QChart::ChartThemeLight); signal_value_font.setPointSize(9); - QObject::connect(axis_y, &QValueAxis::rangeChanged, this, &ChartView::resetChartCache); - QObject::connect(axis_y, &QAbstractAxis::titleTextChanged, this, &ChartView::resetChartCache); - QObject::connect(window()->windowHandle(), &QWindow::screenChanged, this, &ChartView::resetChartCache); - - QObject::connect(dbc(), &DBCManager::signalRemoved, this, &ChartView::signalRemoved); - QObject::connect(dbc(), &DBCManager::signalUpdated, this, &ChartView::signalUpdated); - QObject::connect(dbc(), &DBCManager::msgRemoved, this, &ChartView::msgRemoved); - QObject::connect(dbc(), &DBCManager::msgUpdated, this, &ChartView::msgUpdated); + 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); } void ChartView::createToolButtons() { - move_icon = new QGraphicsPixmapItem(utils::icon("grip-horizontal"), chart()); - move_icon->setToolTip(tr("Drag and drop to move chart")); - - QToolButton *remove_btn = new ToolButton("x", tr("Remove Chart")); - close_btn_proxy = new QGraphicsProxyWidget(chart()); - close_btn_proxy->setWidget(remove_btn); - close_btn_proxy->setZValue(chart()->zValue() + 11); + close_btn = new ToolButton("x", tr("Remove Chart"), this); menu = new QMenu(this); // series types @@ -83,17 +62,14 @@ void ChartView::createToolButtons() { menu->addAction(tr("Manage Signals"), this, &ChartView::manageSignals); split_chart_act = menu->addAction(tr("Split Chart"), [this]() { charts_widget->splitChart(this); }); - QToolButton *manage_btn = new ToolButton("list", ""); + manage_btn = new ToolButton("list", "", this); manage_btn->setMenu(menu); manage_btn->setPopupMode(QToolButton::InstantPopup); manage_btn->setStyleSheet("QToolButton::menu-indicator { image: none; }"); - manage_btn_proxy = new QGraphicsProxyWidget(chart()); - manage_btn_proxy->setWidget(manage_btn); - manage_btn_proxy->setZValue(chart()->zValue() + 11); close_act = new QAction(tr("Close"), this); QObject::connect(close_act, &QAction::triggered, [this] () { charts_widget->removeChart(this); }); - QObject::connect(remove_btn, &QToolButton::clicked, close_act, &QAction::triggered); + QObject::connect(close_btn, &QToolButton::clicked, close_act, &QAction::triggered); QObject::connect(change_series_group, &QActionGroup::triggered, [this](QAction *action) { setSeriesType((SeriesType)action->data().toInt()); }); @@ -103,29 +79,11 @@ QSize ChartView::sizeHint() const { return {CHART_MIN_WIDTH, settings.chart_height}; } -void ChartView::setTheme(QChart::ChartTheme theme) { - chart()->setTheme(theme); - if (theme == QChart::ChartThemeDark) { - axis_x->setTitleBrush(palette().text()); - axis_x->setLabelsBrush(palette().text()); - axis_y->setTitleBrush(palette().text()); - axis_y->setLabelsBrush(palette().text()); - chart()->legend()->setLabelColor(palette().color(QPalette::Text)); - } - axis_x->setLineVisible(false); - axis_y->setLineVisible(false); - for (auto &s : sigs) { - s.series->setColor(s.sig->color); - } -} - void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) { if (hasSignal(msg_id, sig)) return; - QXYSeries *series = createSeries(series_type, sig->color); - sigs.push_back({.msg_id = msg_id, .sig = sig, .series = series}); + sigs.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(toQColor(sig->color))}); updateSeries(sig); - updateSeriesPoints(); updateTitle(); emit charts_widget->seriesChanged(); } @@ -136,29 +94,21 @@ bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) co void ChartView::removeIf(std::function predicate) { int prev_size = sigs.size(); - for (auto it = sigs.begin(); it != sigs.end(); /**/) { - if (predicate(*it)) { - chart()->removeSeries(it->series); - it->series->deleteLater(); - it = sigs.erase(it); - } else { - ++it; - } - } + sigs.erase(std::remove_if(sigs.begin(), sigs.end(), predicate), sigs.end()); if (sigs.empty()) { charts_widget->removeChart(this); } else if (sigs.size() != prev_size) { emit charts_widget->seriesChanged(); updateAxisY(); - resetChartCache(); + updateTitle(); } } void ChartView::signalUpdated(const cabana::Signal *sig) { auto it = std::find_if(sigs.begin(), sigs.end(), [sig](auto &s) { return s.sig == sig; }); if (it != sigs.end()) { - if (it->series->color() != sig->color) { - setSeriesColor(it->series, sig->color); + if (it->color != toQColor(sig->color)) { + it->color = uniqueColor(toQColor(sig->color), sig); } updateTitle(); updateSeries(sig); @@ -188,98 +138,75 @@ void ChartView::manageSignals() { } void ChartView::resizeEvent(QResizeEvent *event) { - qreal left, top, right, bottom; - chart()->layout()->getContentsMargins(&left, &top, &right, &bottom); - move_icon->setPos(left, top); - close_btn_proxy->setPos(rect().right() - right - close_btn_proxy->size().width(), top); - int x = close_btn_proxy->pos().x() - manage_btn_proxy->size().width() - style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing); - manage_btn_proxy->setPos(x, top); - if (align_to > 0) { - updatePlotArea(align_to, true); - } - QChartView::resizeEvent(event); + QWidget::resizeEvent(event); + const auto margins = layoutMargins(style()); + QPixmap grip = utils::icon("grip-horizontal"); + move_icon_rect = QRect(QPoint(margins.left(), margins.top()), grip.size() / grip.devicePixelRatio()); + close_btn->resize(close_btn->sizeHint()); + manage_btn->resize(manage_btn->sizeHint()); + close_btn->move(rect().right() - margins.right() - close_btn->width(), margins.top()); + manage_btn->move(close_btn->x() - manage_btn->width() - style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), margins.top()); + updatePlotArea(align_to, true); } void ChartView::updatePlotArea(int left_pos, bool force) { if (align_to != left_pos || force) { align_to = left_pos; - qreal left, top, right, bottom; - chart()->layout()->getContentsMargins(&left, &top, &right, &bottom); - QSizeF legend_size = chart()->legend()->layout()->minimumSize(); - legend_size.setWidth(manage_btn_proxy->sceneBoundingRect().left() - move_icon->sceneBoundingRect().right()); - chart()->legend()->setGeometry({move_icon->sceneBoundingRect().topRight(), legend_size}); + const auto margins = layoutMargins(style()); + QFont bold_font = font(); + bold_font.setBold(true); + QFontMetrics fm(font()), bfm(bold_font); + const int marker_size = fm.height() - 4; + const int row_height = std::max(marker_size, fm.height()) + QFontMetrics(signal_value_font).height() + 3; + const int legend_left = move_icon_rect.right() + margins.left(); + const int legend_right = std::max(manage_btn->x() - margins.right(), legend_left + 10); - // add top space for signal value - int adjust_top = chart()->legend()->geometry().height() + QFontMetrics(signal_value_font).height() + 3; - adjust_top = std::max(adjust_top, manage_btn_proxy->sceneBoundingRect().height() + style()->pixelMetric(QStyle::PM_LayoutTopMargin)); + // layout legend entries left-to-right, wrapping between the move icon and the buttons + legend_rects.clear(); + int x = legend_left, y = margins.top(); + for (auto &s : sigs) { + int w = marker_size + 5 + bfm.horizontalAdvance(QString::fromStdString(s.sig->name)) + + fm.horizontalAdvance(QString::fromStdString(" " + msgName(s.msg_id) + " " + s.msg_id.toString())); + w = std::min(w, legend_right - legend_left); // keep oversized entries clear of the header buttons + if (x + w > legend_right && x > legend_left) { + x = legend_left; + y += row_height; + } + legend_rects.emplace_back(x, y, w, std::max(marker_size, fm.height())); + x += w + 12; + } + + // add top space for the legend and signal values + int adjust_top = (y + row_height) - margins.top(); + adjust_top = std::max(adjust_top, manage_btn->geometry().bottom() + style()->pixelMetric(QStyle::PM_LayoutTopMargin)); // add right space for x-axis label - QSizeF x_label_size = QFontMetrics(axis_x->labelsFont()).size(Qt::TextSingleLine, QString::number(axis_x->max(), 'f', 2)); - x_label_size += QSizeF{5, 5}; - chart()->setPlotArea(rect().adjusted(align_to + left, adjust_top + top, -x_label_size.width() / 2 - right, -x_label_size.height() - bottom)); - chart()->layout()->invalidate(); + QSizeF x_label_size = fm.size(Qt::TextSingleLine, QString::number(x_max, 'f', xAxisPrecision())) + QSizeF{5, 5}; + plot_area = rect().adjusted(align_to + margins.left(), adjust_top + margins.top(), + -x_label_size.width() / 2 - margins.right(), + -x_label_size.height() - margins.bottom()); resetChartCache(); } } void ChartView::updateTitle() { - for (QLegendMarker *marker : chart()->legend()->markers()) { - QObject::connect(marker, &QLegendMarker::clicked, this, &ChartView::handleMarkerClicked, Qt::UniqueConnection); - } - - // Use CSS to draw titles in the WindowText color - auto tmp = palette().color(QPalette::WindowText); - auto titleColorCss = tmp.name(QColor::HexArgb); - // Draw message details in similar color, but slightly fade it to the background - tmp.setAlpha(180); - auto msgColorCss = tmp.name(QColor::HexArgb); - - for (auto &s : sigs) { - auto decoration = s.series->isVisible() ? "none" : "line-through"; - s.series->setName(QString("%3 %5 %6") - .arg(decoration, titleColorCss, QString::fromStdString(s.sig->name), - msgColorCss, QString::fromStdString(msgName(s.msg_id)), QString::fromStdString(s.msg_id.toString()))); - } split_chart_act->setEnabled(sigs.size() > 1); - resetChartCache(); + updatePlotArea(align_to, true); } void ChartView::updatePlot(double cur, double min, double max) { cur_sec = cur; - if (min != axis_x->min() || max != axis_x->max()) { - axis_x->setRange(min, max); + if (min != x_min || max != x_max) { + x_min = min; + x_max = max; updateAxisY(); - updateSeriesPoints(); // update tooltip if (tooltip_x >= 0) { - showTip(chart()->mapToValue({tooltip_x, 0}).x()); + showTip(secondsAtPoint({tooltip_x, 0})); } resetChartCache(); } - viewport()->update(); -} - -void ChartView::updateSeriesPoints() { - // Show points when zoomed in enough - for (auto &s : sigs) { - auto begin = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan); - auto end = std::lower_bound(begin, s.vals.cend(), axis_x->max(), xLessThan); - if (begin != end) { - int num_points = std::max((end - begin), 1); - QPointF right_pt = end == s.vals.cend() ? s.vals.back() : *end; - double pixels_per_point = (chart()->mapToPosition(right_pt).x() - chart()->mapToPosition(*begin).x()) / num_points; - - if (series_type == SeriesType::Scatter) { - qreal size = std::clamp(pixels_per_point / 2.0, 2.0, 8.0); - if (s.series->useOpenGL()) { - size *= devicePixelRatioF(); - } - ((QScatterSeries *)s.series)->setMarkerSize(size); - } else { - s.series->setPointsVisible(num_points == 1 || pixels_per_point > 20); - } - } - } + update(); } void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector &events, @@ -324,8 +251,6 @@ void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap * if (!can->liveStreaming()) { s.segment_tree.build(s.vals); } - const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals; - s.series->replace(QVector(points.cbegin(), points.cend())); } } updateAxisY(); @@ -342,15 +267,15 @@ void ChartView::updateAxisY() { QString unit = QString::fromStdString(sigs[0].sig->unit); for (auto &s : sigs) { - if (!s.series->isVisible()) continue; + if (!s.visible) continue; // Only show unit when all signals have the same unit if (unit != QString::fromStdString(s.sig->unit)) { unit.clear(); } - auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan); - auto last = std::lower_bound(first, s.vals.cend(), axis_x->max(), xLessThan); + auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), x_min, xLessThan); + auto last = std::lower_bound(first, s.vals.cend(), x_max, xLessThan); s.min = std::numeric_limits::max(); s.max = std::numeric_limits::lowest(); if (can->liveStreaming()) { @@ -367,28 +292,28 @@ void ChartView::updateAxisY() { if (min == std::numeric_limits::max()) min = 0; if (max == std::numeric_limits::lowest()) max = 0; - if (axis_y->titleText() != unit) { - axis_y->setTitleText(unit); + if (y_unit != unit) { + y_unit = unit; y_label_width = 0; // recalc width } double delta = std::abs(max - min) < 1e-3 ? 1 : (max - min) * 0.05; auto [min_y, max_y, tick_count] = getNiceAxisNumbers(min - delta, max + delta, 3); - if (min_y != axis_y->min() || max_y != axis_y->max() || y_label_width == 0) { - axis_y->setRange(min_y, max_y); - axis_y->setTickCount(tick_count); + if (min_y != y_min || max_y != y_max || y_label_width == 0) { + y_min = min_y; + y_max = max_y; + y_tick_count = tick_count; + y_precision = std::max(int(-std::floor(std::log10((max_y - min_y) / (tick_count - 1)))), 0); - int n = std::max(int(-std::floor(std::log10((max_y - min_y) / (tick_count - 1)))), 0); + QFontMetrics fm(font()); int max_label_width = 0; - QFontMetrics fm(axis_y->labelsFont()); for (int i = 0; i < tick_count; i++) { qreal value = min_y + (i * (max_y - min_y) / (tick_count - 1)); - max_label_width = std::max(max_label_width, fm.horizontalAdvance(QString::number(value, 'f', n))); + max_label_width = std::max(max_label_width, fm.horizontalAdvance(QString::number(value, 'f', y_precision))); } - int title_spacing = unit.isEmpty() ? 0 : QFontMetrics(axis_y->titleFont()).size(Qt::TextSingleLine, unit).height(); + int title_spacing = y_unit.isEmpty() ? 0 : fm.size(Qt::TextSingleLine, y_unit).height(); y_label_width = title_spacing + max_label_width + 15; - axis_y->setLabelFormat(QString("%.%1f").arg(n)); emit axisYLabelWidthChanged(y_label_width); } } @@ -402,6 +327,10 @@ std::tuple ChartView::getNiceAxisNumbers(qreal min, qreal m return {min * step, max * step, tick_count}; } +int ChartView::xAxisPrecision() const { + return std::max(int(-std::floor(std::log10((x_max - x_min) / (X_TICK_COUNT - 1)))), 2); +} + // nice numbers can be expressed as form of 1*10^n, 2* 10^n or 5*10^n qreal ChartView::niceNumber(qreal x, bool ceiling) { qreal z = std::pow(10, std::floor(std::log10(x))); //find corresponding number of the form of 10^n than is smaller than x @@ -420,45 +349,6 @@ qreal ChartView::niceNumber(qreal x, bool ceiling) { return q * z; } -QPixmap getBlankShadowPixmap(const QPixmap &px, int radius) { - QGraphicsDropShadowEffect *e = new QGraphicsDropShadowEffect; - e->setColor(QColor(40, 40, 40, 245)); - e->setOffset(0, 0); - e->setBlurRadius(radius); - - qreal dpr = px.devicePixelRatio(); - QPixmap blank(px.size()); - blank.setDevicePixelRatio(dpr); - blank.fill(Qt::white); - - QGraphicsScene scene; - QGraphicsPixmapItem item(blank); - item.setGraphicsEffect(e); - scene.addItem(&item); - - QPixmap shadow(px.size() + QSize(radius * dpr * 2, radius * dpr * 2)); - shadow.setDevicePixelRatio(dpr); - shadow.fill(Qt::transparent); - QPainter p(&shadow); - scene.render(&p, {QPoint(), shadow.size() / dpr}, item.boundingRect().adjusted(-radius, -radius, radius, radius)); - return shadow; -} - -static QPixmap getDropPixmap(const QPixmap &src) { - static QPixmap shadow_px; - const int radius = 10; - if (shadow_px.size() != src.size() + QSize(radius * 2, radius * 2)) { - shadow_px = getBlankShadowPixmap(src, radius); - } - QPixmap px = shadow_px; - QPainter p(&px); - QRectF target_rect(QPointF(radius, radius), src.size() / src.devicePixelRatio()); - p.drawPixmap(target_rect.topLeft(), src); - p.setCompositionMode(QPainter::CompositionMode_DestinationIn); - p.fillRect(target_rect, QColor(0, 0, 0, 200)); - return px; -} - void ChartView::contextMenuEvent(QContextMenuEvent *event) { QMenu context_menu(this); context_menu.addActions(menu->actions()); @@ -471,392 +361,410 @@ void ChartView::contextMenuEvent(QContextMenuEvent *event) { } void ChartView::mousePressEvent(QMouseEvent *event) { - if (event->button() == Qt::LeftButton && move_icon->sceneBoundingRect().contains(event->pos())) { - QMimeData *mimeData = new QMimeData; - mimeData->setData(CHART_MIME_TYPE, QByteArray::number((qulonglong)this)); - QPixmap px = grab().scaledToWidth(CHART_MIN_WIDTH * viewport()->devicePixelRatio(), Qt::SmoothTransformation); - charts_widget->stopAutoScroll(); - QDrag *drag = new QDrag(this); - drag->setMimeData(mimeData); - drag->setPixmap(getDropPixmap(px)); - drag->setHotSpot(-QPoint(5, 5)); - drag->exec(Qt::CopyAction | Qt::MoveAction, Qt::MoveAction); - } else if (event->button() == Qt::LeftButton && QApplication::keyboardModifiers().testFlag(Qt::ShiftModifier)) { + press_pos = event->pos(); + if (event->button() == Qt::LeftButton && move_icon_rect.contains(event->pos())) { + charts_widget->startChartDrag(this, event->globalPos()); + } else if (event->button() == Qt::LeftButton && event->modifiers().testFlag(Qt::ShiftModifier)) { // Save current playback state when scrubbing resume_after_scrub = !can->isPaused(); if (resume_after_scrub) { can->pause(true); } - is_scrubbing = true; + mouse_mode = MouseMode::Scrub; + } else if (event->button() == Qt::LeftButton && plot_area.contains(event->pos())) { + mouse_mode = MouseMode::Rubber; + rubber_rect = QRect(); } else { - QChartView::mousePressEvent(event); - } -} - -void ChartView::mouseReleaseEvent(QMouseEvent *event) { - auto rubber = findChild(); - if (event->button() == Qt::LeftButton && rubber && rubber->isVisible()) { - rubber->hide(); - auto rect = rubber->geometry().normalized(); - // Prevent zooming/seeking past the end of the route - double min = std::clamp(chart()->mapToValue(rect.topLeft()).x(), can->minSeconds(), can->maxSeconds()); - double max = std::clamp(chart()->mapToValue(rect.bottomRight()).x(), can->minSeconds(), can->maxSeconds()); - if (rubber->width() <= 0) { - // no rubber dragged, seek to mouse position - can->seekTo(min); - } else if (rubber->width() > 10 && (max - min) > MIN_ZOOM_SECONDS) { - charts_widget->zoom_undo_stack->push(new ZoomCommand({min, max})); - } else { - viewport()->update(); - } - event->accept(); - } else if (event->button() == Qt::RightButton) { - charts_widget->zoom_undo_stack->undo(); - event->accept(); - } else { - QGraphicsView::mouseReleaseEvent(event); - } - - // Resume playback if we were scrubbing - is_scrubbing = false; - if (resume_after_scrub) { - can->pause(false); - resume_after_scrub = false; + QWidget::mousePressEvent(event); } } void ChartView::mouseMoveEvent(QMouseEvent *ev) { - const auto plot_area = chart()->plotArea(); // Scrubbing - if (is_scrubbing && QApplication::keyboardModifiers().testFlag(Qt::ShiftModifier)) { + if (mouse_mode == MouseMode::Scrub && ev->modifiers().testFlag(Qt::ShiftModifier)) { if (plot_area.contains(ev->pos())) { - can->seekTo(std::clamp(chart()->mapToValue(ev->pos()).x(), can->minSeconds(), can->maxSeconds())); + can->seekTo(std::clamp(secondsAtPoint(ev->pos()), can->minSeconds(), can->maxSeconds())); } } - auto rubber = findChild(); - bool is_zooming = rubber && rubber->isVisible(); - clearTrackPoints(); + if (mouse_mode == MouseMode::Rubber) { + // horizontal selection, clamped to the plot area + int left = std::clamp(std::min(press_pos.x(), ev->pos().x()), plot_area.left(), plot_area.right()); + int right = std::clamp(std::max(press_pos.x(), ev->pos().x()), plot_area.left(), plot_area.right()); + rubber_rect = QRect(left, plot_area.top(), right - left, plot_area.height()); + update(); + } - if (!is_zooming && plot_area.contains(ev->pos()) && isActiveWindow()) { + clearTrackPoints(); + if (mouse_mode != MouseMode::Rubber && plot_area.contains(ev->pos()) && isActiveWindow()) { charts_widget->showValueTip(secondsAtPoint(ev->pos())); } else if (tip_label->isVisible()) { charts_widget->showValueTip(-1); } + QWidget::mouseMoveEvent(ev); +} - QChartView::mouseMoveEvent(ev); - if (is_zooming) { - QRect rubber_rect = rubber->geometry(); - rubber_rect.setLeft(std::max(rubber_rect.left(), (int)plot_area.left())); - rubber_rect.setRight(std::min(rubber_rect.right(), (int)plot_area.right())); - if (rubber_rect != rubber->geometry()) { - rubber->setGeometry(rubber_rect); +void ChartView::mouseReleaseEvent(QMouseEvent *event) { + if (event->button() == Qt::LeftButton && mouse_mode == MouseMode::Rubber) { + mouse_mode = MouseMode::None; + // Prevent zooming/seeking past the end of the route + double min = std::clamp(secondsAtPoint(rubber_rect.topLeft()), can->minSeconds(), can->maxSeconds()); + double max = std::clamp(secondsAtPoint(rubber_rect.bottomRight()), can->minSeconds(), can->maxSeconds()); + if (rubber_rect.width() <= 0) { + // no rubber dragged, seek to mouse position + can->seekTo(std::clamp(secondsAtPoint(press_pos), can->minSeconds(), can->maxSeconds())); + } else if (rubber_rect.width() > 10 && (max - min) > MIN_ZOOM_SECONDS) { + charts_widget->zoom_undo_stack.push(new ZoomCommand({min, max})); + } + rubber_rect = QRect(); + update(); + } else if (event->button() == Qt::LeftButton && mouse_mode == MouseMode::None && sigs.size() > 1) { + // toggle series visibility by clicking its legend entry + for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { + if (legend_rects[i].contains(press_pos) && legend_rects[i].contains(event->pos())) { + sigs[i].visible = !sigs[i].visible; + updateAxisY(); + updateTitle(); + break; + } + } + } else if (event->button() == Qt::RightButton) { + charts_widget->zoom_undo_stack.undo(); + } else { + QWidget::mouseReleaseEvent(event); + } + + // Resume playback if we were scrubbing + if (mouse_mode == MouseMode::Scrub) { + mouse_mode = MouseMode::None; + if (resume_after_scrub) { + can->pause(false); + resume_after_scrub = false; } - viewport()->update(); } } +void ChartView::takeSignalsFrom(ChartView *source) { + for (auto &s : source->sigs) { + sigs.push_back(std::move(s)); + sigs.back().color = uniqueColor(sigs.back().color, sigs.back().sig); + } + source->sigs.clear(); + updateAxisY(); + updateTitle(); + charts_widget->removeChart(source); +} + void ChartView::showTip(double sec) { - QRect tip_area(0, chart()->plotArea().top(), rect().width(), chart()->plotArea().height()); + QRect tip_area(0, plot_area.top(), rect().width(), plot_area.height()); QRect visible_rect = charts_widget->chartVisibleRect(this).intersected(tip_area); if (visible_rect.isEmpty()) { tip_label->hide(); return; } - tooltip_x = chart()->mapToPosition({sec, 0}).x(); + tooltip_x = xPos(sec); qreal x = -1; QStringList text_list; for (auto &s : sigs) { - if (s.series->isVisible()) { + if (s.visible) { QString value = "--"; // use reverse iterator to find last item <= sec. auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double v) { return p.x() > v; }); - if (it != s.vals.crend() && it->x() >= axis_x->min()) { + if (it != s.vals.crend() && it->x() >= x_min) { value = QString::fromStdString(s.sig->formatValue(it->y(), false)); s.track_pt = *it; - x = std::max(x, chart()->mapToPosition(*it).x()); + x = std::max(x, xPos(it->x())); } QString name = sigs.size() > 1 ? QString::fromStdString(s.sig->name) + ": " : ""; QString min = s.min == std::numeric_limits::max() ? "--" : QString::number(s.min); QString max = s.max == std::numeric_limits::lowest() ? "--" : QString::number(s.max); text_list << QString("%2%3 (%4, %5)") - .arg(s.series->color().name(), name, value, min, max); + .arg(s.color.name(), name, value, min, max); } } if (x < 0) { x = tooltip_x; } - QPoint pt(x, chart()->plotArea().top()); - text_list.push_front(QString::number(chart()->mapToValue({x, 0}).x(), 'f', 3)); + QPoint pt(x, plot_area.top()); + text_list.push_front(QString::number(secondsAtPoint({x, 0}), 'f', 3)); QString text = "

    " % text_list.join("
    ") % "

    "; tip_label->showText(pt, text, this, visible_rect); - viewport()->update(); + update(); } void ChartView::hideTip() { clearTrackPoints(); tooltip_x = -1; tip_label->hide(); - viewport()->update(); -} - -void ChartView::dragEnterEvent(QDragEnterEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - drawDropIndicator(event->source() != this); - event->acceptProposedAction(); - } -} - -void ChartView::dragMoveEvent(QDragMoveEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - event->setDropAction(event->source() == this ? Qt::MoveAction : Qt::CopyAction); - event->accept(); - } - charts_widget->startAutoScroll(); -} - -void ChartView::dropEvent(QDropEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - if (event->source() != this) { - ChartView *source_chart = (ChartView *)event->source(); - for (auto &s : source_chart->sigs) { - source_chart->chart()->removeSeries(s.series); - addSeries(s.series); - } - sigs.insert(sigs.end(), std::move_iterator(source_chart->sigs.begin()), std::move_iterator(source_chart->sigs.end())); - updateAxisY(); - updateTitle(); - startAnimation(); - - source_chart->sigs.clear(); - charts_widget->removeChart(source_chart); - event->acceptProposedAction(); - } - can_drop = false; - } + update(); } void ChartView::resetChartCache() { chart_pixmap = QPixmap(); - viewport()->update(); -} - -void ChartView::startAnimation() { - QGraphicsOpacityEffect *eff = new QGraphicsOpacityEffect(this); - viewport()->setGraphicsEffect(eff); - QPropertyAnimation *a = new QPropertyAnimation(eff, "opacity"); - a->setDuration(250); - a->setStartValue(0.3); - a->setEndValue(1); - a->setEasingCurve(QEasingCurve::InBack); - a->start(QPropertyAnimation::DeleteWhenStopped); + update(); } void ChartView::paintEvent(QPaintEvent *event) { - if (!can->liveStreaming()) { - if (chart_pixmap.isNull()) { - const qreal dpr = viewport()->devicePixelRatioF(); - chart_pixmap = QPixmap(viewport()->size() * dpr); - chart_pixmap.setDevicePixelRatio(dpr); - QPainter p(&chart_pixmap); - p.setRenderHints(QPainter::Antialiasing); - drawBackground(&p, viewport()->rect()); - scene()->setSceneRect(viewport()->rect()); - scene()->render(&p, viewport()->rect()); - } + QPainter painter(this); + painter.setRenderHints(QPainter::Antialiasing); - QPainter painter(viewport()); - painter.setRenderHints(QPainter::Antialiasing); - painter.drawPixmap(QPoint(), chart_pixmap); - if (can_drop) { - painter.setPen(QPen(palette().color(QPalette::Highlight), 4)); - painter.drawRect(viewport()->rect()); - } - QRectF exposed_rect = mapToScene(event->region().boundingRect()).boundingRect(); - drawForeground(&painter, exposed_rect); - } else { - QChartView::paintEvent(event); + // the static layer is invalidated on x-range change and data merge, so cache it in live mode too + const qreal dpr = devicePixelRatioF(); + if (chart_pixmap.isNull() || chart_pixmap.size() != size() * dpr) { + chart_pixmap = QPixmap(size() * dpr); + chart_pixmap.setDevicePixelRatio(dpr); + QPainter p(&chart_pixmap); + p.setRenderHints(QPainter::Antialiasing); + p.setFont(font()); + drawStaticLayer(&p); + } + painter.drawPixmap(QPoint(), chart_pixmap); + + if (can_drop) { + painter.setPen(QPen(palette().color(QPalette::Highlight), 4)); + painter.drawRect(rect()); + } + drawForeground(&painter); +} + +void ChartView::drawStaticLayer(QPainter *painter) { + painter->fillRect(rect(), palette().color(QPalette::Base)); + painter->drawPixmap(move_icon_rect.topLeft(), utils::icon("grip-horizontal")); + drawAxes(painter); + drawLegend(painter); + drawSeries(painter); +} + +void ChartView::drawAxes(QPainter *painter) { + const QColor text_color = palette().color(QPalette::Text); + QColor grid_color = text_color; + grid_color.setAlpha(50); + QFontMetrics fm(font()); + painter->setFont(font()); + + // y grid lines and tick labels + for (int i = 0; i < y_tick_count; ++i) { + double value = y_min + i * (y_max - y_min) / (y_tick_count - 1); + qreal y = yPos(value); + painter->setPen(grid_color); + painter->drawLine(QPointF(plot_area.left(), y), QPointF(plot_area.right(), y)); + painter->setPen(text_color); + QRectF label_rect(0, y - fm.height() / 2.0, plot_area.left() - 6, fm.height()); + painter->drawText(label_rect, Qt::AlignRight | Qt::AlignVCenter, QString::number(value, 'f', y_precision)); + } + + // rotated y axis title (unit) + if (!y_unit.isEmpty()) { + painter->save(); + painter->translate(plot_area.left() - y_label_width + fm.height() / 2.0, plot_area.center().y()); + painter->rotate(-90); + painter->drawText(QRectF(-plot_area.height() / 2.0, -fm.height() / 2.0, plot_area.height(), fm.height()), + Qt::AlignCenter, y_unit); + painter->restore(); + } + + // x grid lines and tick labels + const int x_precision = xAxisPrecision(); + for (int i = 0; i < X_TICK_COUNT; ++i) { + double sec = x_min + i * (x_max - x_min) / (X_TICK_COUNT - 1); + qreal x = xPos(sec); + painter->setPen(grid_color); + painter->drawLine(QPointF(x, plot_area.top()), QPointF(x, plot_area.bottom())); + painter->setPen(text_color); + QString label = QString::number(sec, 'f', x_precision); + QRectF label_rect(x - 100, plot_area.bottom() + AXIS_X_TOP_MARGIN, 200, fm.height()); + painter->drawText(label_rect, Qt::AlignHCenter | Qt::AlignTop, label); } } -void ChartView::drawBackground(QPainter *painter, const QRectF &rect) { - painter->fillRect(rect, palette().color(QPalette::Base)); +void ChartView::drawLegend(QPainter *painter) { + QColor title_color = palette().color(QPalette::WindowText); + // Draw message details in similar color, but slightly fade it to the background + QColor msg_color = title_color; + msg_color.setAlpha(180); + QFont bold_font = font(); + bold_font.setBold(true); + const int marker_size = QFontMetrics(font()).height() - 4; + + for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { + const auto &s = sigs[i]; + const QRect &r = legend_rects[i]; + painter->setPen(Qt::NoPen); + painter->setBrush(s.color); + QRectF marker_rect(r.left(), r.center().y() - marker_size / 2.0, marker_size, marker_size); + series_type == SeriesType::Scatter ? painter->drawEllipse(marker_rect) : painter->drawRect(marker_rect); + + bold_font.setStrikeOut(!s.visible); + QFont normal_font = font(); + normal_font.setStrikeOut(!s.visible); + + qreal x = r.left() + marker_size + 5; + painter->setFont(bold_font); + painter->setPen(title_color); + QString name = QFontMetrics(bold_font).elidedText(QString::fromStdString(s.sig->name), Qt::ElideRight, r.right() - x); + painter->drawText(QRectF(x, r.top(), r.right() - x, r.height()), Qt::AlignLeft | Qt::AlignVCenter, name); + x += QFontMetrics(bold_font).horizontalAdvance(name); + painter->setFont(normal_font); + painter->setPen(msg_color); + QString msg = QFontMetrics(normal_font).elidedText(QString::fromStdString(" " + msgName(s.msg_id) + " " + s.msg_id.toString()), + Qt::ElideRight, r.right() - x); + painter->drawText(QRectF(x, r.top(), r.right() - x, r.height()), Qt::AlignLeft | Qt::AlignVCenter, msg); + } } -void ChartView::drawForeground(QPainter *painter, const QRectF &rect) { +void ChartView::drawSeries(QPainter *painter) { + painter->save(); + painter->setClipRect(plot_area); + for (auto &s : sigs) { + if (!s.visible) continue; + + // visible points in vals to compute point density + auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), x_min, xLessThan); + auto last = std::lower_bound(first, s.vals.cend(), x_max, xLessThan); + int num_points = std::max(last - first, 1); + double pixels_per_point = 0; + if (first != last) { + const QPointF &right_pt = last == s.vals.cend() ? s.vals.back() : *last; + pixels_per_point = (xPos(right_pt.x()) - xPos(first->x())) / num_points; + } + + if (series_type == SeriesType::Scatter) { + qreal radius = std::clamp(pixels_per_point / 2.0, 2.0, 8.0) / 2.0; + painter->setPen(Qt::NoPen); + painter->setBrush(s.color); + for (auto it = first; it != last; ++it) { + painter->drawEllipse(QPointF(xPos(it->x()), yPos(it->y())), radius, radius); + } + } else { + const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals; + auto begin = std::lower_bound(points.cbegin(), points.cend(), x_min, xLessThan); + if (begin != points.cbegin()) --begin; + auto end = std::lower_bound(begin, points.cend(), x_max, xLessThan); + if (end != points.cend()) ++end; + if (begin == end) continue; + + std::vector polyline; + polyline.reserve(end - begin); + for (auto it = begin; it != end; ++it) { + polyline.emplace_back(xPos(it->x()), yPos(it->y())); + } + painter->setPen(QPen(s.color, 2)); + painter->setBrush(Qt::NoBrush); + painter->drawPolyline(polyline.data(), polyline.size()); + + // show points when zoomed in enough + if (num_points == 1 || pixels_per_point > 20) { + painter->setPen(Qt::NoPen); + painter->setBrush(s.color); + for (auto it = first; it != last; ++it) { + painter->drawEllipse(QPointF(xPos(it->x()), yPos(it->y())), 4, 4); + } + } + } + } + painter->restore(); +} + +void ChartView::drawForeground(QPainter *painter) { drawTimeline(painter); drawSignalValue(painter); // draw track points painter->setPen(Qt::NoPen); qreal track_line_x = -1; for (auto &s : sigs) { - if (!s.track_pt.isNull() && s.series->isVisible()) { - painter->setBrush(s.series->color().darker(125)); - QPointF pos = chart()->mapToPosition(s.track_pt); + if (!s.track_pt.isNull() && s.visible) { + painter->setBrush(s.color.darker(125)); + QPointF pos(xPos(s.track_pt.x()), yPos(s.track_pt.y())); painter->drawEllipse(pos, 5.5, 5.5); track_line_x = std::max(track_line_x, pos.x()); } } if (track_line_x > 0) { - auto plot_area = chart()->plotArea(); painter->setPen(QPen(Qt::darkGray, 1, Qt::DashLine)); - painter->drawLine(QPointF{track_line_x, plot_area.top()}, QPointF{track_line_x, plot_area.bottom()}); - } - - // paint points. OpenGL mode lacks certain features (such as showing points) - painter->setPen(Qt::NoPen); - for (auto &s : sigs) { - if (s.series->useOpenGL() && s.series->isVisible() && s.series->pointsVisible()) { - auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan); - auto last = std::lower_bound(first, s.vals.cend(), axis_x->max(), xLessThan); - painter->setBrush(s.series->color()); - for (auto it = first; it != last; ++it) { - painter->drawEllipse(chart()->mapToPosition(*it), 4, 4); - } - } + painter->drawLine(QPointF{track_line_x, (qreal)plot_area.top()}, QPointF{track_line_x, (qreal)plot_area.bottom()}); } drawRubberBandTimeRange(painter); } void ChartView::drawRubberBandTimeRange(QPainter *painter) { - auto rubber = findChild(); - if (rubber && rubber->isVisible() && rubber->width() > 1) { - painter->setPen(Qt::white); - auto rubber_rect = rubber->geometry().normalized(); - for (const auto &pt : {rubber_rect.bottomLeft(), rubber_rect.bottomRight()}) { - QString sec = QString::number(chart()->mapToValue(pt).x(), 'f', 2); - auto r = painter->fontMetrics().boundingRect(sec).adjusted(-6, -AXIS_X_TOP_MARGIN, 6, AXIS_X_TOP_MARGIN); - pt == rubber_rect.bottomLeft() ? r.moveTopRight(pt + QPoint{0, 2}) : r.moveTopLeft(pt + QPoint{0, 2}); - painter->fillRect(r, Qt::gray); - painter->drawText(r, Qt::AlignCenter, sec); - } + if (rubber_rect.width() <= 1) return; + + // selection rect + QColor highlight = palette().color(QPalette::Highlight); + QColor fill = highlight; + fill.setAlpha(50); + painter->fillRect(rubber_rect, fill); + painter->setPen(highlight); + painter->setBrush(Qt::NoBrush); + painter->drawRect(rubber_rect); + + // time labels at the bottom corners + painter->setPen(Qt::white); + painter->setFont(font()); + for (const auto &pt : {rubber_rect.bottomLeft(), rubber_rect.bottomRight()}) { + QString sec = QString::number(secondsAtPoint(pt), 'f', 2); + auto r = painter->fontMetrics().boundingRect(sec).adjusted(-6, -AXIS_X_TOP_MARGIN, 6, AXIS_X_TOP_MARGIN); + pt == rubber_rect.bottomLeft() ? r.moveTopRight(pt + QPoint{0, 2}) : r.moveTopLeft(pt + QPoint{0, 2}); + painter->fillRect(r, Qt::gray); + painter->drawText(r, Qt::AlignCenter, sec); } } void ChartView::drawTimeline(QPainter *painter) { - const auto plot_area = chart()->plotArea(); // draw vertical time line - qreal x = std::clamp(chart()->mapToPosition(QPointF{cur_sec, 0}).x(), plot_area.left(), plot_area.right()); - painter->setPen(QPen(chart()->titleBrush().color(), 1)); - painter->drawLine(QPointF{x, plot_area.top() - 1}, QPointF{x, plot_area.bottom() + 1}); + qreal x = std::clamp(xPos(cur_sec), (qreal)plot_area.left(), (qreal)plot_area.right()); + painter->setPen(QPen(palette().color(QPalette::Text), 1)); + painter->drawLine(QPointF{x, plot_area.top() - 1.0}, QPointF{x, plot_area.bottom() + 1.0}); // draw current time under the axis-x QString time_str = QString::number(cur_sec, 'f', 2); - QSize time_str_size = QFontMetrics(axis_x->labelsFont()).size(Qt::TextSingleLine, time_str) + QSize(8, 2); + QSize time_str_size = QFontMetrics(font()).size(Qt::TextSingleLine, time_str) + QSize(8, 2); QRectF time_str_rect(QPointF(x - time_str_size.width() / 2.0, plot_area.bottom() + AXIS_X_TOP_MARGIN), time_str_size); QPainterPath path; path.addRoundedRect(time_str_rect, 3, 3); painter->fillPath(path, utils::isDarkTheme() ? Qt::darkGray : Qt::gray); painter->setPen(palette().color(QPalette::BrightText)); - painter->setFont(axis_x->labelsFont()); + painter->setFont(font()); painter->drawText(time_str_rect, Qt::AlignCenter, time_str); } void ChartView::drawSignalValue(QPainter *painter) { - auto item_group = qgraphicsitem_cast(chart()->legend()->childItems()[0]); - assert(item_group != nullptr); - auto legend_markers = item_group->childItems(); - assert(legend_markers.size() == sigs.size()); - painter->setFont(signal_value_font); - painter->setPen(chart()->legend()->labelColor()); - int i = 0; - for (auto &s : sigs) { + painter->setPen(palette().color(QPalette::Text)); + for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { + const auto &s = sigs[i]; auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), cur_sec, [](auto &p, double x) { return p.x() > x + EPSILON; }); - QString value = (it != s.vals.crend() && it->x() >= axis_x->min()) ? QString::fromStdString(s.sig->formatValue(it->y())) : "--"; - QRectF marker_rect = legend_markers[i++]->sceneBoundingRect(); - QRectF value_rect(marker_rect.bottomLeft() - QPoint(0, 1), marker_rect.size()); + QString value = (it != s.vals.crend() && it->x() >= x_min) ? QString::fromStdString(s.sig->formatValue(it->y())) : "--"; + QRectF value_rect(legend_rects[i].bottomLeft() - QPoint(0, 1), legend_rects[i].size()); QString elided_val = painter->fontMetrics().elidedText(value, Qt::ElideRight, value_rect.width()); painter->drawText(value_rect, Qt::AlignHCenter | Qt::AlignTop, elided_val); } } -QXYSeries *ChartView::createSeries(SeriesType type, QColor color) { - QXYSeries *series = nullptr; - if (type == SeriesType::Line) { - series = new QLineSeries(this); - chart()->legend()->setMarkerShape(QLegend::MarkerShapeRectangle); - } else if (type == SeriesType::StepLine) { - series = new QLineSeries(this); - chart()->legend()->setMarkerShape(QLegend::MarkerShapeFromSeries); - } else { - series = new QScatterSeries(this); - static_cast(series)->setBorderColor(color); - chart()->legend()->setMarkerShape(QLegend::MarkerShapeCircle); - } - series->setColor(color); - // TODO: Due to a bug in CameraWidget the camera frames - // are drawn instead of the graphs on MacOS. Re-enable OpenGL when fixed -#ifndef __APPLE__ - series->setUseOpenGL(true); - // Qt doesn't properly apply device pixel ratio in OpenGL mode - QPen pen = series->pen(); - pen.setWidthF(2.0 * devicePixelRatioF()); - series->setPen(pen); -#endif - addSeries(series); - return series; -} - -void ChartView::addSeries(QXYSeries *series) { - setSeriesColor(series, series->color()); - chart()->addSeries(series); - series->attachAxis(axis_x); - series->attachAxis(axis_y); - - // disables the delivery of mouse events to the opengl widget. - // this enables the user to select the zoom area when the mouse press on the data point. - auto glwidget = findChild(); - if (glwidget && !glwidget->testAttribute(Qt::WA_TransparentForMouseEvents)) { - glwidget->setAttribute(Qt::WA_TransparentForMouseEvents); - } -} - -void ChartView::setSeriesColor(QXYSeries *series, QColor color) { - auto existing_series = chart()->series(); - for (auto s : existing_series) { - if (s != series && std::abs(color.hueF() - qobject_cast(s)->color().hueF()) < 0.1) { +QColor ChartView::uniqueColor(QColor color, const cabana::Signal *exclude) const { + for (auto &s : sigs) { + if (s.sig != exclude && std::abs(color.hueF() - s.color.hueF()) < 0.1) { // use different color to distinguish it from others. - auto last_color = qobject_cast(existing_series.back())->color(); + auto last_color = sigs.back().color; + static thread_local std::mt19937 rng{std::random_device{}()}; + std::uniform_int_distribution sat(35, 99); + std::uniform_int_distribution val(85, 99); color.setHsvF(std::fmod(last_color.hueF() + 60 / 360.0, 1.0), - QRandomGenerator::global()->bounded(35, 100) / 100.0, - QRandomGenerator::global()->bounded(85, 100) / 100.0); + sat(rng) / 100.0, + val(rng) / 100.0); break; } } - series->setColor(color); + return color; } void ChartView::setSeriesType(SeriesType type) { if (type != series_type) { series_type = type; - for (auto &s : sigs) { - chart()->removeSeries(s.series); - s.series->deleteLater(); - } - for (auto &s : sigs) { - s.series = createSeries(series_type, s.sig->color); - const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals; - s.series->replace(QVector(points.cbegin(), points.cend())); - } - updateSeriesPoints(); - updateTitle(); - menu->actions()[(int)type]->setChecked(true); - } -} - -void ChartView::handleMarkerClicked() { - auto marker = qobject_cast(sender()); - Q_ASSERT(marker); - if (sigs.size() > 1) { - auto series = marker->series(); - series->setVisible(!series->isVisible()); - marker->setVisible(true); - updateAxisY(); updateTitle(); } } diff --git a/openpilot/tools/cabana/chart/chart.h b/openpilot/tools/cabana/chart/chart.h index f9472bd4f6..b03623475a 100644 --- a/openpilot/tools/cabana/chart/chart.h +++ b/openpilot/tools/cabana/chart/chart.h @@ -1,18 +1,11 @@ #pragma once +#include #include #include #include #include -#include -#include -#include -#include -#include -#include -#include -using namespace QtCharts; #include "tools/cabana/chart/tiplabel.h" #include "tools/cabana/dbc/dbcmanager.h" @@ -25,7 +18,7 @@ enum class SeriesType { }; class ChartsWidget; -class ChartView : public QChartView { +class ChartView : public QWidget { Q_OBJECT public: @@ -38,13 +31,15 @@ public: void updatePlotArea(int left, bool force = false); void showTip(double sec); void hideTip(); - void startAnimation(); - double secondsAtPoint(const QPointF &pt) const { return chart()->mapToValue(pt).x(); } + double secondsAtPoint(const QPointF &pt) const { + return x_min + (pt.x() - plot_area.left()) * (x_max - x_min) / std::max(plot_area.width(), 1); + } struct SigItem { MessageId msg_id; const cabana::Signal *sig = nullptr; - QXYSeries *series = nullptr; + QColor color; + bool visible = true; std::vector vals; std::vector step_vals; QPointF track_pt{}; @@ -59,7 +54,6 @@ signals: private slots: void signalUpdated(const cabana::Signal *sig); void manageSignals(); - void handleMarkerClicked(); void msgUpdated(MessageId id); void msgRemoved(MessageId id) { removeIf([=](auto &s) { return s.msg_id.address == id.address && !dbc()->msg(id); }); } void signalRemoved(const cabana::Signal *sig) { removeIf([=](auto &s) { return s.sig == sig; }); } @@ -68,52 +62,65 @@ private: void appendCanEvents(const cabana::Signal *sig, const std::vector &events, std::vector &vals, std::vector &step_vals); void createToolButtons(); - void addSeries(QXYSeries *series); void contextMenuEvent(QContextMenuEvent *event) override; void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *ev) override; - void dragEnterEvent(QDragEnterEvent *event) override; - void dragLeaveEvent(QDragLeaveEvent *event) override { drawDropIndicator(false); } - void dragMoveEvent(QDragMoveEvent *event) override; - void dropEvent(QDropEvent *event) override; void resizeEvent(QResizeEvent *event) override; QSize sizeHint() const override; void updateAxisY(); void updateTitle(); void resetChartCache(); - void setTheme(QChart::ChartTheme theme); void paintEvent(QPaintEvent *event) override; - void drawForeground(QPainter *painter, const QRectF &rect) override; - void drawBackground(QPainter *painter, const QRectF &rect) override; - void drawDropIndicator(bool draw) { if (std::exchange(can_drop, draw) != can_drop) viewport()->update(); } + void drawStaticLayer(QPainter *painter); + void drawAxes(QPainter *painter); + void drawLegend(QPainter *painter); + void drawSeries(QPainter *painter); + void drawForeground(QPainter *painter); void drawSignalValue(QPainter *painter); void drawTimeline(QPainter *painter); void drawRubberBandTimeRange(QPainter *painter); + int xAxisPrecision() const; std::tuple getNiceAxisNumbers(qreal min, qreal max, int tick_count); qreal niceNumber(qreal x, bool ceiling); - QXYSeries *createSeries(SeriesType type, QColor color); - void setSeriesColor(QXYSeries *, QColor color); - void updateSeriesPoints(); + QColor uniqueColor(QColor color, const cabana::Signal *exclude = nullptr) const; void removeIf(std::function predicate); + void takeSignalsFrom(ChartView *source); + void setDropHighlight(bool highlight) { if (std::exchange(can_drop, highlight) != highlight) update(); } inline void clearTrackPoints() { for (auto &s : sigs) s.track_pt = {}; } + inline qreal xPos(double sec) const { return plot_area.left() + (sec - x_min) / (x_max - x_min) * plot_area.width(); } + inline qreal yPos(double val) const { return plot_area.bottom() - (val - y_min) / (y_max - y_min) * plot_area.height(); } + // layout + QRect plot_area; + QRect move_icon_rect; + std::vector legend_rects; + // axes + double x_min; + double x_max; + double y_min = 0; + double y_max = 1; + int y_tick_count = 3; + int y_precision = 0; + QString y_unit; int y_label_width = 0; int align_to = 0; - QValueAxis *axis_x; - QValueAxis *axis_y; + // interaction + enum class MouseMode { None, Rubber, Scrub }; + MouseMode mouse_mode = MouseMode::None; + QPoint press_pos; + QRect rubber_rect; + bool resume_after_scrub = false; + QMenu *menu; QAction *split_chart_act; QAction *close_act; - QGraphicsPixmapItem *move_icon; - QGraphicsProxyWidget *close_btn_proxy; - QGraphicsProxyWidget *manage_btn_proxy; + ToolButton *manage_btn; + ToolButton *close_btn; TipLabel *tip_label; std::vector sigs; double cur_sec = 0; SeriesType series_type = SeriesType::Line; - bool is_scrubbing = false; - bool resume_after_scrub = false; QPixmap chart_pixmap; bool can_drop = false; double tooltip_x = -1; diff --git a/openpilot/tools/cabana/chart/chartswidget.cc b/openpilot/tools/cabana/chart/chartswidget.cc index 44dca42152..3144c5132d 100644 --- a/openpilot/tools/cabana/chart/chartswidget.cc +++ b/openpilot/tools/cabana/chart/chartswidget.cc @@ -1,11 +1,12 @@ #include "tools/cabana/chart/chartswidget.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include #include #include -#include +#include #include #include @@ -71,11 +72,14 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { range_slider_action = toolbar->addWidget(range_slider); // zoom controls - zoom_undo_stack = new QUndoStack(this); - toolbar->addAction(undo_zoom_action = zoom_undo_stack->createUndoAction(this)); - undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise")); - toolbar->addAction(redo_zoom_action = zoom_undo_stack->createRedoAction(this)); - redo_zoom_action->setIcon(utils::icon("arrow-clockwise")); + undo_zoom_action = toolbar->addAction(utils::icon("arrow-counterclockwise"), tr("Undo Zoom"), [this]() { zoom_undo_stack.undo(); }); + 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]() { + 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); @@ -88,8 +92,6 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { tabbar->setAutoHide(true); tabbar->setExpanding(false); tabbar->setDrawBase(true); - tabbar->setAcceptDrops(true); - tabbar->setChangeCurrentOnDrag(true); tabbar->setUsesScrollButtons(true); main_layout->addWidget(tabbar); @@ -104,6 +106,11 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { charts_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); main_layout->addWidget(charts_scroll); + // chart drag preview + drag_preview = new QLabel(this); + drag_preview->setAttribute(Qt::WA_TransparentForMouseEvents); + drag_preview->hide(); + // init settings current_theme = settings.theme; column_count = std::clamp(settings.chart_column_count, 1, MAX_COLUMN_COUNT); @@ -115,7 +122,7 @@ 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(dbc(), &DBCManager::DBCFileChanged, this, &ChartsWidget::removeAll); + 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); @@ -185,7 +192,7 @@ void ChartsWidget::timeRangeChanged(const std::optionalsetTimeRange(std::nullopt); - zoom_undo_stack->clear(); + zoom_undo_stack.clear(); } QRect ChartsWidget::chartVisibleRect(ChartView *chart) { @@ -255,10 +262,6 @@ void ChartsWidget::settingChanged() { if (std::exchange(current_theme, settings.theme) != current_theme) { undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise")); redo_zoom_action->setIcon(utils::icon("arrow-clockwise")); - auto theme = utils::isDarkTheme() ? QChart::QChart::ChartThemeDark : QChart::ChartThemeLight; - for (auto c : charts) { - c->setTheme(theme); - } } if (range_slider->maximum() != settings.max_cached_minutes * 60) { range_slider->setRange(1, settings.max_cached_minutes * 60); @@ -306,12 +309,8 @@ void ChartsWidget::splitChart(ChartView *src_chart) { int pos = std::find(charts.begin(), charts.end(), src_chart) - charts.begin() + 1; for (auto it = src_chart->sigs.begin() + 1; it != src_chart->sigs.end(); /**/) { auto c = createChart(pos); - src_chart->chart()->removeSeries(it->series); - // Restore to the original color - it->series->setColor(it->sig->color); - - c->addSeries(it->series); + it->color = toQColor(it->sig->color); c->sigs.emplace_back(std::move(*it)); c->updateAxisY(); c->updateTitle(); @@ -319,6 +318,7 @@ void ChartsWidget::splitChart(ChartView *src_chart) { } src_chart->updateAxisY(); src_chart->updateTitle(); + updateState(); QTimer::singleShot(0, src_chart, &ChartView::resetChartCache); } } @@ -389,7 +389,87 @@ void ChartsWidget::updateLayout(bool force) { } } -void ChartsWidget::startAutoScroll() { +void ChartsWidget::startChartDrag(ChartView *chart, const QPoint &global_pos) { + stopAutoScroll(); + drag = {.source = chart, .press_pos = global_pos}; + QPixmap px = chart->grab().scaledToWidth(CHART_MIN_WIDTH * chart->devicePixelRatio(), Qt::SmoothTransformation); + drag_preview->setPixmap(px); + drag_preview->resize(px.size() / px.devicePixelRatio()); +} + +void ChartsWidget::dragChartMove(const QPoint &global_pos) { + if (!drag.active) { + if ((global_pos - drag.press_pos).manhattanLength() < QApplication::startDragDistance()) return; + drag.active = true; + drag_preview->show(); + drag_preview->raise(); + } + drag_preview->move(mapFromGlobal(global_pos) + QPoint(5, 5)); + + // hovering a tab switches to it so the chart can be dropped into another tab + int tab = tabbar->tabAt(tabbar->mapFromGlobal(global_pos)); + if (tab >= 0 && tab != tabbar->currentIndex()) { + tabbar->setCurrentIndex(tab); + } + + const QPoint container_pos = charts_container->mapFromGlobal(global_pos); + ChartView *target = nullptr; + for (auto c : currentCharts()) { + if (c != drag.source && c->isVisible() && c->geometry().contains(container_pos)) { + target = c; + break; + } + } + if (std::exchange(drop_target, target) != target) { + for (auto c : charts) c->setDropHighlight(c == target); + } + bool in_viewport = charts_scroll->viewport()->rect().contains(charts_scroll->viewport()->mapFromGlobal(global_pos)); + bool on_background = !target && in_viewport && !charts_container->childAt(container_pos); + charts_container->drawDropIndicator(on_background ? container_pos : QPoint()); + + if (in_viewport) { + startAutoScroll(global_pos); + } +} + +void ChartsWidget::cancelChartDrag() { + drag = {}; + stopAutoScroll(); + drag_preview->hide(); + charts_container->drawDropIndicator({}); + if (auto target = std::exchange(drop_target, nullptr)) target->setDropHighlight(false); +} + +void ChartsWidget::dragChartRelease(const QPoint &global_pos) { + ChartView *source = drag.source; + bool active = drag.active; + ChartView *target = drop_target; + cancelChartDrag(); + if (!active) return; + + const QPoint container_pos = charts_container->mapFromGlobal(global_pos); + bool in_viewport = charts_scroll->viewport()->rect().contains(charts_scroll->viewport()->mapFromGlobal(global_pos)); + if (target) { + // merge source into target + target->takeSignalsFrom(source); + } else if (in_viewport && !charts_container->childAt(container_pos)) { + // reorder within the current tab + auto w = charts_container->getDropAfter(container_pos); + if (w != source) { + for (auto &[_, list] : tab_charts) { + list.erase(std::remove(list.begin(), list.end(), source), list.end()); + } + auto &cur = currentCharts(); + int to = w ? std::find(cur.begin(), cur.end(), w) - cur.begin() + 1 : 0; + cur.insert(cur.begin() + to, source); + updateLayout(true); + updateTabBar(); + } + } +} + +void ChartsWidget::startAutoScroll(const QPoint &global_pos) { + auto_scroll_pos = global_pos; auto_scroll_timer->start(50); } @@ -405,7 +485,7 @@ void ChartsWidget::doAutoScroll() { } int value = scroll->value(); - QPoint pos = charts_scroll->viewport()->mapFromGlobal(QCursor::pos()); + QPoint pos = charts_scroll->viewport()->mapFromGlobal(auto_scroll_pos); QRect area = charts_scroll->viewport()->rect(); if (pos.y() - area.top() < settings.chart_height / 2) { @@ -413,16 +493,11 @@ void ChartsWidget::doAutoScroll() { } else if (area.bottom() - pos.y() < settings.chart_height / 2) { scroll->setValue(value + auto_scroll_count); } - bool vertical_unchanged = value == scroll->value(); - if (vertical_unchanged) { + if (value == scroll->value()) { stopAutoScroll(); - } else { - // mouseMoveEvent to updates the drag-selection rectangle - const QPoint globalPos = charts_scroll->viewport()->mapToGlobal(pos); - const QPoint windowPos = charts_scroll->window()->mapFromGlobal(globalPos); - QMouseEvent mm(QEvent::MouseMove, pos, windowPos, globalPos, - Qt::NoButton, Qt::LeftButton, Qt::NoModifier, Qt::MouseEventSynthesizedByQt); - QApplication::sendEvent(charts_scroll->viewport(), &mm); + } else if (chartDragActive()) { + // refresh the drop indicator/target at the new scroll position + dragChartMove(auto_scroll_pos); } } @@ -439,11 +514,14 @@ void ChartsWidget::newChart() { for (auto it : items) { c->addSignal(it->msg_id, it->sig); } + updateState(); } } } void ChartsWidget::removeChart(ChartView *chart) { + if (drag.source == chart) cancelChartDrag(); + if (drop_target == chart) drop_target = nullptr; charts.erase(std::remove(charts.begin(), charts.end(), chart), charts.end()); chart->deleteLater(); for (auto &[_, list] : tab_charts) { @@ -483,6 +561,19 @@ void ChartsWidget::alignCharts() { } bool ChartsWidget::eventFilter(QObject *o, QEvent *e) { + // route all mouse events to the chart drag, even when the source chart is hidden by a tab switch + if (chartDragActive()) { + if (e->type() == QEvent::MouseMove) { + dragChartMove(static_cast(e)->globalPos()); + return true; + } else if (e->type() == QEvent::MouseButtonRelease && static_cast(e)->button() == Qt::LeftButton) { + dragChartRelease(static_cast(e)->globalPos()); + return false; // let the release through so Qt clears the implicit mouse grab + } else if (e->type() == QEvent::MouseButtonPress || e->type() == QEvent::MouseButtonRelease) { + return true; // swallow other buttons during the drag + } + } + if (!value_tip_visible_) return false; if (e->type() == QEvent::MouseMove) { @@ -491,7 +582,7 @@ bool ChartsWidget::eventFilter(QObject *o, QEvent *e) { for (const auto &c : charts) { auto local_pos = c->mapFromGlobal(global_pos); - if (c->chart()->plotArea().contains(local_pos)) { + if (c->plot_area.contains(local_pos)) { if (on_tip) { showValueTip(c->secondsAtPoint(local_pos)); } @@ -523,13 +614,14 @@ bool ChartsWidget::event(QEvent *event) { break; case QEvent::WindowDeactivate: case QEvent::FocusOut: + if (chartDragActive()) cancelChartDrag(); showValueTip(-1); default: break; } if (back_button) { - zoom_undo_stack->undo(); + zoom_undo_stack.undo(); return true; // Return true since the event has been handled } return QFrame::event(event); @@ -538,7 +630,6 @@ bool ChartsWidget::event(QEvent *event) { // ChartsContainer ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), QWidget(parent) { - setAcceptDrops(true); setBackgroundRole(QPalette::Window); QVBoxLayout *charts_main_layout = new QVBoxLayout(this); charts_main_layout->setContentsMargins(0, CHART_SPACING, 0, CHART_SPACING); @@ -548,33 +639,6 @@ ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), charts_main_layout->addStretch(0); } -void ChartsContainer::dragEnterEvent(QDragEnterEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - event->acceptProposedAction(); - drawDropIndicator(event->pos()); - } -} - -void ChartsContainer::dropEvent(QDropEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - auto w = getDropAfter(event->pos()); - auto chart = qobject_cast(event->source()); - if (w != chart) { - for (auto &[_, list] : charts_widget->tab_charts) { - list.erase(std::remove(list.begin(), list.end(), chart), list.end()); - } - auto &cur = charts_widget->currentCharts(); - int to = w ? std::find(cur.begin(), cur.end(), w) - cur.begin() + 1 : 0; - cur.insert(cur.begin() + to, chart); - charts_widget->updateLayout(true); - charts_widget->updateTabBar(); - event->acceptProposedAction(); - chart->startAnimation(); - } - drawDropIndicator({}); - } -} - void ChartsContainer::paintEvent(QPaintEvent *ev) { if (!drop_indictor_pos.isNull() && !childAt(drop_indictor_pos)) { QRect r = geometry(); diff --git a/openpilot/tools/cabana/chart/chartswidget.h b/openpilot/tools/cabana/chart/chartswidget.h index ef3fbc471a..8b3003dcd6 100644 --- a/openpilot/tools/cabana/chart/chartswidget.h +++ b/openpilot/tools/cabana/chart/chartswidget.h @@ -8,15 +8,13 @@ #include #include #include -#include -#include #include "tools/cabana/chart/signalselector.h" +#include "tools/cabana/commands.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" const int CHART_MIN_WIDTH = 300; -const QString CHART_MIME_TYPE = "application/x-cabanachartview"; class ChartView; class ChartsWidget; @@ -24,9 +22,6 @@ class ChartsWidget; class ChartsContainer : public QWidget { public: ChartsContainer(ChartsWidget *parent); - void dragEnterEvent(QDragEnterEvent *event) override; - void dropEvent(QDropEvent *event) override; - void dragLeaveEvent(QDragLeaveEvent *event) override { drawDropIndicator({}); } void drawDropIndicator(const QPoint &pt) { drop_indictor_pos = pt; update(); } void paintEvent(QPaintEvent *ev) override; ChartView *getDropAfter(const QPoint &pos) const; @@ -69,7 +64,12 @@ private: void eventsMerged(const MessageEventsMap &new_events); void updateState(); void zoomReset(); - void startAutoScroll(); + void startChartDrag(ChartView *chart, const QPoint &global_pos); + void dragChartMove(const QPoint &global_pos); + void dragChartRelease(const QPoint &global_pos); + void cancelChartDrag(); + bool chartDragActive() const { return drag.source != nullptr; } + void startAutoScroll(const QPoint &global_pos); void stopAutoScroll(); void doAutoScroll(); void updateToolBar(); @@ -97,7 +97,7 @@ private: QAction *redo_zoom_action; QAction *reset_zoom_action; ToolButton *reset_zoom_btn; - QUndoStack *zoom_undo_stack; + UndoStack zoom_undo_stack; ToolButton *remove_all_btn; std::vector charts; @@ -110,7 +110,15 @@ private: QAction *columns_action; int column_count = 1; int current_column_count = 0; + struct ChartDrag { + ChartView *source = nullptr; + QPoint press_pos; // global + bool active = false; + } drag; + QLabel *drag_preview; + ChartView *drop_target = nullptr; int auto_scroll_count = 0; + QPoint auto_scroll_pos; QTimer *auto_scroll_timer; QTimer *align_timer; int current_theme = 0; @@ -119,11 +127,10 @@ private: friend class ChartsContainer; }; -class ZoomCommand : public QUndoCommand { +class ZoomCommand : public UndoCommand { public: - ZoomCommand(std::pair range) : range(range), QUndoCommand() { + ZoomCommand(std::pair range) : range(range) { prev_range = can->timeRange(); - setText(QObject::tr("Zoom to %1-%2").arg(range.first, 0, 'f', 2).arg(range.second, 0, 'f', 2)); } void undo() override { can->setTimeRange(prev_range); } void redo() override { can->setTimeRange(range); } diff --git a/openpilot/tools/cabana/chart/signalselector.cc b/openpilot/tools/cabana/chart/signalselector.cc index 6f2fd8de46..85832e796b 100644 --- a/openpilot/tools/cabana/chart/signalselector.cc +++ b/openpilot/tools/cabana/chart/signalselector.cc @@ -1,6 +1,6 @@ #include "tools/cabana/chart/signalselector.h" +#include "tools/cabana/dbc/dbcqt.h" -#include #include #include #include @@ -20,8 +20,6 @@ SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) msgs_combo->setEditable(true); msgs_combo->lineEdit()->setPlaceholderText(tr("Select a msg...")); msgs_combo->setInsertPolicy(QComboBox::NoInsert); - msgs_combo->completer()->setCompletionMode(QCompleter::PopupCompletion); - msgs_combo->completer()->setFilterMode(Qt::MatchContains); main_layout->addWidget(available_list = new QListWidget(this), 2, 0); @@ -92,7 +90,7 @@ void SignalSelector::updateAvailableList(int index) { } void SignalSelector::addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name) { - QString text = QString(" %1").arg(sig->color.name(), QString::fromStdString(sig->name)); + QString text = QString(" %1").arg(toQColor(sig->color).name(), QString::fromStdString(sig->name)); if (show_msg_name) text += QString(" %0 %1").arg(QString::fromStdString(msgName(id)), QString::fromStdString(id.toString())); QLabel *label = new QLabel(text); diff --git a/openpilot/tools/cabana/chart/sparkline.cc b/openpilot/tools/cabana/chart/sparkline.cc index 91435cd5ac..f5bef0fc2e 100644 --- a/openpilot/tools/cabana/chart/sparkline.cc +++ b/openpilot/tools/cabana/chart/sparkline.cc @@ -31,7 +31,7 @@ void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIt } freq_ = points_.size() / std::max(points_.back().x() - points_.front().x(), 1.0); - render(sig->color, range, size); + render(toQColor(sig->color), range, size); } void Sparkline::render(const QColor &color, int range, QSize size) { diff --git a/openpilot/tools/cabana/commands.cc b/openpilot/tools/cabana/commands.cc index f158528b51..b47bf90b0c 100644 --- a/openpilot/tools/cabana/commands.cc +++ b/openpilot/tools/cabana/commands.cc @@ -1,20 +1,81 @@ -#include - #include "tools/cabana/commands.h" +#include + +// UndoStack + +void UndoStack::push(UndoCommand *cmd) { + commands_.resize(index_); // drop any redoable commands + if (clean_index_ > index_) clean_index_ = -1; + commands_.emplace_back(cmd); + cmd->redo(); + setIndex(index_ + 1); +} + +void UndoStack::undo() { + if (!canUndo()) return; + commands_[index_ - 1]->undo(); + setIndex(index_ - 1); +} + +void UndoStack::redo() { + if (!canRedo()) return; + commands_[index_]->redo(); + setIndex(index_ + 1); +} + +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); +} + +void UndoStack::setClean() { + if (!isClean()) { + clean_index_ = index_; + if (callbacks_.clean_changed) callbacks_.clean_changed(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()); +} + +UndoStack *UndoStack::instance() { + static UndoStack undo_stack; + 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, - const std::string &node, const std::string &comment, QUndoCommand *parent) - : id(id), new_name(name), new_size(size), new_node(node), new_comment(comment), QUndoCommand(parent) { + const std::string &node, const std::string &comment) + : id(id), new_name(name), new_size(size), new_node(node), new_comment(comment) { if (auto msg = dbc()->msg(id)) { old_name = msg->name; old_size = msg->size; old_node = msg->transmitter; old_comment = msg->comment; - setText(QObject::tr("edit message %1:%2").arg(QString::fromStdString(name)).arg(id.address)); + text = "edit message " + name + ":" + std::to_string(id.address); } else { - setText(QObject::tr("new message %1:%2").arg(QString::fromStdString(name)).arg(id.address)); + text = "new message " + name + ":" + std::to_string(id.address); } } @@ -31,10 +92,10 @@ void EditMsgCommand::redo() { // RemoveMsgCommand -RemoveMsgCommand::RemoveMsgCommand(const MessageId &id, QUndoCommand *parent) : id(id), QUndoCommand(parent) { +RemoveMsgCommand::RemoveMsgCommand(const MessageId &id) : id(id) { if (auto msg = dbc()->msg(id)) { message = *msg; - setText(QObject::tr("remove message %1:%2").arg(QString::fromStdString(message.name)).arg(id.address)); + text = "remove message " + message.name + ":" + std::to_string(id.address); } } @@ -53,9 +114,9 @@ void RemoveMsgCommand::redo() { // AddSigCommand -AddSigCommand::AddSigCommand(const MessageId &id, const cabana::Signal &sig, QUndoCommand *parent) - : id(id), signal(sig), QUndoCommand(parent) { - setText(QObject::tr("add signal %1 to %2:%3").arg(QString::fromStdString(sig.name)).arg(QString::fromStdString(msgName(id))).arg(id.address)); +AddSigCommand::AddSigCommand(const MessageId &id, const cabana::Signal &sig) + : id(id), signal(sig) { + text = "add signal " + sig.name + " to " + msgName(id) + ":" + std::to_string(id.address); } void AddSigCommand::undo() { @@ -75,8 +136,7 @@ void AddSigCommand::redo() { // RemoveSigCommand -RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *sig, QUndoCommand *parent) - : id(id), QUndoCommand(parent) { +RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *sig) : id(id) { sigs.push_back(*sig); if (sig->type == cabana::Signal::Type::Multiplexor) { for (const auto &s : dbc()->msg(id)->sigs) { @@ -85,7 +145,7 @@ RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *si } } } - setText(QObject::tr("remove signal %1 from %2:%3").arg(QString::fromStdString(sig->name)).arg(QString::fromStdString(msgName(id))).arg(id.address)); + text = "remove signal " + sig->name + " from " + msgName(id) + ":" + std::to_string(id.address); } void RemoveSigCommand::undo() { for (const auto &s : sigs) dbc()->addSignal(id, s); } @@ -93,8 +153,8 @@ void RemoveSigCommand::redo() { for (const auto &s : sigs) dbc()->removeSignal(i // EditSignalCommand -EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig, QUndoCommand *parent) - : id(id), QUndoCommand(parent) { +EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig) + : id(id) { sigs.push_back({*sig, new_sig}); if (sig->type == cabana::Signal::Type::Multiplexor && new_sig.type == cabana::Signal::Type::Normal) { // convert all multiplexed signals to normal signals @@ -108,17 +168,8 @@ EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal * } } } - setText(QObject::tr("edit signal %1 in %2:%3").arg(QString::fromStdString(sig->name)).arg(QString::fromStdString(msgName(id))).arg(id.address)); + text = "edit signal " + sig->name + " in " + msgName(id) + ":" + std::to_string(id.address); } void EditSignalCommand::undo() { for (const auto &s : sigs) dbc()->updateSignal(id, s.second.name, s.first); } void EditSignalCommand::redo() { for (const auto &s : sigs) dbc()->updateSignal(id, s.first.name, s.second); } - -namespace UndoStack { - -QUndoStack *instance() { - static QUndoStack *undo_stack = new QUndoStack(qApp); - return undo_stack; -} - -} // namespace UndoStack diff --git a/openpilot/tools/cabana/commands.h b/openpilot/tools/cabana/commands.h index 4081f86985..200a4f2f5b 100644 --- a/openpilot/tools/cabana/commands.h +++ b/openpilot/tools/cabana/commands.h @@ -1,19 +1,70 @@ #pragma once +#include +#include #include #include #include -#include -#include +#include #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" -class EditMsgCommand : public QUndoCommand { +class UndoCommand { +public: + virtual ~UndoCommand() = default; + virtual void undo() = 0; + virtual void redo() = 0; + std::string text; +}; + +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(); + void clear(); + void setClean(); + bool isClean() const { return clean_index_ == index_; } + bool canUndo() const { return index_ > 0; } + 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(); + +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, - const std::string &comment, QUndoCommand *parent = nullptr); + const std::string &comment); void undo() override; void redo() override; @@ -23,9 +74,9 @@ private: int old_size = 0, new_size = 0; }; -class RemoveMsgCommand : public QUndoCommand { +class RemoveMsgCommand : public UndoCommand { public: - RemoveMsgCommand(const MessageId &id, QUndoCommand *parent = nullptr); + RemoveMsgCommand(const MessageId &id); void undo() override; void redo() override; @@ -34,9 +85,9 @@ private: cabana::Msg message; }; -class AddSigCommand : public QUndoCommand { +class AddSigCommand : public UndoCommand { public: - AddSigCommand(const MessageId &id, const cabana::Signal &sig, QUndoCommand *parent = nullptr); + AddSigCommand(const MessageId &id, const cabana::Signal &sig); void undo() override; void redo() override; @@ -46,9 +97,9 @@ private: cabana::Signal signal = {}; }; -class RemoveSigCommand : public QUndoCommand { +class RemoveSigCommand : public UndoCommand { public: - RemoveSigCommand(const MessageId &id, const cabana::Signal *sig, QUndoCommand *parent = nullptr); + RemoveSigCommand(const MessageId &id, const cabana::Signal *sig); void undo() override; void redo() override; @@ -57,9 +108,9 @@ private: std::vector sigs; }; -class EditSignalCommand : public QUndoCommand { +class EditSignalCommand : public UndoCommand { public: - EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig, QUndoCommand *parent = nullptr); + EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig); void undo() override; void redo() override; @@ -67,8 +118,3 @@ private: const MessageId id; std::vector> sigs; // {old_sig, new_sig} }; - -namespace UndoStack { - QUndoStack *instance(); - inline void push(QUndoCommand *cmd) { instance()->push(cmd); } -}; diff --git a/openpilot/tools/cabana/core/can_data.h b/openpilot/tools/cabana/core/can_data.h new file mode 100644 index 0000000000..38141b1890 --- /dev/null +++ b/openpilot/tools/cabana/core/can_data.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/core/color.h" +#include "tools/cabana/core/message_id.h" + +struct CanData { + void compute(const MessageId &msg_id, const uint8_t *data, int size, double current_sec, + double playback_speed, const std::vector &mask, double frequency = 0); + + double ts = 0.; + uint32_t count = 0; + double freq = 0; + std::vector dat; + std::vector colors; + + struct ByteLastChange { + double ts = 0; + int delta = 0; + int same_delta_counter = 0; + bool suppressed = false; + }; + std::vector last_changes; + std::vector> bit_flip_counts; + double last_freq_update_ts = 0; +}; + +struct CanEvent { + uint8_t src; + uint32_t address; + uint64_t mono_time; + uint8_t size; + uint8_t dat[]; +}; + +struct CompareCanEvent { + constexpr bool operator()(const CanEvent *const event, uint64_t ts) const { return event->mono_time < ts; } + constexpr bool operator()(uint64_t ts, const CanEvent *const event) const { return ts < event->mono_time; } +}; + +using MessageEventsMap = std::unordered_map>; +using CanEventIter = std::vector::const_iterator; diff --git a/openpilot/tools/cabana/core/color.h b/openpilot/tools/cabana/core/color.h new file mode 100644 index 0000000000..c29704dd44 --- /dev/null +++ b/openpilot/tools/cabana/core/color.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include + +struct CabanaColor { + uint8_t r = 0; + uint8_t g = 0; + uint8_t b = 0; + uint8_t a = 255; + + constexpr CabanaColor() = default; + constexpr CabanaColor(uint8_t red, uint8_t green, uint8_t blue, uint8_t alpha = 255) + : r(red), g(green), b(blue), a(alpha) {} + + static CabanaColor fromHsv(float hue, float saturation, float value, float alpha = 1.0f) { + const float h = hue - std::floor(hue); + const float c = value * saturation; + const float x = c * (1.0f - std::fabs(std::fmod(h * 6.0f, 2.0f) - 1.0f)); + const float m = value - c; + float red = 0, green = 0, blue = 0; + switch (static_cast(h * 6.0f) % 6) { + case 0: red = c; green = x; break; + case 1: red = x; green = c; break; + case 2: green = c; blue = x; break; + case 3: green = x; blue = c; break; + case 4: red = x; blue = c; break; + default: red = c; blue = x; break; + } + auto channel = [m](float v) { return static_cast(std::clamp((v + m) * 255.0f, 0.0f, 255.0f) + 0.5f); }; + return {channel(red), channel(green), channel(blue), + static_cast(std::clamp(alpha * 255.0f, 0.0f, 255.0f) + 0.5f)}; + } + + CabanaColor darker(int factor = 200) const { + if (factor <= 0) return *this; + if (factor < 100) return lighter(10000 / factor); + auto [hue, saturation, value] = hsv(); + return fromHsv(hue, saturation, value * 100.0f / factor, a / 255.0f); + } + + CabanaColor lighter(int factor = 150) const { + if (factor <= 0) return *this; + if (factor < 100) return darker(10000 / factor); + auto [hue, saturation, value] = hsv(); + const float scaled_value = value * factor / 100.0f; + if (scaled_value > 1.0f) saturation = std::max(0.0f, saturation - (scaled_value - 1.0f)); + return fromHsv(hue, saturation, std::min(1.0f, scaled_value), a / 255.0f); + } + + constexpr int red() const { return r; } + constexpr int green() const { return g; } + constexpr int blue() const { return b; } + constexpr int alpha() const { return a; } + float alphaF() const { return a / 255.0f; } + void setAlphaF(float alpha) { a = static_cast(std::clamp(alpha * 255.0f, 0.0f, 255.0f) + 0.5f); } + + constexpr bool operator==(const CabanaColor &other) const { + return r == other.r && g == other.g && b == other.b && a == other.a; + } + +private: + struct Hsv { float hue; float saturation; float value; }; + Hsv hsv() const { + const float red = r / 255.0f, green = g / 255.0f, blue = b / 255.0f; + const float maximum = std::max({red, green, blue}); + const float minimum = std::min({red, green, blue}); + const float delta = maximum - minimum; + float hue = 0; + if (delta > 0) { + if (maximum == red) hue = std::fmod((green - blue) / delta, 6.0f) / 6.0f; + else if (maximum == green) hue = ((blue - red) / delta + 2.0f) / 6.0f; + else hue = ((red - green) / delta + 4.0f) / 6.0f; + if (hue < 0) hue += 1.0f; + } + return {hue, maximum == 0 ? 0 : delta / maximum, maximum}; + } +}; diff --git a/openpilot/tools/cabana/core/message_id.h b/openpilot/tools/cabana/core/message_id.h new file mode 100644 index 0000000000..ecac279631 --- /dev/null +++ b/openpilot/tools/cabana/core/message_id.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#include +#include +#include + +constexpr int INVALID_SOURCE = 0xff; + +struct MessageId { + uint8_t source = 0; + uint32_t address = 0; + std::string toString() const { char b[64]; snprintf(b, sizeof(b), "%u:%X", source, address); return b; } + static MessageId fromString(const std::string &s) { + const auto p = s.find(':'); + if (p == std::string::npos) return {}; + return {.source = static_cast(std::stoul(s.substr(0, p))), .address = static_cast(std::stoul(s.substr(p + 1), nullptr, 16))}; + } + bool operator==(const MessageId &o) const { return source == o.source && address == o.address; } + bool operator!=(const MessageId &o) const { return !(*this == o); } + bool operator<(const MessageId &o) const { return std::tie(source, address) < std::tie(o.source, o.address); } + bool operator>(const MessageId &o) const { return o < *this; } +}; + +template <> struct std::hash { + size_t operator()(const MessageId &id) const noexcept { return std::hash{}(id.source) ^ (std::hash{}(id.address) << 1); } +}; diff --git a/openpilot/tools/cabana/core/settings.h b/openpilot/tools/cabana/core/settings.h new file mode 100644 index 0000000000..cad152de92 --- /dev/null +++ b/openpilot/tools/cabana/core/settings.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +constexpr int LIGHT_THEME = 1; +constexpr int DARK_THEME = 2; + +struct CabanaSettingsState { + enum DragDirection { MsbFirst, LsbFirst, AlwaysLE, AlwaysBE }; + + bool absolute_time = false; + int fps = 10; + int max_cached_minutes = 30; + int chart_height = 200; + int chart_column_count = 1; + int chart_range = 3 * 60; + int chart_series_type = 0; + int theme = 0; + int sparkline_range = 15; + bool multiple_lines_hex = false; + bool log_livestream = true; + bool suppress_defined_signals = false; + std::string log_path; + std::string last_dir; + std::string last_route_dir; + std::vector recent_files; + DragDirection drag_direction = MsbFirst; + + std::string recent_dbc_file; + std::string active_msg_id; + std::vector selected_msg_ids; + std::vector active_charts; +}; diff --git a/openpilot/tools/cabana/dbc/dbc.cc b/openpilot/tools/cabana/dbc/dbc.cc index 8e41cf54e3..c8a794c73f 100644 --- a/openpilot/tools/cabana/dbc/dbc.cc +++ b/openpilot/tools/cabana/dbc/dbc.cc @@ -1,8 +1,18 @@ #include "tools/cabana/dbc/dbc.h" #include +#include -#include "tools/cabana/utils/util.h" +namespace { +int numDecimals(double value) { + int decimals = 0; + while (decimals < 6 && std::fabs(value - std::round(value)) > 1e-9) { + value *= 10.0; + ++decimals; + } + return decimals; +} +} // cabana::Msg @@ -135,8 +145,8 @@ void cabana::Signal::update() { float s = 0.25 + 0.25 * (float)(hash & 0xff) / 255.0; float v = 0.75 + 0.25 * (float)((hash >> 8) & 0xff) / 255.0; - color = QColor::fromHsvF(h, s, v); - precision = std::max(num_decimals(factor), num_decimals(offset)); + color = CabanaColor::fromHsv(h, s, v); + precision = std::max(numDecimals(factor), numDecimals(offset)); } std::string cabana::Signal::formatValue(double value, bool with_unit) const { diff --git a/openpilot/tools/cabana/dbc/dbc.h b/openpilot/tools/cabana/dbc/dbc.h index a10e7871fe..585325d391 100644 --- a/openpilot/tools/cabana/dbc/dbc.h +++ b/openpilot/tools/cabana/dbc/dbc.h @@ -8,58 +8,14 @@ #include #include -#include -#include +#include "tools/cabana/core/color.h" +#include "tools/cabana/core/message_id.h" const std::string UNTITLED = "untitled"; const std::string DEFAULT_NODE_NAME = "XXX"; constexpr int CAN_MAX_DATA_BYTES = 64; -struct MessageId { - uint8_t source = 0; - uint32_t address = 0; - - std::string toString() const { - char buf[64]; - snprintf(buf, sizeof(buf), "%u:%X", source, address); - return buf; - } - - inline static MessageId fromString(const std::string &str) { - auto pos = str.find(':'); - if (pos == std::string::npos) return {}; - return MessageId{.source = uint8_t(std::stoul(str.substr(0, pos))), - .address = uint32_t(std::stoul(str.substr(pos + 1), nullptr, 16))}; - } - - bool operator==(const MessageId &other) const { - return source == other.source && address == other.address; - } - - bool operator!=(const MessageId &other) const { - return !(*this == other); - } - - bool operator<(const MessageId &other) const { - return std::tie(source, address) < std::tie(other.source, other.address); - } - - bool operator>(const MessageId &other) const { - return std::tie(source, address) > std::tie(other.source, other.address); - } -}; - -Q_DECLARE_METATYPE(MessageId); - -template <> -struct std::hash { - std::size_t operator()(const MessageId &k) const noexcept { - return std::hash{}(k.source) ^ (std::hash{}(k.address) << 1); - } -}; - typedef std::vector> ValueDescription; -Q_DECLARE_METATYPE(ValueDescription); namespace cabana { @@ -92,7 +48,7 @@ public: std::string receiver_name; ValueDescription val_desc; int precision = 0; - QColor color; + CabanaColor color; // Multiplexed int multiplex_value = 0; diff --git a/openpilot/tools/cabana/dbc/dbcfile.cc b/openpilot/tools/cabana/dbc/dbcfile.cc index d9c129ee81..99a5b71822 100644 --- a/openpilot/tools/cabana/dbc/dbcfile.cc +++ b/openpilot/tools/cabana/dbc/dbcfile.cc @@ -1,23 +1,60 @@ #include "tools/cabana/dbc/dbcfile.h" -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include -DBCFile::DBCFile(const std::string &dbc_file_name) { - QFile file(QString::fromStdString(dbc_file_name)); - if (file.open(QIODevice::ReadOnly)) { - name_ = QFileInfo(QString::fromStdString(dbc_file_name)).baseName().toStdString(); - filename = dbc_file_name; - parse(file.readAll()); - } else { - throw std::runtime_error("Failed to open file."); - } +namespace { + +std::string trim(const std::string &value) { + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return {}; + return value.substr(first, value.find_last_not_of(" \t\r\n") - first + 1); } -DBCFile::DBCFile(const std::string &name, const std::string &content) : name_(name), filename("") { - parse(QString::fromStdString(content)); +bool startsWith(const std::string &value, const char *prefix) { + return value.rfind(prefix, 0) == 0; +} + +std::string unescapeComment(std::string value) { + for (size_t pos = 0; (pos = value.find("\\\"", pos)) != std::string::npos; ++pos) { + value.replace(pos, 2, "\""); + } + return trim(value); +} + +bool commentComplete(const std::string &line) { + bool escaped = false; + for (size_t i = 0; i < line.size(); ++i) { + if (line[i] == '\\' && !escaped) { + escaped = true; + continue; + } + if (line[i] == '"' && !escaped) { + size_t next = line.find_first_not_of(" \t\r\n", i + 1); + if (next != std::string::npos && line[next] == ';') return true; + } + escaped = false; + } + return false; +} + +} // namespace + +DBCFile::DBCFile(const std::string &dbc_file_name) { + std::ifstream file(dbc_file_name, std::ios::binary); + if (!file) throw std::runtime_error("Failed to open file."); + filename = dbc_file_name; + name_ = std::filesystem::path(dbc_file_name).stem().string(); + parse(std::string(std::istreambuf_iterator(file), std::istreambuf_iterator())); +} + +DBCFile::DBCFile(const std::string &name, const std::string &content) : name_(name) { + parse(content); } bool DBCFile::save() { @@ -31,15 +68,14 @@ bool DBCFile::saveAs(const std::string &new_filename) { } bool DBCFile::writeContents(const std::string &fn) { - QFile file(QString::fromStdString(fn)); - if (file.open(QIODevice::WriteOnly)) { - std::string content = generateDBC(); - return file.write(content.c_str(), content.size()) >= 0; - } - return false; + std::ofstream file(fn, std::ios::binary | std::ios::trunc); + if (!file) return false; + file << generateDBC(); + return file.good(); } -void DBCFile::updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment) { +void DBCFile::updateMsg(const MessageId &id, const std::string &name, uint32_t size, + const std::string &node, const std::string &comment) { auto &m = msgs[id.address]; m.address = id.address; m.name = name; @@ -55,178 +91,143 @@ cabana::Msg *DBCFile::msg(uint32_t address) { cabana::Msg *DBCFile::msg(const std::string &name) { auto it = std::find_if(msgs.begin(), msgs.end(), [&name](auto &m) { return m.second.name == name; }); - return it != msgs.end() ? &(it->second) : nullptr; + return it != msgs.end() ? &it->second : nullptr; } cabana::Signal *DBCFile::signal(uint32_t address, const std::string &name) { auto m = msg(address); - return m ? (cabana::Signal *)m->sig(name) : nullptr; + return m ? m->sig(name) : nullptr; } -void DBCFile::parse(const QString &content) { +void DBCFile::parse(const std::string &content) { msgs.clear(); - - int line_num = 0; - QString line; + header.clear(); + std::istringstream input(content); + std::string raw_line; cabana::Msg *current_msg = nullptr; int multiplexor_cnt = 0; + int line_num = 0; bool seen_first = false; - QTextStream stream((QString *)&content); - while (!stream.atEnd()) { + while (std::getline(input, raw_line)) { ++line_num; - QString raw_line = stream.readLine(); - line = raw_line.trimmed(); + const size_t first_nonspace = raw_line.find_first_not_of(" \t\r"); + std::string line = first_nonspace == std::string::npos ? std::string() : raw_line.substr(first_nonspace); + const int statement_line = line_num; + if ((startsWith(line, "CM_ BO_") || startsWith(line, "CM_ SG_ ")) && !commentComplete(line)) { + std::string continuation; + while (std::getline(input, continuation)) { + ++line_num; + line += "\n" + continuation; + if (commentComplete(line)) break; + } + } bool seen = true; try { - if (line.startsWith("BO_ ")) { + if (startsWith(line, "BO_ ")) { multiplexor_cnt = 0; current_msg = parseBO(line); - } else if (line.startsWith("SG_ ")) { + } else if (startsWith(line, "SG_ ")) { parseSG(line, current_msg, multiplexor_cnt); - } else if (line.startsWith("VAL_ ")) { + } else if (startsWith(line, "VAL_ ")) { parseVAL(line); - } else if (line.startsWith("CM_ BO_")) { - parseCM_BO(line, content, raw_line, stream); - } else if (line.startsWith("CM_ SG_ ")) { - parseCM_SG(line, content, raw_line, stream); + } else if (startsWith(line, "CM_ BO_")) { + parseCM_BO(line); + } else if (startsWith(line, "CM_ SG_ ")) { + parseCM_SG(line); } else { seen = false; } - } catch (std::exception &e) { - throw std::runtime_error(QString("[%1:%2]%3: %4").arg(QString::fromStdString(filename)).arg(line_num).arg(e.what()).arg(line).toStdString()); - } - - if (seen) { - seen_first = true; - } else if (!seen_first) { - header += raw_line.toStdString() + "\n"; + } catch (const std::exception &e) { + throw std::runtime_error("[" + filename + ":" + std::to_string(statement_line) + "]" + e.what() + ": " + line); } + if (seen) seen_first = true; + else if (!seen_first) header += raw_line + "\n"; } - - for (auto &[_, m] : msgs) { - m.update(); - } + for (auto &[_, message] : msgs) message.update(); } -cabana::Msg *DBCFile::parseBO(const QString &line) { - static QRegularExpression bo_regexp(R"(^BO_ (?
    \w+) (?\w+) *: (?\w+) (?\w+))"); - - QRegularExpressionMatch match = bo_regexp.match(line); - if (!match.hasMatch()) - throw std::runtime_error("Invalid BO_ line format"); - - uint32_t address = match.captured("address").toUInt(); - if (msgs.count(address) > 0) - throw std::runtime_error(QString("Duplicate message address: %1").arg(address).toStdString()); - - // Create a new message object - cabana::Msg *msg = &msgs[address]; - msg->address = address; - msg->name = match.captured("name").toStdString(); - msg->size = match.captured("size").toULong(); - msg->transmitter = match.captured("transmitter").trimmed().toStdString(); - return msg; +cabana::Msg *DBCFile::parseBO(const std::string &line) { + static const std::regex pattern(R"(^BO_ ([[:alnum:]_]+) ([[:alnum:]_]+) *: ([[:alnum:]_]+) ([[:alnum:]_]+))"); + std::smatch match; + if (!std::regex_search(line, match, pattern)) throw std::runtime_error("Invalid BO_ line format"); + const uint32_t address = std::stoul(match[1].str()); + if (msgs.count(address)) throw std::runtime_error("Duplicate message address: " + std::to_string(address)); + auto &message = msgs[address]; + message.address = address; + message.name = match[2].str(); + message.size = std::stoul(match[3].str()); + message.transmitter = trim(match[4].str()); + return &message; } -void DBCFile::parseCM_BO(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream) { - static QRegularExpression msg_comment_regexp(R"(^CM_ BO_ *(?
    \w+) *\"(?(?:[^"\\]|\\.)*)\"\s*;)"); +void DBCFile::parseSG(const std::string &line, cabana::Msg *current_msg, int &multiplexor_cnt) { + static const std::regex pattern(R"dbc(^SG_ ([[:alnum:]_]+)(?: +([[:alnum:]_]+))? *: ([0-9]+)\|([0-9]+)@([0-9]+)([+-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] "(.*)" (.*))dbc"); + if (!current_msg) throw std::runtime_error("No Message"); + std::smatch match; + if (!std::regex_search(line, match, pattern)) throw std::runtime_error("Invalid SG_ line format"); + if (current_msg->sig(match[1].str())) throw std::runtime_error("Duplicate signal name"); - QString parse_line = line; - if (!parse_line.endsWith("\";")) { - int pos = stream.pos() - raw_line.length() - 1; - parse_line = content.mid(pos, content.indexOf("\";", pos)); - } - auto match = msg_comment_regexp.match(parse_line); - if (!match.hasMatch()) - throw std::runtime_error("Invalid message comment format"); - - if (auto m = (cabana::Msg *)msg(match.captured("address").toUInt())) - m->comment = match.captured("comment").trimmed().replace("\\\"", "\"").toStdString(); -} - -void DBCFile::parseSG(const QString &line, cabana::Msg *current_msg, int &multiplexor_cnt) { - static QRegularExpression sg_regexp(R"(^SG_ (\w+) *: (\d+)\|(\d+)@(\d+)([\+|\-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] \"(.*)\" (.*))"); - static QRegularExpression sgm_regexp(R"(^SG_ (\w+) (\w+) *: (\d+)\|(\d+)@(\d+)([\+|\-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] \"(.*)\" (.*))"); - - if (!current_msg) - throw std::runtime_error("No Message"); - - int offset = 0; - auto match = sg_regexp.match(line); - if (!match.hasMatch()) { - match = sgm_regexp.match(line); - offset = 1; - } - if (!match.hasMatch()) - throw std::runtime_error("Invalid SG_ line format"); - - std::string name = match.captured(1).toStdString(); - if (current_msg->sig(name) != nullptr) - throw std::runtime_error("Duplicate signal name"); - - cabana::Signal s{}; - if (offset == 1) { - auto indicator = match.captured(2); + cabana::Signal signal{}; + const std::string indicator = match[2].str(); + if (!indicator.empty()) { if (indicator == "M") { - ++multiplexor_cnt; - // Only one signal within a single message can be the multiplexer switch. - if (multiplexor_cnt >= 2) - throw std::runtime_error("Multiple multiplexor"); - - s.type = cabana::Signal::Type::Multiplexor; + if (++multiplexor_cnt >= 2) throw std::runtime_error("Multiple multiplexor"); + signal.type = cabana::Signal::Type::Multiplexor; } else { - s.type = cabana::Signal::Type::Multiplexed; - s.multiplex_value = indicator.mid(1).toInt(); + signal.type = cabana::Signal::Type::Multiplexed; + signal.multiplex_value = indicator.size() > 1 ? std::stoi(indicator.substr(1)) : 0; } } - s.name = name; - s.start_bit = match.captured(offset + 2).toInt(); - s.size = match.captured(offset + 3).toInt(); - s.is_little_endian = match.captured(offset + 4).toInt() == 1; - s.is_signed = match.captured(offset + 5) == "-"; - s.factor = match.captured(offset + 6).toDouble(); - s.offset = match.captured(offset + 7).toDouble(); - s.min = match.captured(8 + offset).toDouble(); - s.max = match.captured(9 + offset).toDouble(); - s.unit = match.captured(10 + offset).toStdString(); - s.receiver_name = match.captured(11 + offset).trimmed().toStdString(); - current_msg->sigs.push_back(new cabana::Signal(s)); + signal.name = match[1].str(); + signal.start_bit = std::stoi(match[3].str()); + signal.size = std::stoi(match[4].str()); + signal.is_little_endian = match[5].str() == "1"; + signal.is_signed = match[6].str() == "-"; + signal.factor = std::stod(match[7].str()); + signal.offset = std::stod(match[8].str()); + signal.min = std::stod(match[9].str()); + signal.max = std::stod(match[10].str()); + signal.unit = match[11].str(); + signal.receiver_name = trim(match[12].str()); + current_msg->sigs.push_back(new cabana::Signal(signal)); } -void DBCFile::parseCM_SG(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream) { - static QRegularExpression sg_comment_regexp(R"(^CM_ SG_ *(\w+) *(\w+) *\"((?:[^"\\]|\\.)*)\"\s*;)"); - - QString parse_line = line; - if (!parse_line.endsWith("\";")) { - int pos = stream.pos() - raw_line.length() - 1; - parse_line = content.mid(pos, content.indexOf("\";", pos)); +void DBCFile::parseCM_BO(const std::string &line) { + std::istringstream prefix(line.substr(7)); + uint32_t address = 0; + prefix >> address; + const size_t first_quote = line.find('"'); + const size_t last_quote = line.rfind('"'); + if (!prefix || first_quote == std::string::npos || last_quote <= first_quote) { + throw std::runtime_error("Invalid message comment format"); } - auto match = sg_comment_regexp.match(parse_line); - if (!match.hasMatch()) + if (auto message = msg(address)) message->comment = unescapeComment(line.substr(first_quote + 1, last_quote - first_quote - 1)); +} + +void DBCFile::parseCM_SG(const std::string &line) { + std::istringstream prefix(line.substr(7)); + uint32_t address = 0; + std::string name; + prefix >> address >> name; + const size_t first_quote = line.find('"'); + const size_t last_quote = line.rfind('"'); + if (!prefix || name.empty() || first_quote == std::string::npos || last_quote <= first_quote) { throw std::runtime_error("Invalid CM_ SG_ line format"); - - if (auto s = signal(match.captured(1).toUInt(), match.captured(2).toStdString())) { - s->comment = match.captured(3).trimmed().replace("\\\"", "\"").toStdString(); } + if (auto sig = signal(address, name)) sig->comment = unescapeComment(line.substr(first_quote + 1, last_quote - first_quote - 1)); } -void DBCFile::parseVAL(const QString &line) { - static QRegularExpression val_regexp(R"(VAL_ (\w+) (\w+) (\s*[-+]?[0-9]+\s+\".+?\"[^;]*))"); - - auto match = val_regexp.match(line); - if (!match.hasMatch()) - throw std::runtime_error("invalid VAL_ line format"); - - if (auto s = signal(match.captured(1).toUInt(), match.captured(2).toStdString())) { - QStringList desc_list = match.captured(3).trimmed().split('"'); - for (int i = 0; i < desc_list.size(); i += 2) { - auto val = desc_list[i].trimmed(); - if (!val.isEmpty() && (i + 1) < desc_list.size()) { - auto desc = desc_list[i + 1].trimmed(); - s->val_desc.push_back({val.toDouble(), desc.toStdString()}); - } +void DBCFile::parseVAL(const std::string &line) { + static const std::regex header_pattern(R"(^VAL_ ([[:alnum:]_]+) ([[:alnum:]_]+) (.*))"); + static const std::regex entry_pattern(R"dbc(([+-]?[0-9]+(?:\.[0-9]+)?)\s+"([^"]*)")dbc"); + std::smatch match; + if (!std::regex_search(line, match, header_pattern)) throw std::runtime_error("invalid VAL_ line format"); + if (auto sig = signal(std::stoul(match[1].str()), match[2].str())) { + const std::string entries = match[3].str(); + for (std::sregex_iterator it(entries.begin(), entries.end(), entry_pattern), end; it != end; ++it) { + sig->val_desc.emplace_back(std::stod((*it)[1].str()), trim((*it)[2].str())); } } } @@ -237,40 +238,29 @@ std::string DBCFile::generateDBC() { const std::string &transmitter = m.transmitter.empty() ? DEFAULT_NODE_NAME : m.transmitter; dbc_string += "BO_ " + std::to_string(address) + " " + m.name + ": " + std::to_string(m.size) + " " + transmitter + "\n"; if (!m.comment.empty()) { - std::string escaped_comment = m.comment; - // Replace " with \" - for (size_t pos = 0; (pos = escaped_comment.find('"', pos)) != std::string::npos; pos += 2) - escaped_comment.replace(pos, 1, "\\\""); - comment += "CM_ BO_ " + std::to_string(address) + " \"" + escaped_comment + "\";\n"; + std::string escaped = m.comment; + for (size_t pos = 0; (pos = escaped.find('"', pos)) != std::string::npos; pos += 2) escaped.replace(pos, 1, "\\\""); + comment += "CM_ BO_ " + std::to_string(address) + " \"" + escaped + "\";\n"; } for (auto sig : m.getSignals()) { - std::string multiplexer_indicator; - if (sig->type == cabana::Signal::Type::Multiplexor) { - multiplexer_indicator = "M "; - } else if (sig->type == cabana::Signal::Type::Multiplexed) { - multiplexer_indicator = "m" + std::to_string(sig->multiplex_value) + " "; - } - const std::string &recv = sig->receiver_name.empty() ? DEFAULT_NODE_NAME : sig->receiver_name; - dbc_string += " SG_ " + sig->name + " " + multiplexer_indicator + ": " + - std::to_string(sig->start_bit) + "|" + std::to_string(sig->size) + "@" + - std::string(1, sig->is_little_endian ? '1' : '0') + - std::string(1, sig->is_signed ? '-' : '+') + + std::string mux; + if (sig->type == cabana::Signal::Type::Multiplexor) mux = "M "; + else if (sig->type == cabana::Signal::Type::Multiplexed) mux = "m" + std::to_string(sig->multiplex_value) + " "; + const std::string &receiver = sig->receiver_name.empty() ? DEFAULT_NODE_NAME : sig->receiver_name; + dbc_string += " SG_ " + sig->name + " " + mux + ": " + std::to_string(sig->start_bit) + "|" + std::to_string(sig->size) + "@" + + (sig->is_little_endian ? "1" : "0") + (sig->is_signed ? "-" : "+") + " (" + doubleToString(sig->factor) + "," + doubleToString(sig->offset) + ")" + - " [" + doubleToString(sig->min) + "|" + doubleToString(sig->max) + "]" + - " \"" + sig->unit + "\" " + recv + "\n"; + " [" + doubleToString(sig->min) + "|" + doubleToString(sig->max) + "] \"" + sig->unit + "\" " + receiver + "\n"; if (!sig->comment.empty()) { - std::string escaped_comment = sig->comment; - for (size_t pos = 0; (pos = escaped_comment.find('"', pos)) != std::string::npos; pos += 2) - escaped_comment.replace(pos, 1, "\\\""); - comment += "CM_ SG_ " + std::to_string(address) + " " + sig->name + " \"" + escaped_comment + "\";\n"; + std::string escaped = sig->comment; + for (size_t pos = 0; (pos = escaped.find('"', pos)) != std::string::npos; pos += 2) escaped.replace(pos, 1, "\\\""); + comment += "CM_ SG_ " + std::to_string(address) + " " + sig->name + " \"" + escaped + "\";\n"; } if (!sig->val_desc.empty()) { std::string text; - for (auto &[val, desc] : sig->val_desc) { + for (const auto &[value, description] : sig->val_desc) { if (!text.empty()) text += " "; - char val_buf[64]; - snprintf(val_buf, sizeof(val_buf), "%g", val); - text += std::string(val_buf) + " \"" + desc + "\""; + text += doubleToString(value) + " \"" + description + "\""; } val_desc += "VAL_ " + std::to_string(address) + " " + sig->name + " " + text + ";\n"; } diff --git a/openpilot/tools/cabana/dbc/dbcfile.h b/openpilot/tools/cabana/dbc/dbcfile.h index decb566abd..13841a94fa 100644 --- a/openpilot/tools/cabana/dbc/dbcfile.h +++ b/openpilot/tools/cabana/dbc/dbcfile.h @@ -2,7 +2,6 @@ #include #include -#include #include "tools/cabana/dbc/dbc.h" @@ -32,12 +31,12 @@ public: std::string filename; private: - void parse(const QString &content); - cabana::Msg *parseBO(const QString &line); - void parseSG(const QString &line, cabana::Msg *current_msg, int &multiplexor_cnt); - void parseCM_BO(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream); - void parseCM_SG(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream); - void parseVAL(const QString &line); + void parse(const std::string &content); + cabana::Msg *parseBO(const std::string &line); + void parseSG(const std::string &line, cabana::Msg *current_msg, int &multiplexor_cnt); + void parseCM_BO(const std::string &line); + void parseCM_SG(const std::string &line); + void parseVAL(const std::string &line); std::string header; std::map msgs; diff --git a/openpilot/tools/cabana/dbc/dbcmanager.cc b/openpilot/tools/cabana/dbc/dbcmanager.cc index 2236a93da1..7a95a4f809 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.cc +++ b/openpilot/tools/cabana/dbc/dbcmanager.cc @@ -1,9 +1,10 @@ #include "tools/cabana/dbc/dbcmanager.h" #include +#include #include -bool DBCManager::open(const SourceSet &sources, const std::string &dbc_file_name, QString *error) { +bool DBCManager::open(const SourceSet &sources, const std::string &dbc_file_name, std::string *error) { try { auto it = std::find_if(dbc_files.begin(), dbc_files.end(), [&](auto &f) { return f.second && f.second->filename == dbc_file_name; }); @@ -16,11 +17,11 @@ bool DBCManager::open(const SourceSet &sources, const std::string &dbc_file_name return false; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); return true; } -bool DBCManager::open(const SourceSet &sources, const std::string &name, const std::string &content, QString *error) { +bool DBCManager::open(const SourceSet &sources, const std::string &name, const std::string &content, std::string *error) { try { auto file = std::make_shared(name, content); for (auto s : sources) { @@ -31,7 +32,7 @@ bool DBCManager::open(const SourceSet &sources, const std::string &name, const s return false; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); return true; } @@ -39,26 +40,26 @@ void DBCManager::close(const SourceSet &sources) { for (auto s : sources) { dbc_files[s] = nullptr; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); } void DBCManager::close(DBCFile *dbc_file) { for (auto &[_, f] : dbc_files) { if (f.get() == dbc_file) f = nullptr; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); } void DBCManager::closeAll() { dbc_files.clear(); - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); } void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) { if (auto m = msg(id)) { if (auto s = m->addSignal(sig)) { - emit signalAdded(id, s); - emit maskUpdated(); + if (callbacks_.signal_added) callbacks_.signal_added(id, s); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } } } @@ -66,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)) { - emit signalUpdated(s); - emit maskUpdated(); + if (callbacks_.signal_updated) callbacks_.signal_updated(s); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } } } @@ -75,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)) { - emit signalRemoved(s); + if (callbacks_.signal_removed) callbacks_.signal_removed(s); m->removeSignal(sig_name); - emit maskUpdated(); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } } } @@ -86,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); - emit msgUpdated(id); + if (callbacks_.msg_updated) callbacks_.msg_updated(id); } void DBCManager::removeMsg(const MessageId &id) { auto dbc_file = findDBCFile(id); assert(dbc_file); // This should be impossible dbc_file->removeMsg(id); - emit msgRemoved(id); - emit maskUpdated(); + if (callbacks_.msg_removed) callbacks_.msg_removed(id); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } std::string DBCManager::newMsgName(const MessageId &id) { @@ -176,6 +177,6 @@ std::string toString(const SourceSet &ss) { } DBCManager *dbc() { - static DBCManager dbc_manager(nullptr); + static DBCManager dbc_manager; return &dbc_manager; } diff --git a/openpilot/tools/cabana/dbc/dbcmanager.h b/openpilot/tools/cabana/dbc/dbcmanager.h index 4a122073ea..5a09fae03d 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.h +++ b/openpilot/tools/cabana/dbc/dbcmanager.h @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include #include @@ -11,17 +11,23 @@ typedef std::set SourceSet; const SourceSet SOURCE_ALL = {-1}; -const int INVALID_SOURCE = 0xff; inline bool operator<(const std::shared_ptr &l, const std::shared_ptr &r) { return l.get() < r.get(); } -class DBCManager : public QObject { - Q_OBJECT - +class DBCManager { public: - DBCManager(QObject *parent) : QObject(parent) {} - ~DBCManager() {} - bool open(const SourceSet &sources, const std::string &dbc_file_name, QString *error = nullptr); - bool open(const SourceSet &sources, const std::string &name, const std::string &content, QString *error = nullptr); + struct Callbacks { + std::function 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); void close(const SourceSet &sources); void close(DBCFile *dbc_file); void closeAll(); @@ -48,18 +54,11 @@ public: DBCFile *findDBCFile(const uint8_t source); inline DBCFile *findDBCFile(const MessageId &id) { return findDBCFile(id.source); } std::set allDBCFiles(); - -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(); + void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); } 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 new file mode 100644 index 0000000000..4354caf3f5 --- /dev/null +++ b/openpilot/tools/cabana/dbc/dbcqt.cc @@ -0,0 +1,18 @@ +#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 new file mode 100644 index 0000000000..b889f854ed --- /dev/null +++ b/openpilot/tools/cabana/dbc/dbcqt.h @@ -0,0 +1,27 @@ +#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/deqt.md b/openpilot/tools/cabana/deqt.md new file mode 100644 index 0000000000..3bef2d7ab0 --- /dev/null +++ b/openpilot/tools/cabana/deqt.md @@ -0,0 +1,53 @@ +we're migrating cabana away from Qt and to eventually entirely use imgui + +we are doing it incrementally, in small pieces that are easy to execute and verify. +we will repeat this until we're all done. + +# Cabana Qt API inventory + +these are all still in cabana. we remove them from this list once they're gone. +each bullet is an atomic unit of work. + +our workflow is: +- pick the easiest of the bulleted items from below +- implement it and make] sure it builds +- spin up reviewer agents to review the code in a clean context and a separate one to click around in xvfb as a gui test +- then implement the fixes from the above reviewer agents + +some rules +- do not add more Qt usage ever + +- `QObject`, `QMetaObject`, `QMetaType` +- `QApplication`, `QCoreApplication`, `QGuiApplication` +- `QString`, `QStringList`, `QStringBuilder`, `QChar`, `QLatin1Char` +- `QVariant` +- `QTimer` +- `QWidget`, `QMainWindow`, `QWindow` +- `QDialog`, `QDialogButtonBox`, `QMessageBox`, `QProgressDialog` +- `QFileDialog` +- `QMenu`, `QMenuBar`, `QAction`, `QActionGroup`, `QWidgetAction` +- `QToolBar`, `QToolButton`, `QPushButton` +- `QCheckBox`, `QRadioButton`, `QButtonGroup`, `QAbstractButton` +- `QComboBox`, `QLineEdit`, `QTextEdit`, `QSpinBox`, `QSlider` +- `QLabel`, `QGroupBox`, `QFrame` +- `QTabBar`, `QTabWidget`, `QSplitter`, `QScrollArea`, `QScrollBar` +- `QDockWidget`, `QStatusBar`, `QProgressBar` +- `QFormLayout`, `QGridLayout`, `QHBoxLayout`, `QVBoxLayout` +- `QSizePolicy` +- `QAbstractItemModel`, `QAbstractTableModel`, `QModelIndex` +- `QAbstractItemView`, `QTableView`, `QTreeView` +- `QTableWidget`, `QTableWidgetItem`, `QListWidget`, `QListWidgetItem` +- `QItemSelection`, `QItemSelectionModel`, `QItemSelectionRange` +- `QHeaderView`, `QStyledItemDelegate`, `QStyleOptionViewItem` +- `QValidator`, `QIntValidator` +- `QColor`, `QRgb`, `QPalette` +- `QBrush`, `QPen` +- `QPainter`, `QPainterPath`, `QStylePainter` +- `QImage`, `QPixmap`, `QPixmapCache`, `QStaticText` +- `QFont`, `QFontDatabase`, `QFontMetrics`, `QTextDocument` +- `QStyle`, `QStyleOption`, `QStyleOptionFrame`, `QStyleOptionSlider` +- `QPoint`, `QPointF`, `QRect`, `QRectF`, `QRegion` +- `QSize`, `QSizeF` +- `QEvent`, `QPaintEvent`, `QResizeEvent`, `QShowEvent`, `QCloseEvent` +- `QMouseEvent`, `QWheelEvent`, `QNativeGestureEvent`, `QContextMenuEvent` +- `QKeySequence`, `QShortcut`, `QToolTip` diff --git a/openpilot/tools/cabana/detailwidget.cc b/openpilot/tools/cabana/detailwidget.cc index 148b059e5b..6b62f54959 100644 --- a/openpilot/tools/cabana/detailwidget.cc +++ b/openpilot/tools/cabana/detailwidget.cc @@ -1,4 +1,5 @@ #include "tools/cabana/detailwidget.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -56,8 +57,8 @@ DetailWidget::DetailWidget(ChartsWidget *charts, QWidget *parent) : charts(chart 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(dbc(), &DBCManager::DBCFileChanged, this, &DetailWidget::refresh); - QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &DetailWidget::refresh); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &DetailWidget::refresh); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &DetailWidget::refresh); QObject::connect(tabbar, &QTabBar::customContextMenuRequested, this, &DetailWidget::showTabBarContextMenu); QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) { if (index != -1) { @@ -210,13 +211,13 @@ void DetailWidget::editMsg() { int size = msg ? msg->size : can->lastMessage(msg_id).dat.size(); EditMessageDialog dlg(msg_id, QString::fromStdString(msgName(msg_id)), size, this); if (dlg.exec()) { - UndoStack::push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed().toStdString(), dlg.size_spin->value(), + UndoStack::instance()->push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed().toStdString(), dlg.size_spin->value(), dlg.node->text().trimmed().toStdString(), dlg.comment_edit->toPlainText().trimmed().toStdString())); } } void DetailWidget::removeMsg() { - UndoStack::push(new RemoveMsgCommand(msg_id)); + UndoStack::instance()->push(new RemoveMsgCommand(msg_id)); } // EditMessageDialog diff --git a/openpilot/tools/cabana/historylog.cc b/openpilot/tools/cabana/historylog.cc index fb79ff9cea..3fd569c648 100644 --- a/openpilot/tools/cabana/historylog.cc +++ b/openpilot/tools/cabana/historylog.cc @@ -1,4 +1,5 @@ #include "tools/cabana/historylog.h" +#include "tools/cabana/dbc/dbcqt.h" #include @@ -54,7 +55,7 @@ QVariant HistoryLogModel::headerData(int section, Qt::Orientation orientation, i return unit.isEmpty() ? name : QString("%1 (%2)").arg(name, unit); } else if (role == Qt::BackgroundRole && section > 0 && !isHexMode()) { // Alpha-blend the signal color with the background to ensure contrast - QColor sigColor = sigs[section - 1]->color; + QColor sigColor = toQColor(sigs[section - 1]->color); sigColor.setAlpha(128); return QBrush(sigColor); } @@ -207,8 +208,8 @@ LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) { 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(dbc(), &DBCManager::DBCFileChanged, model, &HistoryLogModel::reset); - QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, 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); }); } @@ -238,11 +239,11 @@ void LogsWidget::filterChanged() { } void LogsWidget::exportToCSV() { - QString dir = QString("%1/%2_%3.csv").arg(settings.last_dir).arg(QString::fromStdString(can->routeName())).arg(QString::fromStdString(msgName(model->msg_id))); + QString dir = QString("%1/%2_%3.csv").arg(QString::fromStdString(settings.last_dir)).arg(QString::fromStdString(can->routeName())).arg(QString::fromStdString(msgName(model->msg_id))); QString fn = QFileDialog::getSaveFileName(this, QString("Export %1 to CSV file").arg(QString::fromStdString(msgName(model->msg_id))), dir, tr("csv (*.csv)")); if (!fn.isEmpty()) { - model->isHexMode() ? utils::exportToCSV(fn, model->msg_id) - : utils::exportSignalsToCSV(fn, model->msg_id); + model->isHexMode() ? utils::exportToCSV(fn.toStdString(), model->msg_id) + : utils::exportSignalsToCSV(fn.toStdString(), model->msg_id); } } diff --git a/openpilot/tools/cabana/historylog.h b/openpilot/tools/cabana/historylog.h index 1ac6e5bbad..1d3200b200 100644 --- a/openpilot/tools/cabana/historylog.h +++ b/openpilot/tools/cabana/historylog.h @@ -40,7 +40,7 @@ public: uint64_t mono_time = 0; std::vector sig_values; std::vector data; - std::vector colors; + std::vector colors; }; void fetchData(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time); diff --git a/openpilot/tools/cabana/mainwin.cc b/openpilot/tools/cabana/mainwin.cc index 39fb979c79..953f573a0b 100644 --- a/openpilot/tools/cabana/mainwin.cc +++ b/openpilot/tools/cabana/mainwin.cc @@ -1,25 +1,24 @@ #include "tools/cabana/mainwin.h" +#include "tools/cabana/dbc/dbcqt.h" #include +#include +#include #include +#include #include +#include -#include -#include -#include #include -#include -#include #include #include #include #include #include #include -#include #include -#include +#include "json11/json11.hpp" #include "tools/cabana/commands.h" #include "tools/cabana/streamselector.h" #include "tools/cabana/tools/findsignal.h" @@ -36,14 +35,11 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW createShortcuts(); // save default window state to allow resetting it - default_state = saveState(); + default_state = utils::toBytes(saveState()); - // restore states - restoreGeometry(settings.geometry); - if (isMaximized()) { - setGeometry(QApplication::desktop()->availableGeometry(this)); - } - restoreState(settings.window_state); + // restore states; restoreGeometry() itself corrects stale off-screen geometry + restoreGeometry(utils::qbytes(settings.geometry)); + restoreState(utils::qbytes(settings.window_state)); // install handlers static auto static_main_win = this; @@ -52,11 +48,9 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW installDownloadProgressHandler([](uint64_t cur, uint64_t total, bool success) { emit static_main_win->updateProgressBar(cur, total, success); }); - qInstallMessageHandler([](QtMsgType type, const QMessageLogContext &context, const QString &msg) { - if (type == QtDebugMsg) return; - emit static_main_win->showMessage(msg, 2000); + installMessageHandler([](ReplyMsgType type, const std::string msg) { + emit static_main_win->showMessage(QString::fromStdString(msg), 2000); }); - installMessageHandler([](ReplyMsgType type, const std::string msg) { qInfo() << msg.c_str(); }); setStyleSheet(QString(R"(QMainWindow::separator { width: %1px; /* when vertical */ @@ -65,8 +59,8 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW QObject::connect(this, &MainWindow::showMessage, statusBar(), &QStatusBar::showMessage); QObject::connect(this, &MainWindow::updateProgressBar, this, &MainWindow::updateDownloadProgress); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &MainWindow::DBCFileChanged); - QObject::connect(UndoStack::instance(), &QUndoStack::cleanChanged, this, &MainWindow::undoStackCleanChanged); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &MainWindow::DBCFileChanged); + QObject::connect(undoNotifier(), &QtUndoNotifier::cleanChanged, this, &MainWindow::undoStackCleanChanged); QObject::connect(&settings, &Settings::changed, this, &MainWindow::updateStatus); QTimer::singleShot(0, this, [=]() { stream ? openStream(stream, dbc_file) : selectAndOpenStream(); }); @@ -74,9 +68,17 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW } void MainWindow::loadFingerprints() { - QFile json_file(QApplication::applicationDirPath() + "/dbc/car_fingerprint_to_dbc.json"); - if (json_file.open(QIODevice::ReadOnly)) { - fingerprint_to_dbc = QJsonDocument::fromJson(json_file.readAll()); + std::ifstream json_file((QApplication::applicationDirPath() + "/dbc/car_fingerprint_to_dbc.json").toStdString()); + if (!json_file) return; + const std::string contents{std::istreambuf_iterator(json_file), std::istreambuf_iterator()}; + std::string err; + auto doc = json11::Json::parse(contents, err); + if (!err.empty() || !doc.is_object()) return; + fingerprint_to_dbc.clear(); + for (const auto &kv : doc.object_items()) { + if (kv.second.is_string()) { + fingerprint_to_dbc.emplace(kv.first, kv.second.string_value()); + } } } @@ -102,8 +104,17 @@ void MainWindow::createActions() { file_menu->addSeparator(); QMenu *load_opendbc_menu = file_menu->addMenu(tr("Load DBC from commaai/opendbc")); // load_opendbc_menu->setStyleSheet("QMenu { menu-scrollable: true; }"); - for (const auto &dbc_name : QDir(OPENDBC_FILE_PATH).entryList({"*.dbc"}, QDir::Files, QDir::Name)) { - load_opendbc_menu->addAction(dbc_name, [this, name = dbc_name]() { loadDBCFromOpendbc(name); }); + std::vector dbc_names; + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH, ec)) { + if (entry.is_regular_file() && entry.path().extension() == ".dbc") { + dbc_names.push_back(entry.path().filename().string()); + } + } + std::sort(dbc_names.begin(), dbc_names.end()); + for (const auto &dbc_name : dbc_names) { + QString name = QString::fromStdString(dbc_name); + load_opendbc_menu->addAction(name, [this, name]() { loadDBCFromOpendbc(name); }); } file_menu->addAction(tr("Load DBC From Clipboard"), [=]() { loadFromClipboard(); }); @@ -121,18 +132,12 @@ void MainWindow::createActions() { // Edit Menu QMenu *edit_menu = menuBar()->addMenu(tr("&Edit")); - auto undo_act = UndoStack::instance()->createUndoAction(this, tr("&Undo")); + undo_act = edit_menu->addAction(tr("&Undo"), []() { UndoStack::instance()->undo(); }); undo_act->setShortcuts(QKeySequence::Undo); - edit_menu->addAction(undo_act); - auto redo_act = UndoStack::instance()->createRedoAction(this, tr("&Redo")); + redo_act = edit_menu->addAction(tr("&Redo"), []() { UndoStack::instance()->redo(); }); redo_act->setShortcuts(QKeySequence::Redo); - edit_menu->addAction(redo_act); - edit_menu->addSeparator(); - - QMenu *commands_menu = edit_menu->addMenu(tr("Command &List")); - QWidgetAction *commands_act = new QWidgetAction(this); - commands_act->setDefaultWidget(new QUndoView(UndoStack::instance())); - commands_menu->addAction(commands_act); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &MainWindow::updateUndoRedoActions); + updateUndoRedoActions(); // View Menu QMenu *view_menu = menuBar()->addMenu(tr("&View")); @@ -142,7 +147,7 @@ void MainWindow::createActions() { view_menu->addAction(messages_dock->toggleViewAction()); view_menu->addAction(video_dock->toggleViewAction()); view_menu->addSeparator(); - view_menu->addAction(tr("Reset Window Layout"), [this]() { restoreState(default_state); }); + view_menu->addAction(tr("Reset Window Layout"), [this]() { restoreState(utils::qbytes(default_state)); }); // Tools Menu tools_menu = menuBar()->addMenu(tr("&Tools")); @@ -189,7 +194,7 @@ void MainWindow::createDockWidgets() { video_splitter->addWidget(charts_container); video_splitter->setStretchFactor(1, 1); - video_splitter->restoreState(settings.video_splitter_state); + video_splitter->restoreState(utils::qbytes(settings.video_splitter_state)); video_splitter->handle(1)->setEnabled(!can->liveStreaming()); video_dock->setWidget(video_splitter); QObject::connect(charts_widget, &ChartsWidget::toggleChartsDocking, this, &MainWindow::toggleChartsDocking); @@ -220,6 +225,14 @@ void MainWindow::undoStackCleanChanged(bool clean) { setWindowModified(!clean); } +void MainWindow::updateUndoRedoActions() { + auto stack = UndoStack::instance(); + undo_act->setEnabled(stack->canUndo()); + undo_act->setText(stack->canUndo() ? tr("&Undo %1").arg(QString::fromStdString(stack->undoText())) : tr("&Undo")); + redo_act->setEnabled(stack->canRedo()); + redo_act->setText(stack->canRedo() ? tr("&Redo %1").arg(QString::fromStdString(stack->redoText())) : tr("&Redo")); +} + void MainWindow::DBCFileChanged() { UndoStack::instance()->clear(); @@ -253,16 +266,16 @@ void MainWindow::selectAndOpenStream() { void MainWindow::closeStream() { openStream(new DummyStream(this)); if (dbc()->nonEmptyDBCCount() > 0) { - emit dbc()->DBCFileChanged(); + emit dbcNotifier()->DBCFileChanged(); } statusBar()->showMessage(tr("stream closed")); } void MainWindow::exportToCSV() { - QString dir = QString("%1/%2.csv").arg(settings.last_dir).arg(QString::fromStdString(can->routeName())); + QString dir = QString("%1/%2.csv").arg(QString::fromStdString(settings.last_dir)).arg(QString::fromStdString(can->routeName())); QString fn = QFileDialog::getSaveFileName(this, "Export stream to CSV file", dir, tr("csv (*.csv)")); if (!fn.isEmpty()) { - utils::exportToCSV(fn); + utils::exportToCSV(fn.toStdString()); } } @@ -273,7 +286,7 @@ void MainWindow::newFile(SourceSet s) { void MainWindow::openFile(SourceSet s) { remindSaveChanges(); - QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), settings.last_dir, "DBC (*.dbc)"); + QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)"); if (!fn.isEmpty()) { loadFile(fn, s); } @@ -283,13 +296,13 @@ void MainWindow::loadFile(const QString &fn, SourceSet s) { if (!fn.isEmpty()) { closeFile(s); - QString error; + std::string error; if (dbc()->open(s, fn.toStdString(), &error)) { updateRecentFiles(fn); statusBar()->showMessage(tr("DBC File %1 loaded").arg(fn), 2000); } else { QMessageBox msg_box(QMessageBox::Warning, tr("Failed to load DBC file"), tr("Failed to parse DBC file %1").arg(fn)); - msg_box.setDetailedText(error); + msg_box.setDetailedText(QString::fromStdString(error)); msg_box.exec(); } } @@ -300,16 +313,25 @@ void MainWindow::loadDBCFromOpendbc(const QString &name) { } void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { + std::string text; + if (!utils::getClipboardText(&text)) { + QMessageBox::warning(this, tr("Load From Clipboard"), tr("No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland).")); + return; + } + if (text.empty()) { + QMessageBox::warning(this, tr("Load From Clipboard"), tr("Clipboard is empty.")); + return; + } + closeFile(s); - QString dbc_str = QGuiApplication::clipboard()->text(); - QString error; - bool ret = dbc()->open(s, std::string(""), dbc_str.toStdString(), &error); + std::string error; + bool ret = dbc()->open(s, std::string(""), text, &error); if (ret && dbc()->nonEmptyDBCCount() > 0) { QMessageBox::information(this, tr("Load From Clipboard"), tr("DBC Successfully Loaded!")); } else { QMessageBox msg_box(QMessageBox::Warning, tr("Failed to load DBC from clipboard"), tr("Make sure that you paste the text with correct format.")); - msg_box.setDetailedText(error); + msg_box.setDetailedText(QString::fromStdString(error)); msg_box.exec(); } } @@ -373,8 +395,11 @@ void MainWindow::eventsMerged() { .arg(QString::fromStdString(can->routeName())) .arg(car_fingerprint.isEmpty() ? tr("Unknown Car") : car_fingerprint)); // Don't overwrite already loaded DBC - if (!dbc()->nonEmptyDBCCount() && fingerprint_to_dbc.object().contains(car_fingerprint)) { - QTimer::singleShot(0, this, [this]() { loadDBCFromOpendbc(fingerprint_to_dbc[car_fingerprint].toString() + ".dbc"); }); + auto it = fingerprint_to_dbc.find(car_fingerprint.toStdString()); + if (!dbc()->nonEmptyDBCCount() && it != fingerprint_to_dbc.end()) { + QTimer::singleShot(0, this, [this, dbc_name = QString::fromStdString(it->second)]() { + loadDBCFromOpendbc(dbc_name + ".dbc"); + }); } } } @@ -427,7 +452,7 @@ void MainWindow::saveFile(DBCFile *dbc_file) { void MainWindow::saveFileAs(DBCFile *dbc_file) { QString title = tr("Save File (bus: %1)").arg(QString::fromStdString(toString(dbc()->sources(dbc_file)))); - QString fn = QFileDialog::getSaveFileName(this, title, QDir::cleanPath(settings.last_dir + "/untitled.dbc"), tr("DBC (*.dbc)")); + QString fn = QFileDialog::getSaveFileName(this, title, QString::fromStdString((std::filesystem::path(settings.last_dir) / "untitled.dbc").string()), tr("DBC (*.dbc)")); if (!fn.isEmpty()) { dbc_file->saveAs(fn.toStdString()); UndoStack::instance()->setClean(); @@ -446,8 +471,11 @@ void MainWindow::saveToClipboard() { void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { assert(dbc_file != nullptr); - QGuiApplication::clipboard()->setText(QString::fromStdString(dbc_file->generateDBC())); - QMessageBox::information(this, tr("Copy To Clipboard"), tr("DBC Successfully copied!")); + if (utils::setClipboardText(dbc_file->generateDBC())) { + QMessageBox::information(this, tr("Copy To Clipboard"), tr("DBC Successfully copied!")); + } else { + QMessageBox::warning(this, tr("Copy To Clipboard"), tr("Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland).")); + } } void MainWindow::updateLoadSaveMenus() { @@ -481,12 +509,13 @@ void MainWindow::updateLoadSaveMenus() { } void MainWindow::updateRecentFiles(const QString &fn) { - settings.recent_files.removeAll(fn); - settings.recent_files.prepend(fn); + const std::string filename = fn.toStdString(); + settings.recent_files.erase(std::remove(settings.recent_files.begin(), settings.recent_files.end(), filename), settings.recent_files.end()); + settings.recent_files.insert(settings.recent_files.begin(), filename); while (settings.recent_files.size() > MAX_RECENT_FILES) { - settings.recent_files.removeLast(); + settings.recent_files.pop_back(); } - settings.last_dir = QFileInfo(fn).absolutePath(); + settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string(); } void MainWindow::updateRecentFileMenu() { @@ -499,8 +528,8 @@ void MainWindow::updateRecentFileMenu() { } for (int i = 0; i < num_recent_files; ++i) { - QString text = tr("&%1 %2").arg(i + 1).arg(QFileInfo(settings.recent_files[i]).fileName()); - open_recent_menu->addAction(text, this, [this, file = settings.recent_files[i]]() { loadFile(file); }); + QString text = tr("&%1 %2").arg(i + 1).arg(QString::fromStdString(std::filesystem::path(settings.recent_files[i]).filename().string())); + open_recent_menu->addAction(text, this, [this, file = settings.recent_files[i]]() { loadFile(QString::fromStdString(file)); }); } } @@ -560,22 +589,23 @@ void MainWindow::closeEvent(QCloseEvent *event) { remindSaveChanges(); installDownloadProgressHandler(nullptr); - qInstallMessageHandler(nullptr); + installMessageHandler(nullptr); if (floating_window) floating_window->deleteLater(); // save states - settings.geometry = saveGeometry(); - settings.window_state = saveState(); + settings.geometry = utils::toBytes(saveGeometry()); + settings.window_state = utils::toBytes(saveState()); if (can && !can->liveStreaming()) { - settings.video_splitter_state = video_splitter->saveState(); + settings.video_splitter_state = utils::toBytes(video_splitter->saveState()); } if (messages_widget) { settings.message_header_state = messages_widget->saveHeaderState(); } saveSessionState(); + settings.save(); QWidget::closeEvent(event); } @@ -627,30 +657,39 @@ void MainWindow::saveSessionState() { settings.active_charts.clear(); for (auto &f : dbc()->allDBCFiles()) - if (!f->isEmpty()) { settings.recent_dbc_file = QString::fromStdString(f->filename); break; } + if (!f->isEmpty()) { settings.recent_dbc_file = f->filename; break; } if (auto *detail = center_widget->getDetailWidget()) { auto [active_id, ids] = detail->serializeMessageIds(); - settings.active_msg_id = active_id; - settings.selected_msg_ids = ids; + settings.active_msg_id = active_id.toStdString(); + settings.selected_msg_ids.clear(); + for (const auto &id : ids) settings.selected_msg_ids.push_back(id.toStdString()); + } + if (charts_widget) { + settings.active_charts.clear(); + for (const auto &id : charts_widget->serializeChartIds()) settings.active_charts.push_back(id.toStdString()); } - if (charts_widget) - settings.active_charts = charts_widget->serializeChartIds(); } void MainWindow::restoreSessionState() { - if (settings.recent_dbc_file.isEmpty() || dbc()->nonEmptyDBCCount() == 0) return; + if (settings.recent_dbc_file.empty() || dbc()->nonEmptyDBCCount() == 0) return; QString dbc_file; for (auto& f : dbc()->allDBCFiles()) if (!f->isEmpty()) { dbc_file = QString::fromStdString(f->filename); break; } - if (dbc_file != settings.recent_dbc_file) return; + if (dbc_file.toStdString() != settings.recent_dbc_file) return; - if (!settings.selected_msg_ids.isEmpty()) - center_widget->ensureDetailWidget()->restoreTabs(settings.active_msg_id, settings.selected_msg_ids); + if (!settings.selected_msg_ids.empty()) { + QStringList ids; + for (const auto &id : settings.selected_msg_ids) ids.push_back(QString::fromStdString(id)); + center_widget->ensureDetailWidget()->restoreTabs(QString::fromStdString(settings.active_msg_id), ids); + } - if (charts_widget != nullptr && !settings.active_charts.empty()) - charts_widget->restoreChartsFromIds(settings.active_charts); + if (charts_widget != nullptr && !settings.active_charts.empty()) { + QStringList ids; + for (const auto &id : settings.active_charts) ids.push_back(QString::fromStdString(id)); + charts_widget->restoreChartsFromIds(ids); + } } // HelpOverlay diff --git a/openpilot/tools/cabana/mainwin.h b/openpilot/tools/cabana/mainwin.h index 92c2714ae7..279ea3b969 100644 --- a/openpilot/tools/cabana/mainwin.h +++ b/openpilot/tools/cabana/mainwin.h @@ -1,13 +1,16 @@ #pragma once #include -#include #include #include #include #include #include +#include #include +#include +#include +#include #include "tools/cabana/chart/chartswidget.h" #include "tools/cabana/dbc/dbcmanager.h" @@ -67,6 +70,7 @@ protected: void findSimilarBits(); void findSignal(); void undoStackCleanChanged(bool clean); + void updateUndoRedoActions(); void onlineHelp(); void toggleFullScreen(); void updateStatus(); @@ -85,7 +89,7 @@ protected: QVBoxLayout *charts_layout; QProgressBar *progress_bar; QLabel *status_label; - QJsonDocument fingerprint_to_dbc; + std::unordered_map fingerprint_to_dbc; QSplitter *video_splitter = nullptr; enum { MAX_RECENT_FILES = 15 }; QMenu *open_recent_menu = nullptr; @@ -96,8 +100,10 @@ protected: QAction *save_dbc = nullptr; QAction *save_dbc_as = nullptr; QAction *copy_dbc_to_clipboard = nullptr; + QAction *undo_act = nullptr; + QAction *redo_act = nullptr; QString car_fingerprint; - QByteArray default_state; + std::vector default_state; }; class HelpOverlay : public QWidget { diff --git a/openpilot/tools/cabana/messageswidget.cc b/openpilot/tools/cabana/messageswidget.cc index 75cdaa7cc3..b07e3ea0bf 100644 --- a/openpilot/tools/cabana/messageswidget.cc +++ b/openpilot/tools/cabana/messageswidget.cc @@ -1,4 +1,5 @@ #include "tools/cabana/messageswidget.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -43,8 +44,8 @@ MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget 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(dbc(), &DBCManager::DBCFileChanged, model, &MessageListModel::dbcModified); - QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, model, &MessageListModel::dbcModified); + 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); @@ -211,7 +212,7 @@ QVariant MessageListModel::data(const QModelIndex &index, int role) const { return {}; } -void MessageListModel::setFilterStrings(const QMap &filters) { +void MessageListModel::setFilterStrings(const std::map &filters) { filters_ = filters; filterAndSort(); } @@ -264,14 +265,14 @@ static bool parseRange(const QString &filter, uint32_t value, int base = 10) { } bool MessageListModel::match(const MessageListModel::Item &item) { - if (filters_.isEmpty()) + if (filters_.empty()) return true; bool match = true; const auto &data = can->lastMessage(item.id); for (auto it = filters_.cbegin(); it != filters_.cend() && match; ++it) { - const QString &txt = it.value(); - switch (it.key()) { + const QString &txt = it->second; + switch (it->first) { case Column::NAME: { match = item.name.contains(txt, Qt::CaseInsensitive); if (!match) { @@ -387,10 +388,13 @@ void MessageView::drawRow(QPainter *painter, const QStyleOptionViewItem &option, painter->setPen(oldPen); } -void MessageView::dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles) { +void MessageView::setModel(QAbstractItemModel *model) { + QTreeView::setModel(model); // Bypass the slow call to QTreeView::dataChanged. // QTreeView::dataChanged will invalidate the height cache and that's what we don't need in MessageView. - QAbstractItemView::dataChanged(topLeft, bottomRight, roles); + QObject::disconnect(model, &QAbstractItemModel::dataChanged, this, nullptr); + QObject::connect(model, &QAbstractItemModel::dataChanged, this, + [this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, roles); }); } void MessageView::updateBytesSectionSize() { @@ -421,9 +425,9 @@ MessageViewHeader::MessageViewHeader(QWidget *parent) : QHeaderView(Qt::Horizont } void MessageViewHeader::updateFilters() { - QMap filters; - for (int i = 0; i < count(); i++) { - if (editors[i] && !editors[i]->text().isEmpty()) { + std::map filters; + for (int i = 0; i < (int)editors.size(); i++) { + if (!editors[i]->text().isEmpty()) { filters[i] = editors[i]->text(); } } @@ -432,27 +436,24 @@ void MessageViewHeader::updateFilters() { void MessageViewHeader::updateHeaderPositions() { QSize sz = QHeaderView::sizeHint(); - for (int i = 0; i < count(); i++) { - if (editors[i]) { - int h = editors[i]->sizeHint().height(); - editors[i]->setGeometry(sectionViewportPosition(i), sz.height(), sectionSize(i), h); - editors[i]->setHidden(isSectionHidden(i)); - } + for (int i = 0; i < (int)editors.size(); i++) { + int h = editors[i]->sizeHint().height(); + editors[i]->setGeometry(sectionViewportPosition(i), sz.height(), sectionSize(i), h); + editors[i]->setHidden(isSectionHidden(i)); } } void MessageViewHeader::updateGeometries() { - for (int i = 0; i < count(); i++) { - if (!editors[i]) { - QString column_name = model()->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString(); - editors[i] = new QLineEdit(this); - editors[i]->setClearButtonEnabled(true); - editors[i]->setPlaceholderText(tr("Filter %1").arg(column_name)); + for (int i = (int)editors.size(); i < count(); i++) { + QString column_name = model()->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString(); + auto edit = new QLineEdit(this); + edit->setClearButtonEnabled(true); + edit->setPlaceholderText(tr("Filter %1").arg(column_name)); - QObject::connect(editors[i], &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters); - } + QObject::connect(edit, &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters); + editors.push_back(edit); } - setViewportMargins(0, 0, 0, editors[0] ? editors[0]->sizeHint().height() : 0); + setViewportMargins(0, 0, 0, !editors.empty() ? editors[0]->sizeHint().height() : 0); QHeaderView::updateGeometries(); updateHeaderPositions(); @@ -460,5 +461,5 @@ void MessageViewHeader::updateGeometries() { QSize MessageViewHeader::sizeHint() const { QSize sz = QHeaderView::sizeHint(); - return editors[0] ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz; + return !editors.empty() ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz; } diff --git a/openpilot/tools/cabana/messageswidget.h b/openpilot/tools/cabana/messageswidget.h index 9ffb156604..0a9cd256d8 100644 --- a/openpilot/tools/cabana/messageswidget.h +++ b/openpilot/tools/cabana/messageswidget.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include #include @@ -35,7 +37,7 @@ public: QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const; int rowCount(const QModelIndex &parent = QModelIndex()) const override { return items_.size(); } void sort(int column, Qt::SortOrder order = Qt::AscendingOrder) override; - void setFilterStrings(const QMap &filters); + void setFilterStrings(const std::map &filters); void showInactiveMessages(bool show); void msgsReceived(const std::set *new_msgs, bool has_new_ids); bool filterAndSort(); @@ -56,7 +58,7 @@ private: void sortItems(std::vector &items); bool match(const MessageListModel::Item &id); - QMap filters_; + std::map filters_; std::set dbc_messages_; int sort_column = 0; Qt::SortOrder sort_order = Qt::AscendingOrder; @@ -68,11 +70,11 @@ class MessageView : public QTreeView { public: MessageView(QWidget *parent) : QTreeView(parent) {} void updateBytesSectionSize(); + void setModel(QAbstractItemModel *model) override; protected: void drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; void drawBranches(QPainter *painter, const QRect &rect, const QModelIndex &index) const override {} - void dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles = QVector()) override; void wheelEvent(QWheelEvent *event) override; }; @@ -86,7 +88,7 @@ public: QSize sizeHint() const override; void updateFilters(); - QMap editors; + std::vector editors; }; class MessagesWidget : public QWidget { @@ -95,8 +97,13 @@ class MessagesWidget : public QWidget { public: MessagesWidget(QWidget *parent); void selectMessage(const MessageId &message_id); - QByteArray saveHeaderState() const { return view->header()->saveState(); } - bool restoreHeaderState(const QByteArray &state) const { return view->header()->restoreState(state); } + std::vector saveHeaderState() const { + const auto state = view->header()->saveState(); + return {state.begin(), state.end()}; + } + bool restoreHeaderState(const std::vector &state) const { + return view->header()->restoreState({(const char *)state.data(), (int)state.size()}); + } void suppressHighlighted(); signals: diff --git a/openpilot/tools/cabana/settings.cc b/openpilot/tools/cabana/settings.cc index e7b1129a30..0c8136b84c 100644 --- a/openpilot/tools/cabana/settings.cc +++ b/openpilot/tools/cabana/settings.cc @@ -1,15 +1,37 @@ #include "tools/cabana/settings.h" +#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 -#include -#include #include +#include "json11/json11.hpp" #include "tools/cabana/utils/util.h" const int MIN_CACHE_MINIUTES = 30; @@ -17,9 +39,442 @@ const int MAX_CACHE_MINIUTES = 120; Settings settings; -template -void settings_op(SettingOperation op) { - QSettings s("cabana"); +namespace { + +std::filesystem::path settingsFile() { + return utils::configPath() / "cabana.json"; +} + +struct LoadedSettings { + json11::Json::object values; + bool exists = false; + bool valid = true; +}; + +class FileLock { +public: + explicit FileLock(const std::filesystem::path &path) { + fd = open(path.c_str(), O_CREAT | O_CLOEXEC, 0600); + if (fd < 0 || flock(fd, LOCK_EX) < 0) { + fprintf(stderr, "failed to lock Cabana settings %s: %s\n", path.c_str(), strerror(errno)); + if (fd >= 0) close(fd); + fd = -1; + } + } + ~FileLock() { + if (fd >= 0) close(fd); + } + bool isLocked() const { return fd >= 0; } + +private: + int fd = -1; +}; + +LoadedSettings loadSettings() { + std::ifstream input(settingsFile()); + if (!input) return {}; + + const std::string contents{std::istreambuf_iterator(input), std::istreambuf_iterator()}; + std::string error; + auto settings_json = json11::Json::parse(contents, error); + if (!error.empty() || !settings_json.is_object()) { + fprintf(stderr, "failed to read Cabana settings %s%s%s\n", settingsFile().c_str(), error.empty() ? "" : ": ", error.c_str()); + return {.exists = true, .valid = false}; + } + return {.values = settings_json.object_items(), .exists = true}; +} + +bool ensureSettingsDirectory() { + const auto path = settingsFile(); + std::error_code error; + std::filesystem::create_directories(path.parent_path(), error); + if (error) { + fprintf(stderr, "failed to create Cabana settings directory %s: %s\n", path.parent_path().c_str(), error.message().c_str()); + return false; + } + return true; +} + +bool writeAll(int fd, const std::string &data) { + size_t written = 0; + while (written < data.size()) { + ssize_t result = write(fd, data.data() + written, data.size() - written); + if (result < 0 && errno == EINTR) continue; + if (result <= 0) return false; + written += result; + } + return true; +} + +bool saveSettings(const json11::Json::object &settings_json) { + const auto path = settingsFile(); + const std::string contents = json11::Json(settings_json).dump(); + std::string temporary_path = path.string() + ".tmp.XXXXXX"; + int fd = mkstemp(temporary_path.data()); + if (fd < 0) { + fprintf(stderr, "failed to create temporary Cabana settings %s: %s\n", temporary_path.c_str(), strerror(errno)); + return false; + } + + bool success = writeAll(fd, contents) && fsync(fd) == 0; + if (close(fd) < 0) success = false; + if (success && rename(temporary_path.c_str(), path.c_str()) < 0) success = false; + + if (success) { + int dir_fd = open(path.parent_path().c_str(), O_RDONLY | O_CLOEXEC); + success = dir_fd >= 0 && fsync(dir_fd) == 0; + if (dir_fd >= 0 && close(dir_fd) < 0) success = false; + } + + if (!success) { + const int saved_errno = errno; + unlink(temporary_path.c_str()); + fprintf(stderr, "failed to save Cabana settings to %s: %s\n", path.c_str(), strerror(saved_errno)); + } + return success; +} + +bool preserveCorruptSettings() { + const auto path = settingsFile(); + auto backup = path; + backup += ".corrupt"; + for (int i = 1; std::filesystem::exists(backup); ++i) { + backup = path; + backup += ".corrupt." + std::to_string(i); + } + if (rename(path.c_str(), backup.c_str()) < 0) { + fprintf(stderr, "failed to preserve corrupt Cabana settings %s: %s\n", path.c_str(), strerror(errno)); + return false; + } + fprintf(stderr, "preserved corrupt Cabana settings at %s\n", backup.c_str()); + return true; +} + +// TODO: Remove the legacy QSettings migration after users have had time to migrate to cabana.json. +struct LegacyValue { + std::vector strings; + std::string bytes; + bool is_byte_array = false; +}; + +using LegacySettings = std::map; + +int hexDigit(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +#ifndef __APPLE__ + +void appendUtf8(std::string &result, uint32_t codepoint) { + if (codepoint <= 0x7f) { + result.push_back(codepoint); + } else if (codepoint <= 0x7ff) { + result.push_back(0xc0 | (codepoint >> 6)); + result.push_back(0x80 | (codepoint & 0x3f)); + } else if (codepoint <= 0xffff) { + result.push_back(0xe0 | (codepoint >> 12)); + result.push_back(0x80 | ((codepoint >> 6) & 0x3f)); + result.push_back(0x80 | (codepoint & 0x3f)); + } else { + result.push_back(0xf0 | (codepoint >> 18)); + result.push_back(0x80 | ((codepoint >> 12) & 0x3f)); + result.push_back(0x80 | ((codepoint >> 6) & 0x3f)); + result.push_back(0x80 | (codepoint & 0x3f)); + } +} + +LegacyValue decodeIniValue(std::string_view encoded) { + std::vector> decoded(1); + std::vector quoted(1, false); + bool in_quotes = false; + + for (size_t i = 0; i < encoded.size();) { + char c = encoded[i++]; + if (c == '"') { + in_quotes = !in_quotes; + quoted.back() = true; + } else if (c == ',' && !in_quotes) { + decoded.emplace_back(); + quoted.push_back(false); + while (i < encoded.size() && (encoded[i] == ' ' || encoded[i] == '\t')) ++i; + } else if (c == '\\' && i < encoded.size()) { + c = encoded[i++]; + static const std::map escapes = { + {'a', '\a'}, {'b', '\b'}, {'f', '\f'}, {'n', '\n'}, {'r', '\r'}, {'t', '\t'}, + {'v', '\v'}, {'"', '"'}, {'?', '?'}, {'\'', '\''}, {'\\', '\\'}, + }; + if (auto it = escapes.find(c); it != escapes.end()) { + decoded.back().push_back(static_cast(it->second)); + } else if (c == 'x' && i < encoded.size() && hexDigit(encoded[i]) >= 0) { + uint32_t value = 0; + while (i < encoded.size() && hexDigit(encoded[i]) >= 0) value = (value << 4) + hexDigit(encoded[i++]); + decoded.back().push_back(value & 0xffff); + } else if (c >= '0' && c <= '7') { + uint32_t value = c - '0'; + while (i < encoded.size() && encoded[i] >= '0' && encoded[i] <= '7') value = (value << 3) + (encoded[i++] - '0'); + decoded.back().push_back(value & 0xffff); + } + } else { + decoded.back().push_back(static_cast(c)); + } + } + + LegacyValue result; + for (size_t i = 0; i < decoded.size(); ++i) { + auto &value = decoded[i]; + if (!quoted[i]) { + while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) value.erase(value.begin()); + while (!value.empty() && (value.back() == ' ' || value.back() == '\t')) value.pop_back(); + } + + std::string string_value; + for (size_t j = 0; j < value.size(); ++j) { + uint32_t codepoint = value[j]; + if (codepoint >= 0xd800 && codepoint <= 0xdbff && j + 1 < value.size() && value[j + 1] >= 0xdc00 && value[j + 1] <= 0xdfff) { + codepoint = 0x10000 + ((codepoint - 0xd800) << 10) + (value[++j] - 0xdc00); + } + appendUtf8(string_value, codepoint); + } + result.strings.push_back(std::move(string_value)); + } + + if (result.strings.size() == 1 && result.strings[0] == "@Invalid()") { + result.strings.clear(); + } else if (decoded.size() == 1) { + static constexpr std::string_view prefix = "@ByteArray("; + const auto &value = decoded[0]; + if (value.size() >= prefix.size() + 1 && std::equal(prefix.begin(), prefix.end(), value.begin()) && value.back() == ')') { + result.is_byte_array = true; + result.bytes.reserve(value.size() - prefix.size() - 1); + for (size_t i = prefix.size(); i + 1 < value.size(); ++i) result.bytes.push_back(value[i] & 0xff); + } + } + if (!result.is_byte_array) { + for (auto &value : result.strings) { + if (value.compare(0, 2, "@@") == 0) value.erase(0, 1); + } + } + return result; +} + +LegacySettings loadLegacySettings() { + auto path = settingsFile(); + path.replace_filename("cabana.conf"); + std::ifstream input(path); + if (!input) return {}; + + LegacySettings settings; + bool in_general_section = false; + std::string line; + while (std::getline(input, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line == "[General]") { + in_general_section = true; + continue; + } + if (!line.empty() && line.front() == '[') { + in_general_section = false; + continue; + } + if (!in_general_section || line.empty() || line.front() == ';') continue; + if (auto separator = line.find('='); separator != std::string::npos) { + settings[line.substr(0, separator)] = decodeIniValue(std::string_view(line).substr(separator + 1)); + } + } + return settings; +} + +#else + +std::string cfStringToUtf8(CFStringRef value) { + CFIndex length = CFStringGetLength(value); + CFIndex size = CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1; + std::string result(size, '\0'); + if (!CFStringGetCString(value, result.data(), size, kCFStringEncodingUTF8)) return {}; + result.resize(strlen(result.c_str())); + return result; +} + +LegacyValue cfStringValue(CFStringRef string) { + LegacyValue value; + if (CFStringHasPrefix(string, CFSTR("@ByteArray(")) && CFStringHasSuffix(string, CFSTR(")"))) { + CFRange range{11, CFStringGetLength(string) - 12}; + std::vector data(range.length); + CFStringGetCharacters(string, range, data.data()); + value.is_byte_array = true; + value.bytes.reserve(data.size()); + for (UniChar c : data) value.bytes.push_back(c & 0xff); + } else { + std::string string_value = cfStringToUtf8(string); + if (string_value.compare(0, 2, "@@") == 0) string_value.erase(0, 1); + value.strings.push_back(std::move(string_value)); + } + return value; +} + +LegacySettings loadLegacySettings() { + LegacySettings settings; + CFDictionaryRef values = CFPreferencesCopyMultiple(nullptr, CFSTR("com.cabana"), + kCFPreferencesCurrentUser, kCFPreferencesAnyHost); + if (values == nullptr) return settings; + + CFIndex count = CFDictionaryGetCount(values); + std::vector keys(count); + std::vector objects(count); + CFDictionaryGetKeysAndValues(values, keys.data(), objects.data()); + for (CFIndex i = 0; i < count; ++i) { + if (CFGetTypeID(keys[i]) != CFStringGetTypeID()) continue; + std::string key = cfStringToUtf8(static_cast(keys[i])); + CFTypeRef object = objects[i]; + LegacyValue value; + if (CFGetTypeID(object) == CFBooleanGetTypeID()) { + value.strings.push_back(CFBooleanGetValue(static_cast(object)) ? "true" : "false"); + } else if (CFGetTypeID(object) == CFNumberGetTypeID()) { + int number = 0; + if (CFNumberGetValue(static_cast(object), kCFNumberIntType, &number)) value.strings.push_back(std::to_string(number)); + } else if (CFGetTypeID(object) == CFStringGetTypeID()) { + value = cfStringValue(static_cast(object)); + } else if (CFGetTypeID(object) == CFDataGetTypeID()) { + auto data = static_cast(object); + value.is_byte_array = true; + value.bytes.assign(reinterpret_cast(CFDataGetBytePtr(data)), CFDataGetLength(data)); + } else if (CFGetTypeID(object) == CFArrayGetTypeID()) { + auto array = static_cast(object); + for (CFIndex j = 0; j < CFArrayGetCount(array); ++j) { + CFTypeRef item = CFArrayGetValueAtIndex(array, j); + if (CFGetTypeID(item) != CFStringGetTypeID()) { + value.strings.clear(); + break; + } + auto item_value = cfStringValue(static_cast(item)); + if (item_value.strings.size() != 1) { + value.strings.clear(); + break; + } + value.strings.push_back(std::move(item_value.strings[0])); + } + } + if (!value.strings.empty() || value.is_byte_array || CFGetTypeID(object) == CFArrayGetTypeID()) { + settings.emplace(std::move(key), std::move(value)); + } + } + CFRelease(values); + return settings; +} + +#endif + +template +void readLegacySetting(const LegacySettings &legacy_settings, const char *key, T &value) { + auto it = legacy_settings.find(key); + if (it == legacy_settings.end() || it->second.strings.size() != 1) return; + const auto &stored = it->second.strings[0]; + + if constexpr (std::is_same_v) { + if (stored == "true") value = true; + if (stored == "false") value = false; + } else if constexpr (std::is_integral_v || std::is_enum_v) { + int number = 0; + auto [end, error] = std::from_chars(stored.data(), stored.data() + stored.size(), number); + if (error == std::errc{} && end == stored.data() + stored.size()) value = static_cast(number); + } +} + +void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::string &value) { + auto it = legacy_settings.find(key); + if (it != legacy_settings.end() && it->second.strings.size() == 1) value = it->second.strings[0]; +} + +void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::vector &value) { + auto it = legacy_settings.find(key); + if (it != legacy_settings.end() && !it->second.is_byte_array) value = it->second.strings; +} + +void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::vector &value) { + auto it = legacy_settings.find(key); + if (it != legacy_settings.end() && it->second.is_byte_array) { + value.assign(it->second.bytes.begin(), it->second.bytes.end()); + } +} + +template +void readSetting(const json11::Json::object &settings_json, const char *key, T &value) { + auto it = settings_json.find(key); + if (it == settings_json.end()) return; + + if constexpr (std::is_same_v) { + if (it->second.is_bool()) value = it->second.bool_value(); + } else if constexpr (std::is_integral_v) { + if (it->second.is_number()) value = it->second.int_value(); + } else if constexpr (std::is_enum_v) { + if (it->second.is_number()) value = static_cast(it->second.int_value()); + } +} + +void readSetting(const json11::Json::object &settings_json, const char *key, std::string &value) { + auto it = settings_json.find(key); + if (it != settings_json.end() && it->second.is_string()) value = it->second.string_value(); +} + +void readSetting(const json11::Json::object &settings_json, const char *key, std::vector &value) { + auto it = settings_json.find(key); + if (it == settings_json.end() || !it->second.is_array()) return; + + std::vector stored; + for (const auto &item : it->second.array_items()) { + if (!item.is_string()) return; + stored.push_back(item.string_value()); + } + value = std::move(stored); +} + +void readSetting(const json11::Json::object &settings_json, const char *key, std::vector &value) { + auto it = settings_json.find(key); + if (it == settings_json.end() || !it->second.is_string()) return; + + const auto &hex = it->second.string_value(); + if (hex.size() % 2 == 0 && std::all_of(hex.begin(), hex.end(), [](unsigned char c) { return std::isxdigit(c); })) { + value.clear(); + value.reserve(hex.size() / 2); + for (size_t i = 0; i < hex.size(); i += 2) { + value.push_back((hexDigit(hex[i]) << 4) | hexDigit(hex[i + 1])); + } + } +} + +template +void writeSetting(json11::Json::object &settings_json, const char *key, const T &value) { + if constexpr (std::is_same_v) { + settings_json[key] = value; + } else if constexpr (std::is_integral_v || std::is_enum_v) { + settings_json[key] = static_cast(value); + } +} + +void writeSetting(json11::Json::object &settings_json, const char *key, const std::string &value) { + settings_json[key] = value; +} + +void writeSetting(json11::Json::object &settings_json, const char *key, const std::vector &value) { + settings_json[key] = value; +} + +void writeSetting(json11::Json::object &settings_json, const char *key, const std::vector &value) { + static const char digits[] = "0123456789abcdef"; + std::string hex; + hex.reserve(value.size() * 2); + for (uint8_t b : value) { + hex.push_back(digits[b >> 4]); + hex.push_back(digits[b & 0xf]); + } + settings_json[key] = hex; +} + +template +void settingsOp(Store &s, SettingOperation op) { op(s, "absolute_time", settings.absolute_time); op(s, "fps", settings.fps); op(s, "max_cached_minutes", settings.max_cached_minutes); @@ -47,17 +502,38 @@ void settings_op(SettingOperation op) { op(s, "active_charts", settings.active_charts); } +} // namespace + Settings::Settings() { - last_dir = last_route_dir = QDir::homePath(); - log_path = QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/cabana_live_stream/"; - settings_op([](QSettings &s, const QString &key, auto &value) { - if (auto v = s.value(key); v.canConvert>()) - value = v.value>(); - }); + last_dir = last_route_dir = utils::homePath(); + log_path = utils::homePath() + "/cabana_live_stream/"; + const auto stored_settings = loadSettings(); + if (stored_settings.valid) { + if (stored_settings.exists) { + settingsOp(stored_settings.values, [](const auto &s, const char *key, auto &value) { readSetting(s, key, value); }); + } else { + auto legacy_settings = loadLegacySettings(); + settingsOp(legacy_settings, [](const auto &s, const char *key, auto &value) { readLegacySetting(s, key, value); }); + } + } + fps = std::clamp(fps, 1, 100); } -Settings::~Settings() { - settings_op([](QSettings &s, const QString &key, auto &v) { s.setValue(key, v); }); +// Must be called before main() returns: json11's internal statistics are constructed on first +// use at runtime, so they are destroyed before this pre-main global. Saving from ~Settings +// would use them after destruction and corrupt the heap. +void Settings::save() { + if (!ensureSettingsDirectory()) return; + + auto lock_path = settingsFile(); + lock_path += ".lock"; + FileLock lock(lock_path); + if (!lock.isLocked()) return; + + auto stored_settings = loadSettings(); + if (!stored_settings.valid && !preserveCorruptSettings()) return; + settingsOp(stored_settings.values, [](auto &s, const char *key, const auto &value) { writeSetting(s, key, value); }); + saveSettings(stored_settings.values); } // SettingsDlg @@ -101,8 +577,9 @@ SettingsDlg::SettingsDlg(QWidget *parent) : QDialog(parent) { 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(settings.log_path, this)); + 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); @@ -115,7 +592,7 @@ SettingsDlg::SettingsDlg(QWidget *parent) : QDialog(parent) { QObject::connect(browse_btn, &QPushButton::clicked, [this]() { QString fn = QFileDialog::getExistingDirectory( this, tr("Log File Location"), - QStandardPaths::writableLocation(QStandardPaths::HomeLocation), + QString::fromStdString(utils::homePath()), QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); if (!fn.isEmpty()) { log_path->setText(fn); @@ -134,7 +611,7 @@ void SettingsDlg::save() { 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(); + 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 7ab50d1494..7357ecf4fc 100644 --- a/openpilot/tools/cabana/settings.h +++ b/openpilot/tools/cabana/settings.h @@ -1,56 +1,28 @@ #pragma once -#include +#include +#include + #include #include #include #include #include -#define LIGHT_THEME 1 -#define DARK_THEME 2 +#include "tools/cabana/core/settings.h" -class Settings : public QObject { +class Settings : public QObject, public CabanaSettingsState { Q_OBJECT public: - enum DragDirection { - MsbFirst, - LsbFirst, - AlwaysLE, - AlwaysBE, - }; - Settings(); - ~Settings(); + void save(); - bool absolute_time = false; - int fps = 10; - int max_cached_minutes = 30; - int chart_height = 200; - int chart_column_count = 1; - int chart_range = 3 * 60; // 3 minutes - int chart_series_type = 0; - int theme = 0; - int sparkline_range = 15; // 15 seconds - bool multiple_lines_hex = false; - bool log_livestream = true; - bool suppress_defined_signals = false; - QString log_path; - QString last_dir; - QString last_route_dir; - QByteArray geometry; - QByteArray video_splitter_state; - QByteArray window_state; - QStringList recent_files; - QByteArray message_header_state; - DragDirection drag_direction = MsbFirst; - - // session data - QString recent_dbc_file; - QString active_msg_id; - QStringList selected_msg_ids; - QStringList active_charts; + // Qt frontend layout state. This intentionally stays outside CabanaSettingsState. + std::vector geometry; + std::vector video_splitter_state; + std::vector window_state; + std::vector message_header_state; signals: void changed(); diff --git a/openpilot/tools/cabana/signalview.cc b/openpilot/tools/cabana/signalview.cc index a204512e83..ebb5374140 100644 --- a/openpilot/tools/cabana/signalview.cc +++ b/openpilot/tools/cabana/signalview.cc @@ -1,9 +1,9 @@ #include "tools/cabana/signalview.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include -#include #include #include #include @@ -15,6 +15,7 @@ #include #include "tools/cabana/commands.h" +#include "tools/cabana/utils/util.h" // SignalModel @@ -25,12 +26,12 @@ static QString signalTypeToString(cabana::Signal::Type type) { } SignalModel::SignalModel(QObject *parent) : root(new Item), QAbstractItemModel(parent) { - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &SignalModel::refresh); - QObject::connect(dbc(), &DBCManager::msgUpdated, this, &SignalModel::handleMsgChanged); - QObject::connect(dbc(), &DBCManager::msgRemoved, this, &SignalModel::handleMsgChanged); - QObject::connect(dbc(), &DBCManager::signalAdded, this, &SignalModel::handleSignalAdded); - QObject::connect(dbc(), &DBCManager::signalUpdated, this, &SignalModel::handleSignalUpdated); - QObject::connect(dbc(), &DBCManager::signalRemoved, this, &SignalModel::handleSignalRemoved); + 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); } void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { @@ -197,7 +198,7 @@ bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) if (s.is_little_endian != origin_s->is_little_endian) { s.start_bit = flipBitPos(s.start_bit); } - UndoStack::push(new EditSignalCommand(msg_id, origin_s, s)); + UndoStack::instance()->push(new EditSignalCommand(msg_id, origin_s, s)); return true; } @@ -251,7 +252,7 @@ void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { SignalItemDelegate::SignalItemDelegate(QObject *parent) : QStyledItemDelegate(parent) { name_validator = new NameValidator(this); - node_validator = new QRegExpValidator(QRegExp("^\\w+(,\\w+)*$"), this); + node_validator = new NodeValidator(this); double_validator = new DoubleValidator(this); label_font.setPointSize(8); @@ -304,7 +305,7 @@ void SignalItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op path.addRoundedRect(icon_rect, 3, 3); painter->setPen(item->highlight ? Qt::white : Qt::black); painter->setFont(label_font); - painter->fillPath(path, item->sig->color.darker(item->highlight ? 125 : 0)); + painter->fillPath(path, toQColor(item->sig->color.darker(item->highlight ? 125 : 0))); painter->drawText(icon_rect, Qt::AlignCenter, QString::number(item->row() + 1)); rect.setLeft(icon_rect.right() + h_margin * 2); @@ -375,15 +376,6 @@ QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionVie else if (item->type == SignalModel::Item::Node) e->setValidator(node_validator); else e->setValidator(double_validator); - if (item->type == SignalModel::Item::Name) { - auto names = dbc()->signalNames(); - QStringList qnames; - for (const auto &n : names) qnames.push_back(QString::fromStdString(n)); - QCompleter *completer = new QCompleter(qnames, e); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setFilterMode(Qt::MatchContains); - e->setCompleter(completer); - } return e; } else if (item->type == SignalModel::Item::Size) { QSpinBox *spin = new QSpinBox(parent); @@ -428,8 +420,7 @@ SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QHBoxLayout *hl = new QHBoxLayout(title_bar); hl->addWidget(signal_count_lb = new QLabel()); filter_edit = new QLineEdit(this); - QRegularExpression re("\\S+"); - filter_edit->setValidator(new QRegularExpressionValidator(re, this)); + filter_edit->setValidator(new NonWhitespaceValidator(this)); filter_edit->setClearButtonEnabled(true); filter_edit->setPlaceholderText(tr("Filter Signal")); hl->addWidget(filter_edit); @@ -481,8 +472,8 @@ 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(dbc(), &DBCManager::signalAdded, this, &SignalView::handleSignalAdded); - QObject::connect(dbc(), &DBCManager::signalUpdated, this, &SignalView::handleSignalUpdated); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalView::handleSignalAdded); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &SignalView::handleSignalUpdated); QObject::connect(tree->verticalScrollBar(), &QScrollBar::valueChanged, [this]() { updateState(); }); QObject::connect(tree->verticalScrollBar(), &QScrollBar::rangeChanged, [this]() { updateState(); }); QObject::connect(can, &AbstractStream::msgsReceived, this, &SignalView::updateState); @@ -524,7 +515,7 @@ void SignalView::rowsChanged() { tree->setIndexWidget(index, w); auto sig = model->getItem(index)->sig; - QObject::connect(remove_btn, &QToolButton::clicked, [=]() { UndoStack::push(new RemoveSigCommand(model->msg_id, sig)); }); + QObject::connect(remove_btn, &QToolButton::clicked, [=]() { UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, sig)); }); QObject::connect(plot_btn, &QToolButton::clicked, [=](bool checked) { emit showChart(model->msg_id, sig, checked, QGuiApplication::keyboardModifiers() & Qt::ShiftModifier); }); diff --git a/openpilot/tools/cabana/signalview.h b/openpilot/tools/cabana/signalview.h index 42db830df7..bccfbab0cc 100644 --- a/openpilot/tools/cabana/signalview.h +++ b/openpilot/tools/cabana/signalview.h @@ -129,9 +129,12 @@ private: // update widget geometries in QTreeView::rowsInserted QTreeView::rowsInserted(parent, start, end); } - void dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles = QVector()) override { + void setModel(QAbstractItemModel *m) override { + QTreeView::setModel(m); // Bypass the slow call to QTreeView::dataChanged. - QAbstractItemView::dataChanged(topLeft, bottomRight, roles); + QObject::disconnect(m, &QAbstractItemModel::dataChanged, this, nullptr); + QObject::connect(m, &QAbstractItemModel::dataChanged, this, + [this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, roles); }); } void leaveEvent(QEvent *event) override { emit static_cast(parentWidget())->highlight(nullptr); diff --git a/openpilot/tools/cabana/streams/abstractstream.cc b/openpilot/tools/cabana/streams/abstractstream.cc index 1582fcd34d..c58a98084f 100644 --- a/openpilot/tools/cabana/streams/abstractstream.cc +++ b/openpilot/tools/cabana/streams/abstractstream.cc @@ -1,4 +1,5 @@ #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -18,8 +19,8 @@ AbstractStream::AbstractStream(QObject *parent) : QObject(parent) { 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(dbc(), &DBCManager::DBCFileChanged, this, &AbstractStream::updateMasks); - QObject::connect(dbc(), &DBCManager::maskUpdated, this, &AbstractStream::updateMasks); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &AbstractStream::updateMasks); + QObject::connect(dbcNotifier(), &QtDBCNotifier::maskUpdated, this, &AbstractStream::updateMasks); } void AbstractStream::updateMasks() { @@ -233,18 +234,18 @@ std::pair AbstractStream::eventsInRange(const Messag namespace { enum Color { GREYISH_BLUE, CYAN, RED}; -QColor getColor(int c) { +CabanaColor getColor(int c) { constexpr int start_alpha = 128; - static const QColor colors[] = { - [GREYISH_BLUE] = QColor(102, 86, 169, start_alpha / 2), - [CYAN] = QColor(0, 187, 255, start_alpha), - [RED] = QColor(255, 0, 0, start_alpha), + static const CabanaColor colors[] = { + [GREYISH_BLUE] = CabanaColor(102, 86, 169, start_alpha / 2), + [CYAN] = CabanaColor(0, 187, 255, start_alpha), + [RED] = CabanaColor(255, 0, 0, start_alpha), }; return settings.theme == LIGHT_THEME ? colors[c] : colors[c].lighter(135); } -inline QColor blend(const QColor &a, const QColor &b) { - return QColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2, (a.alpha() + b.alpha()) / 2); +inline CabanaColor blend(const CabanaColor &a, const CabanaColor &b) { + return CabanaColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2, (a.alpha() + b.alpha()) / 2); } // Calculate the frequency from the past one minute data @@ -271,7 +272,7 @@ void CanData::compute(const MessageId &msg_id, const uint8_t *can_data, const in if (dat.size() != size) { dat.assign(can_data, can_data + size); - colors.assign(size, QColor(0, 0, 0, 0)); + colors.assign(size, CabanaColor(0, 0, 0, 0)); last_changes.resize(size); bit_flip_counts.resize(size); std::for_each(last_changes.begin(), last_changes.end(), [current_sec](auto &c) { c.ts = current_sec; }); @@ -317,7 +318,7 @@ void CanData::compute(const MessageId &msg_id, const uint8_t *can_data, const in last_change.delta = delta; } else { // Fade out - colors[i].setAlphaF(std::max(0.0, colors[i].alphaF() - alpha_delta)); + colors[i].setAlphaF(std::max(0.0f, colors[i].alphaF() - alpha_delta)); } } } diff --git a/openpilot/tools/cabana/streams/abstractstream.h b/openpilot/tools/cabana/streams/abstractstream.h index 2f3b26fe2a..82cb899937 100644 --- a/openpilot/tools/cabana/streams/abstractstream.h +++ b/openpilot/tools/cabana/streams/abstractstream.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -11,51 +12,12 @@ #include #include -#include -#include - #include "openpilot/cereal/messaging/messaging.h" +#include "tools/cabana/core/can_data.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/utils/util.h" #include "tools/replay/util.h" -struct CanData { - void compute(const MessageId &msg_id, const uint8_t *dat, const int size, double current_sec, - double playback_speed, const std::vector &mask, double in_freq = 0); - - double ts = 0.; - uint32_t count = 0; - double freq = 0; - std::vector dat; - std::vector colors; - - struct ByteLastChange { - double ts = 0; - int delta = 0; - int same_delta_counter = 0; - bool suppressed = false; - }; - std::vector last_changes; - std::vector> bit_flip_counts; - double last_freq_update_ts = 0; -}; - -struct CanEvent { - uint8_t src; - uint32_t address; - uint64_t mono_time; - uint8_t size; - uint8_t dat[]; -}; - -struct CompareCanEvent { - constexpr bool operator()(const CanEvent *const e, uint64_t ts) const { return e->mono_time < ts; } - constexpr bool operator()(uint64_t ts, const CanEvent *const e) const { return ts < e->mono_time; } -}; - -typedef std::unordered_map> MessageEventsMap; -using CanEventIter = std::vector::const_iterator; - class AbstractStream : public QObject { Q_OBJECT @@ -67,7 +29,7 @@ public: virtual void seekTo(double ts) {} virtual std::string routeName() const = 0; virtual std::string carFingerprint() const { return ""; } - virtual QDateTime beginDateTime() const { return {}; } + virtual std::chrono::system_clock::time_point beginDateTime() const { return {}; } virtual uint64_t beginMonoTime() const { return 0; } virtual double minSeconds() const { return 0; } virtual double maxSeconds() const { return 0; } @@ -113,12 +75,12 @@ protected: 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); void waitForSeekFinshed(); + virtual void updateLastMessages(); std::vector all_events_; double current_sec_ = 0; std::optional> time_range_; private: - void updateLastMessages(); void updateLastMsgsTo(double sec); void updateMasks(); diff --git a/openpilot/tools/cabana/streams/devicestream.cc b/openpilot/tools/cabana/streams/devicestream.cc index 96fae908ba..3bd51c079d 100644 --- a/openpilot/tools/cabana/streams/devicestream.cc +++ b/openpilot/tools/cabana/streams/devicestream.cc @@ -1,18 +1,25 @@ #include "tools/cabana/streams/devicestream.h" +#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include #include "openpilot/cereal/services.h" #include -#include #include #include #include -#include -#include -#include + +#include "tools/cabana/utils/util.h" // DeviceStream @@ -20,26 +27,76 @@ DeviceStream::DeviceStream(QObject *parent, QString address) : zmq_address(addre } DeviceStream::~DeviceStream() { - if (!bridge_process) - return; + stop(); + stopBridge(); +} - bridge_process->terminate(); - if (!bridge_process->waitForFinished(3000)) { - bridge_process->kill(); - bridge_process->waitForFinished(); +void DeviceStream::stopBridge() { + if (bridge_pid <= 0) return; + + ::kill(bridge_pid, SIGTERM); + for (int i = 0; i < 30; ++i) { + int status = 0; + pid_t r = ::waitpid(bridge_pid, &status, WNOHANG); + if (r == bridge_pid || (r < 0 && errno == ECHILD)) { + bridge_pid = -1; + return; + } + usleep(100000); // 100ms, up to ~3s } + ::kill(bridge_pid, SIGKILL); + ::waitpid(bridge_pid, nullptr, 0); + bridge_pid = -1; } void DeviceStream::start() { if (!zmq_address.isEmpty()) { - bridge_process = new QProcess(this); - QString bridge_path = QCoreApplication::applicationDirPath() + "/../../openpilot/cereal/messaging/bridge"; - bridge_process->start(QFileInfo(bridge_path).absoluteFilePath(), QStringList { zmq_address, "/\"can/\"" }); + 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 char *can_filter = "/\"can/\""; - if (!bridge_process->waitForStarted()) { - QMessageBox::warning(nullptr, tr("Error"), tr("Failed to start bridge: %1").arg(bridge_process->errorString())); + // 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)))); return; } + + pid_t pid = ::fork(); + 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)); + const int err = errno; + (void)!::write(err_pipe[1], &err, sizeof(err)); + _exit(127); + } + + ::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)))); + return; + } + + int exec_errno = 0; + const ssize_t n = ::read(err_pipe[0], &exec_errno, sizeof(exec_errno)); + ::close(err_pipe[0]); + if (n == static_cast(sizeof(exec_errno))) { + // 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)))); + return; + } + + bridge_pid = pid; } LiveStream::start(); @@ -52,10 +109,10 @@ void DeviceStream::streamThread() { std::unique_ptr sock(SubSocket::create(context.get(), "can", "127.0.0.1", false, true, services.at("can").queue_size)); assert(sock != NULL); // run as fast as messages come in - while (!QThread::currentThread()->isInterruptionRequested()) { + while (!exit_) { std::unique_ptr msg(sock->receive(true)); if (!msg) { - QThread::msleep(50); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); continue; } handleEvent(kj::ArrayPtr((capnp::word*)msg->getData(), msg->getSize() / sizeof(capnp::word))); @@ -69,10 +126,7 @@ OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(p QRadioButton *zmq = new QRadioButton(tr("ZMQ")); ip_address = new QLineEdit(this); ip_address->setPlaceholderText(tr("Enter device Ip Address")); - QString ip_range = "(?:[0-1]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])"; - QString pattern("^" + ip_range + "\\." + ip_range + "\\." + ip_range + "\\." + ip_range + "$"); - QRegularExpression re(pattern); - ip_address->setValidator(new QRegularExpressionValidator(re, this)); + ip_address->setValidator(new IpAddressValidator(this)); group = new QButtonGroup(this); group->addButton(msgq, 0); diff --git a/openpilot/tools/cabana/streams/devicestream.h b/openpilot/tools/cabana/streams/devicestream.h index 4bcdb5351d..0e6951c92c 100644 --- a/openpilot/tools/cabana/streams/devicestream.h +++ b/openpilot/tools/cabana/streams/devicestream.h @@ -2,7 +2,7 @@ #include "tools/cabana/streams/livestream.h" -#include +#include class DeviceStream : public LiveStream { Q_OBJECT @@ -16,7 +16,8 @@ public: protected: void start() override; void streamThread() override; - QProcess *bridge_process = nullptr; + void stopBridge(); + pid_t bridge_pid = -1; const QString zmq_address; }; diff --git a/openpilot/tools/cabana/streams/livestream.cc b/openpilot/tools/cabana/streams/livestream.cc index ac9a6fa105..019a67e8f2 100644 --- a/openpilot/tools/cabana/streams/livestream.cc +++ b/openpilot/tools/cabana/streams/livestream.cc @@ -1,9 +1,11 @@ #include "tools/cabana/streams/livestream.h" -#include #include +#include #include +#include #include +#include #include "common/timing.h" #include "common/util.h" @@ -14,9 +16,14 @@ struct LiveStream::Logger { void write(kj::ArrayPtr data) { int n = (seconds_since_epoch() - start_ts) / 60.0; if (std::exchange(segment_num, n) != segment_num) { + const time_t start_time = start_ts; + std::tm local_time = {}; + 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(settings.log_path) - .arg(QDateTime::fromSecsSinceEpoch(start_ts).toString("yyyy-MM-dd--hh-mm-ss")) + .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)); @@ -35,37 +42,34 @@ LiveStream::LiveStream(QObject *parent) : AbstractStream(parent) { if (settings.log_livestream) { logger = std::make_unique(); } - stream_thread = new QThread(this); - - QObject::connect(&settings, &Settings::changed, this, &LiveStream::startUpdateTimer); - QObject::connect(stream_thread, &QThread::started, [=]() { streamThread(); }); - QObject::connect(stream_thread, &QThread::finished, stream_thread, &QThread::deleteLater); } LiveStream::~LiveStream() { stop(); } -void LiveStream::startUpdateTimer() { - update_timer.stop(); - update_timer.start(1000.0 / settings.fps, this); - timer_id = update_timer.timerId(); -} - void LiveStream::start() { - stream_thread->start(); - startUpdateTimer(); - begin_date_time = QDateTime::currentDateTime(); + begin_date_time = std::chrono::system_clock::now(); + fps_ = settings.fps; + exit_ = false; + stream_thread = std::thread(&LiveStream::streamThread, this); + update_thread = std::thread(&LiveStream::updateThread, this); } void LiveStream::stop() { - if (!stream_thread) return; + exit_ = true; + if (stream_thread.joinable()) stream_thread.join(); + if (update_thread.joinable()) update_thread.join(); +} - update_timer.stop(); - stream_thread->requestInterruption(); - stream_thread->quit(); - stream_thread->wait(); - stream_thread = nullptr; +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. + if (!update_pending_.exchange(true)) { + emit privateUpdateLastMsgsSignal(); + } + } } // called in streamThread @@ -85,23 +89,22 @@ void LiveStream::handleEvent(kj::ArrayPtr data) { } } -void LiveStream::timerEvent(QTimerEvent *event) { - if (event->timerId() == timer_id) { - { - // merge events received from live stream thread. - std::lock_guard lk(lock); - mergeEvents(received_events_); - uint64_t last_received_ts = !received_events_.empty() ? received_events_.back()->mono_time : 0; - lastest_event_ts = std::max(lastest_event_ts, last_received_ts); - received_events_.clear(); - } - if (!all_events_.empty()) { - begin_event_ts = all_events_.front()->mono_time; - updateEvents(); - return; - } +// called on the main thread by the queued privateUpdateLastMsgsSignal connection +void LiveStream::updateLastMessages() { + update_pending_ = false; + fps_ = settings.fps; + { + // merge events received from live stream thread. + std::lock_guard lk(lock); + mergeEvents(received_events_); + uint64_t last_received_ts = !received_events_.empty() ? received_events_.back()->mono_time : 0; + lastest_event_ts = std::max(lastest_event_ts, last_received_ts); + received_events_.clear(); + } + if (!all_events_.empty()) { + begin_event_ts = all_events_.front()->mono_time; + updateEvents(); } - QObject::timerEvent(event); } void LiveStream::updateEvents() { @@ -131,7 +134,7 @@ void LiveStream::updateEvents() { updateEvent(id, (e->mono_time - begin_event_ts) / 1e9, e->dat, e->size); current_event_ts = e->mono_time; } - emit privateUpdateLastMsgsSignal(); + AbstractStream::updateLastMessages(); } void LiveStream::seekTo(double sec) { diff --git a/openpilot/tools/cabana/streams/livestream.h b/openpilot/tools/cabana/streams/livestream.h index 24b9285092..5d65b1743f 100644 --- a/openpilot/tools/cabana/streams/livestream.h +++ b/openpilot/tools/cabana/streams/livestream.h @@ -1,11 +1,11 @@ #pragma once #include +#include #include +#include #include -#include - #include "tools/cabana/streams/abstractstream.h" class LiveStream : public AbstractStream { @@ -16,7 +16,7 @@ public: virtual ~LiveStream(); void start() override; void stop(); - inline QDateTime beginDateTime() const { return begin_date_time; } + inline std::chrono::system_clock::time_point beginDateTime() const override { return begin_date_time; } inline uint64_t beginMonoTime() const override { return begin_event_ts; } double maxSeconds() const override { return std::max(1.0, (lastest_event_ts - begin_event_ts) / 1e9); } void setSpeed(float speed) override { speed_ = speed; } @@ -29,19 +29,20 @@ protected: virtual void streamThread() = 0; void handleEvent(kj::ArrayPtr event); + std::atomic exit_ = false; + private: - void startUpdateTimer(); - void timerEvent(QTimerEvent *event) override; + void updateThread(); + void updateLastMessages() override; void updateEvents(); std::mutex lock; - QThread *stream_thread; + std::thread stream_thread, update_thread; + std::atomic update_pending_ = false; + std::atomic fps_ = 10; std::vector received_events_; - int timer_id; - QBasicTimer update_timer; - - QDateTime begin_date_time; + std::chrono::system_clock::time_point begin_date_time; uint64_t begin_event_ts = 0; uint64_t lastest_event_ts = 0; uint64_t current_event_ts = 0; diff --git a/openpilot/tools/cabana/streams/pandastream.cc b/openpilot/tools/cabana/streams/pandastream.cc index 3692f71a11..7ccb18a756 100644 --- a/openpilot/tools/cabana/streams/pandastream.cc +++ b/openpilot/tools/cabana/streams/pandastream.cc @@ -1,11 +1,13 @@ #include "tools/cabana/streams/pandastream.h" -#include +#include +#include +#include + #include #include #include #include -#include #include PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(config_), LiveStream(parent) { @@ -16,10 +18,10 @@ PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(co bool PandaStream::connect() { try { - qDebug() << "Connecting to panda " << config.serial.c_str(); + fprintf(stderr, "Connecting to panda %s\n", config.serial.c_str()); panda.reset(new Panda(config.serial)); config.bus_config.resize(3); - qDebug() << "Connected"; + fprintf(stderr, "Connected\n"); } catch (const std::exception& e) { return false; } @@ -44,20 +46,20 @@ bool PandaStream::connect() { void PandaStream::streamThread() { std::vector raw_can_data; - while (!QThread::currentThread()->isInterruptionRequested()) { - QThread::msleep(1); + while (!exit_) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); if (!panda->connected()) { - qDebug() << "Connection to panda lost. Attempting reconnect."; + fprintf(stderr, "Connection to panda lost. Attempting reconnect.\n"); if (!connect()){ - QThread::msleep(1000); + std::this_thread::sleep_for(std::chrono::milliseconds(1000)); continue; } } raw_can_data.clear(); if (!panda->can_receive(raw_can_data)) { - qDebug() << "failed to receive"; + fprintf(stderr, "failed to receive\n"); continue; } @@ -123,7 +125,7 @@ void OpenPandaWidget::buildConfigForm() { 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) { - qDebug() << "failed to open panda" << serial; + fprintf(stderr, "failed to open panda %s\n", serial.toUtf8().constData()); has_panda = false; } } diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index b00c6e52c6..6d54369aef 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -1,5 +1,7 @@ #include "tools/cabana/streams/replaystream.h" +#include + #include #include #include @@ -14,10 +16,7 @@ ReplayStream::ReplayStream(QObject *parent) : AbstractStream(parent) { unsetenv("ZMQ"); setenv("COMMA_CACHE", "/tmp/comma_download_cache", 1); - // TODO: Remove when OpenpilotPrefix supports ZMQ -#ifndef __APPLE__ op_prefix = std::make_unique(); -#endif QObject::connect(&settings, &Settings::changed, this, [this]() { if (replay) replay->setSegmentCacheLimit(settings.max_cached_minutes); @@ -47,7 +46,7 @@ void ReplayStream::mergeSegments() { } bool ReplayStream::loadRoute(const std::string &route, const std::string &data_dir, uint32_t replay_flags, bool auto_source) { - replay.reset(new Replay(route, {"can", "roadEncodeIdx", "driverEncodeIdx", "wideRoadEncodeIdx", "carParams"}, + replay.reset(new Replay(route, {"can", "narrowRoadEncodeIdx", "cabinEncodeIdx", "wideRoadEncodeIdx", "carParams"}, {}, nullptr, replay_flags, data_dir, auto_source)); replay->setSegmentCacheLimit(settings.max_cached_minutes); replay->installEventFilter([this](const Event *event) { return eventFilter(event); }); @@ -136,10 +135,10 @@ OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(p setMinimumWidth(550); QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() { - QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), settings.last_route_dir); + 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 = QFileInfo(dir).absolutePath(); + settings.last_route_dir = std::filesystem::absolute(dir.toStdString()).parent_path().string(); } }); QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { @@ -164,8 +163,8 @@ AbstractStream *OpenReplayWidget::open() { } else { auto replay_stream = std::make_unique(qApp); uint32_t flags = REPLAY_FLAG_NONE; - if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_DCAM; - if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_ECAM; + 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)) { diff --git a/openpilot/tools/cabana/streams/replaystream.h b/openpilot/tools/cabana/streams/replaystream.h index 40f8ec8cfb..eecd345715 100644 --- a/openpilot/tools/cabana/streams/replaystream.h +++ b/openpilot/tools/cabana/streams/replaystream.h @@ -26,7 +26,9 @@ public: inline std::string carFingerprint() const override { return replay->carFingerprint(); } double minSeconds() const override { return replay->minSeconds(); } double maxSeconds() const { return replay->maxSeconds(); } - inline QDateTime beginDateTime() const { return QDateTime::fromSecsSinceEpoch(replay->routeDateTime()); } + inline std::chrono::system_clock::time_point beginDateTime() const override { + return std::chrono::system_clock::from_time_t(replay->routeDateTime()); + } inline uint64_t beginMonoTime() const override { return replay->routeStartNanos(); } inline void setSpeed(float speed) override { replay->setSpeed(speed); } inline float getSpeed() const { return replay->getSpeed(); } diff --git a/openpilot/tools/cabana/streams/routes.cc b/openpilot/tools/cabana/streams/routes.cc index e3e5cb1b6e..b6f98da533 100644 --- a/openpilot/tools/cabana/streams/routes.cc +++ b/openpilot/tools/cabana/streams/routes.cc @@ -1,18 +1,19 @@ #include "tools/cabana/streams/routes.h" +#include +#include +#include +#include +#include + #include -#include #include #include -#include -#include -#include #include #include #include -#include -#include +#include "json11/json11.hpp" #include "tools/replay/py_downloader.h" namespace { @@ -20,13 +21,52 @@ namespace { // Parse a PyDownloader JSON response into (success, error_code). std::pair checkApiResponse(const std::string &result) { if (result.empty()) return {false, 500}; - auto doc = QJsonDocument::fromJson(QByteArray::fromStdString(result)); - if (doc.isObject() && doc.object().contains("error")) { - return {false, doc.object()["error"].toString() == "unauthorized" ? 401 : 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 @@ -71,11 +111,10 @@ RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); // Fetch devices - QPointer self = this; - std::thread([self]() { + std::thread([this, alive = std::weak_ptr(alive_)]() { std::string result = PyDownloader::getDevices(); - QMetaObject::invokeMethod(qApp, [self, r = QString::fromStdString(result), response = checkApiResponse(result)]() { - if (self) self->parseDeviceList(r, response.first, response.second); + QMetaObject::invokeMethod(qApp, [this, alive, r = QString::fromStdString(result), response = checkApiResponse(result)]() { + if (!alive.expired()) parseDeviceList(r, response.first, response.second); }, Qt::QueuedConnection); }).detach(); } @@ -83,9 +122,13 @@ RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { void RoutesDialog::parseDeviceList(const QString &json, bool success, int error_code) { if (success) { device_list_->clear(); - for (const QJsonValue &device : QJsonDocument::fromJson(json.toUtf8()).array()) { - QString dongle_id = device["dongle_id"].toString(); - device_list_->addItem(dongle_id, dongle_id); + 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")); @@ -106,36 +149,38 @@ void RoutesDialog::fetchRoutes() { bool preserved = (period == -1); int64_t start_ms = 0, end_ms = 0; if (!preserved) { - QDateTime now = QDateTime::currentDateTime(); - start_ms = now.addDays(-period).toMSecsSinceEpoch(); - end_ms = now.toMSecsSinceEpoch(); + end_ms = nowUnixMs(); + start_ms = end_ms - static_cast(period) * 24LL * 60LL * 60LL * 1000LL; } int request_id = ++fetch_id_; - QPointer self = this; - std::thread([self, did, start_ms, end_ms, preserved, request_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); - if (!self || self->fetch_id_ != request_id) return; - QMetaObject::invokeMethod(qApp, [self, r = QString::fromStdString(result), response = checkApiResponse(result), request_id]() { - if (self && self->fetch_id_ == request_id) self->parseRouteList(r, response.first, response.second); + 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) { - for (const QJsonValue &route : QJsonDocument::fromJson(json.toUtf8()).array()) { - QDateTime from, to; - if (period_selector_->currentData().toInt() == -1) { - from = QDateTime::fromString(route["start_time"].toString(), Qt::ISODateWithMs); - to = QDateTime::fromString(route["end_time"].toString(), Qt::ISODateWithMs); - } else { - from = QDateTime::fromMSecsSinceEpoch(route["start_time_utc_millis"].toDouble()); - to = QDateTime::fromMSecsSinceEpoch(route["end_time_utc_millis"].toDouble()); + 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); } - auto item = new QListWidgetItem(QString("%1 %2min").arg(from.toString()).arg(from.secsTo(to) / 60)); - item->setData(Qt::UserRole, route["fullname"].toString()); - route_list_->addItem(item); } if (route_list_->count() > 0) route_list_->setCurrentRow(0); } else { diff --git a/openpilot/tools/cabana/streams/routes.h b/openpilot/tools/cabana/streams/routes.h index 99fa67ef8c..6ed145603f 100644 --- a/openpilot/tools/cabana/streams/routes.h +++ b/openpilot/tools/cabana/streams/routes.h @@ -1,6 +1,8 @@ #pragma once #include +#include + #include #include @@ -21,4 +23,6 @@ protected: QComboBox *period_selector_; RouteListWidget *route_list_; std::atomic fetch_id_{0}; + // expires on destruction; guards main-thread callbacks from detached worker threads + std::shared_ptr alive_ = std::make_shared(true); }; diff --git a/openpilot/tools/cabana/streams/socketcanstream.cc b/openpilot/tools/cabana/streams/socketcanstream.cc index 768465d5a3..b616e7f242 100644 --- a/openpilot/tools/cabana/streams/socketcanstream.cc +++ b/openpilot/tools/cabana/streams/socketcanstream.cc @@ -7,20 +7,21 @@ #include #include -#include -#include +#include +#include +#include + #include #include #include #include -#include SocketCanStream::SocketCanStream(QObject *parent, SocketCanStreamConfig config_) : config(config_), LiveStream(parent) { if (!available()) { throw std::runtime_error("SocketCAN not available"); } - qDebug() << "Connecting to SocketCAN device" << config.device.c_str(); + fprintf(stderr, "Connecting to SocketCAN device %s\n", config.device.c_str()); if (!connect()) { throw std::runtime_error("Failed to connect to SocketCAN device"); } @@ -44,7 +45,7 @@ bool SocketCanStream::available() { bool SocketCanStream::connect() { sock_fd = socket(PF_CAN, SOCK_RAW, CAN_RAW); if (sock_fd < 0) { - qDebug() << "Failed to create CAN socket"; + fprintf(stderr, "Failed to create CAN socket\n"); return false; } @@ -55,7 +56,7 @@ bool SocketCanStream::connect() { struct ifreq ifr = {}; strncpy(ifr.ifr_name, config.device.c_str(), IFNAMSIZ - 1); if (ioctl(sock_fd, SIOCGIFINDEX, &ifr) < 0) { - qDebug() << "Failed to get interface index for" << config.device.c_str(); + fprintf(stderr, "Failed to get interface index for %s\n", config.device.c_str()); ::close(sock_fd); sock_fd = -1; return false; @@ -65,7 +66,7 @@ bool SocketCanStream::connect() { addr.can_family = AF_CAN; addr.can_ifindex = ifr.ifr_ifindex; if (bind(sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { - qDebug() << "Failed to bind CAN socket"; + fprintf(stderr, "Failed to bind CAN socket\n"); ::close(sock_fd); sock_fd = -1; return false; @@ -81,7 +82,7 @@ bool SocketCanStream::connect() { void SocketCanStream::streamThread() { struct canfd_frame frame; - while (!QThread::currentThread()->isInterruptionRequested()) { + while (!exit_) { ssize_t nbytes = read(sock_fd, &frame, sizeof(frame)); if (nbytes <= 0) continue; @@ -127,14 +128,12 @@ OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWi void OpenSocketCanWidget::refreshDevices() { device_edit->clear(); // Scan /sys/class/net/ for CAN interfaces (type 280 = ARPHRD_CAN) - QDir net_dir("/sys/class/net"); - for (const auto &iface : net_dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { - QFile type_file(net_dir.filePath(iface) + "/type"); - if (type_file.open(QIODevice::ReadOnly)) { - int type = type_file.readAll().trimmed().toInt(); - if (type == 280) { - device_edit->addItem(iface); - } + 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())); } } } diff --git a/openpilot/tools/cabana/streamselector.cc b/openpilot/tools/cabana/streamselector.cc index 4ad552d4b4..7e8adc568d 100644 --- a/openpilot/tools/cabana/streamselector.cc +++ b/openpilot/tools/cabana/streamselector.cc @@ -1,5 +1,7 @@ #include "tools/cabana/streamselector.h" +#include + #include #include #include @@ -52,10 +54,10 @@ StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { setEnabled(true); }); QObject::connect(file_btn, &QPushButton::clicked, [this]() { - QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), settings.last_dir, "DBC (*.dbc)"); + QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)"); if (!fn.isEmpty()) { dbc_file->setText(fn); - settings.last_dir = QFileInfo(fn).absolutePath(); + settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string(); } }); } diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 833cfbe4b5..53be1b0afa 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -1,13 +1,14 @@ -#undef INFO -#include +#include +#include -#include "catch2/catch.hpp" +#include "common/tests/native_test.h" +#include "tools/cabana/dbc/dbcfile.h" #include "tools/cabana/dbc/dbcmanager.h" const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; -TEST_CASE("DBCFile::generateDBC") { +void test_generate_dbc() { std::string fn = std::string(OPENDBC_FILE_PATH) + "/tesla_can.dbc"; DBCFile dbc_origin(fn); DBCFile dbc_from_generated("", dbc_origin.generateDBC()); @@ -28,7 +29,7 @@ TEST_CASE("DBCFile::generateDBC") { } } -TEST_CASE("DBCFile::generateDBC - comment order") { +void test_comment_order() { // Ensure that message comments are followed by signal comments and in the correct order std::string content = R"(BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX @@ -45,7 +46,7 @@ CM_ SG_ 162 signal_2 "signal comment"; REQUIRE(dbc.generateDBC() == content); } -TEST_CASE("DBCFile::generateDBC -- preserve original header") { +void test_preserve_original_header() { std::string content = R"(VERSION "1.0" NS_ : @@ -65,7 +66,7 @@ CM_ SG_ 160 signal_1 "signal comment"; REQUIRE(dbc.generateDBC() == content); } -TEST_CASE("DBCFile::generateDBC - escaped quotes") { +void test_escaped_quotes() { std::string content = R"(BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX @@ -76,7 +77,7 @@ CM_ SG_ 160 signal_1 "signal comment with \"escaped quotes\""; REQUIRE(dbc.generateDBC() == content); } -TEST_CASE("parse_dbc") { +void test_parse_dbc() { std::string content = R"( BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX @@ -142,16 +143,59 @@ CM_ SG_ 162 signal_1 "signal comment with \"escaped quotes\""; REQUIRE(msg->sigs[0]->comment == "signal comment with \"escaped quotes\""); } -TEST_CASE("parse_opendbc") { - QDir dir(OPENDBC_FILE_PATH); - QStringList errors; - for (auto fn : dir.entryList({"*.dbc"}, QDir::Files, QDir::Name)) { +void test_parse_opendbc() { + std::vector errors; + for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH)) { + if (!entry.is_regular_file() || entry.path().extension() != ".dbc") continue; try { - auto dbc = DBCFile(dir.filePath(fn).toStdString()); + auto dbc = DBCFile(entry.path().string()); } catch (std::exception &e) { errors.push_back(e.what()); } } - INFO(errors.join("\n").toStdString()); + std::ostringstream details; + for (const auto &error : errors) details << error << '\n'; + if (!errors.empty()) std::cerr << details.str(); REQUIRE(errors.empty()); } + +void test_dbc_manager() { + DBCManager 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; }, + }); + + std::string error; + REQUIRE(manager.open(SOURCE_ALL, "test", "BO_ 160 message: 8 XXX\n", &error)); + REQUIRE(error.empty()); + REQUIRE(files_changed == 1); + + cabana::Signal signal{}; + signal.name = "speed"; + signal.start_bit = 0; + signal.size = 8; + signal.is_little_endian = true; + manager.addSignal({.source = 0, .address = 160}, signal); + REQUIRE(signals_added == 1); + REQUIRE(masks_updated == 1); + REQUIRE(manager.msg({.source = 0, .address = 160})->sig("speed") != nullptr); +} + +void test_cabana_core() { + test_generate_dbc(); + test_comment_order(); + test_preserve_original_header(); + test_escaped_quotes(); + test_parse_dbc(); + test_parse_opendbc(); + test_dbc_manager(); +} + +int main() { + return run_native_test(test_cabana_core); +} diff --git a/openpilot/tools/cabana/tests/test_runner.cc b/openpilot/tools/cabana/tests/test_runner.cc deleted file mode 100644 index b20ac86c64..0000000000 --- a/openpilot/tools/cabana/tests/test_runner.cc +++ /dev/null @@ -1,10 +0,0 @@ -#define CATCH_CONFIG_RUNNER -#include "catch2/catch.hpp" -#include - -int main(int argc, char **argv) { - // unit tests for Qt - QCoreApplication app(argc, argv); - const int res = Catch::Session().run(argc, argv); - return (res < 0xff ? res : 0xff); -} diff --git a/openpilot/tools/cabana/tools/findsignal.cc b/openpilot/tools/cabana/tools/findsignal.cc index d538d676b1..4511af7c01 100644 --- a/openpilot/tools/cabana/tools/findsignal.cc +++ b/openpilot/tools/cabana/tools/findsignal.cc @@ -1,5 +1,6 @@ #include "tools/cabana/tools/findsignal.h" +#include #include #include @@ -210,13 +211,13 @@ void FindSignalDlg::search() { } void FindSignalDlg::setInitialSignals() { - QSet buses; + std::set buses; for (auto bus : bus_edit->text().trimmed().split(",")) { bus = bus.trimmed(); if (!bus.isEmpty()) buses.insert(bus.toUShort()); } - QSet addresses; + std::set addresses; for (auto addr : address_edit->text().trimmed().split(",")) { addr = addr.trimmed(); if (!addr.isEmpty()) addresses.insert(addr.toULong(nullptr, 16)); @@ -239,7 +240,7 @@ void FindSignalDlg::setInitialSignals() { model->initial_signals.clear(); for (const auto &[id, m] : can->lastMessages()) { - if ((buses.isEmpty() || buses.contains(id.source)) && (addresses.isEmpty() || addresses.contains(id.address))) { + if ((buses.empty() || buses.count(id.source)) && (addresses.empty() || addresses.count(id.address))) { const auto &events = can->events(id); auto e = std::lower_bound(events.cbegin(), events.cend(), first_time, CompareCanEvent()); if (e != events.cend()) { @@ -276,7 +277,7 @@ void FindSignalDlg::customMenuRequested(const QPoint &pos) { menu.addAction(tr("Create Signal")); if (menu.exec(view->mapToGlobal(pos))) { auto &s = model->filtered_signals[index.row()]; - UndoStack::push(new AddSigCommand(s.id, s.sig)); + UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); emit openMessage(s.id); } } diff --git a/openpilot/tools/cabana/tools/routeinfo.cc b/openpilot/tools/cabana/tools/routeinfo.cc index 77a0e065cd..dc272e3d12 100644 --- a/openpilot/tools/cabana/tools/routeinfo.cc +++ b/openpilot/tools/cabana/tools/routeinfo.cc @@ -14,7 +14,7 @@ RouteInfoDlg::RouteInfoDlg(QWidget *parent) : QDialog(parent) { table->setEditTriggers(QAbstractItemView::NoEditTriggers); table->setSelectionBehavior(QAbstractItemView::SelectRows); table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setHorizontalHeaderLabels({"", "rlog", "fcam", "ecam", "dcam", "qlog", "qcam"}); + table->setHorizontalHeaderLabels({"", "rlog", "narrow road", "wide road", "driver", "qlog", "qcam"}); table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); table->verticalHeader()->setVisible(false); table->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); @@ -23,9 +23,9 @@ RouteInfoDlg::RouteInfoDlg(QWidget *parent) : QDialog(parent) { for (const auto &[seg_num, seg] : replay->route().segments()) { table->setItem(row, 0, new QTableWidgetItem(QString::number(seg_num))); table->setItem(row, 1, new QTableWidgetItem(seg.rlog.empty() ? "--" : "Yes")); - table->setItem(row, 2, new QTableWidgetItem(seg.road_cam.empty() ? "--" : "Yes")); + table->setItem(row, 2, new QTableWidgetItem(seg.narrow_road_cam.empty() ? "--" : "Yes")); table->setItem(row, 3, new QTableWidgetItem(seg.wide_road_cam.empty() ? "--" : "Yes")); - table->setItem(row, 4, new QTableWidgetItem(seg.driver_cam.empty() ? "--" : "Yes")); + table->setItem(row, 4, new QTableWidgetItem(seg.cabin_cam.empty() ? "--" : "Yes")); table->setItem(row, 5, new QTableWidgetItem(seg.qlog.empty() ? "--" : "Yes")); table->setItem(row, 6, new QTableWidgetItem(seg.qcamera.empty() ? "--" : "Yes")); ++row; diff --git a/openpilot/tools/cabana/utils/export.cc b/openpilot/tools/cabana/utils/export.cc index a7f910193f..d585827ef5 100644 --- a/openpilot/tools/cabana/utils/export.cc +++ b/openpilot/tools/cabana/utils/export.cc @@ -1,41 +1,41 @@ #include "tools/cabana/utils/export.h" -#include -#include +#include +#include #include "tools/cabana/streams/abstractstream.h" namespace utils { -void exportToCSV(const QString &file_name, std::optional msg_id) { - QFile file(file_name); - if (file.open(QIODevice::ReadWrite | QIODevice::Truncate)) { - QTextStream stream(&file); +void exportToCSV(const std::string &file_name, std::optional msg_id) { + std::ofstream stream(file_name, std::ios::trunc); + if (stream) { stream << "time,addr,bus,data\n"; for (auto e : msg_id ? can->events(*msg_id) : can->allEvents()) { - stream << QString::number(can->toSeconds(e->mono_time), 'f', 3) << "," - << "0x" << QString::number(e->address, 16) << "," << e->src << "," - << "0x" << QByteArray::fromRawData((const char *)e->dat, e->size).toHex().toUpper() << "\n"; + stream << std::fixed << std::setprecision(3) << can->toSeconds(e->mono_time) << "," + << "0x" << std::hex << e->address << std::dec << "," << static_cast(e->src) << ",0x" + << std::uppercase << std::hex << std::setfill('0'); + for (int i = 0; i < e->size; ++i) stream << std::setw(2) << static_cast(e->dat[i]); + stream << std::nouppercase << std::dec << "\n"; } } } -void exportSignalsToCSV(const QString &file_name, const MessageId &msg_id) { - QFile file(file_name); - if (auto msg = dbc()->msg(msg_id); msg && msg->sigs.size() && file.open(QIODevice::ReadWrite | QIODevice::Truncate)) { - QTextStream stream(&file); +void exportSignalsToCSV(const std::string &file_name, const MessageId &msg_id) { + std::ofstream stream(file_name, std::ios::trunc); + if (auto msg = dbc()->msg(msg_id); msg && !msg->sigs.empty() && stream) { stream << "time,addr,bus"; for (auto s : msg->sigs) stream << "," << s->name.c_str(); stream << "\n"; for (auto e : can->events(msg_id)) { - stream << QString::number(can->toSeconds(e->mono_time), 'f', 3) << "," - << "0x" << QString::number(e->address, 16) << "," << e->src; + stream << std::fixed << std::setprecision(3) << can->toSeconds(e->mono_time) << "," + << "0x" << std::hex << e->address << std::dec << "," << static_cast(e->src); for (auto s : msg->sigs) { double value = 0; s->getValue(e->dat, e->size, &value); - stream << "," << QString::number(value, 'f', s->precision); + stream << "," << std::fixed << std::setprecision(s->precision) << value; } stream << "\n"; } diff --git a/openpilot/tools/cabana/utils/export.h b/openpilot/tools/cabana/utils/export.h index 270906b163..ee110e8321 100644 --- a/openpilot/tools/cabana/utils/export.h +++ b/openpilot/tools/cabana/utils/export.h @@ -1,10 +1,11 @@ #pragma once #include +#include #include "tools/cabana/dbc/dbcmanager.h" namespace utils { -void exportToCSV(const QString &file_name, std::optional msg_id = std::nullopt); -void exportSignalsToCSV(const QString &file_name, const MessageId &msg_id); +void exportToCSV(const std::string &file_name, std::optional msg_id = std::nullopt); +void exportSignalsToCSV(const std::string &file_name, const MessageId &msg_id); } // namespace utils diff --git a/openpilot/tools/cabana/utils/util.cc b/openpilot/tools/cabana/utils/util.cc index 50ab764423..87a0f89427 100644 --- a/openpilot/tools/cabana/utils/util.cc +++ b/openpilot/tools/cabana/utils/util.cc @@ -1,21 +1,24 @@ #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 #include -#include -#include #include #include #include "common/util.h" @@ -100,7 +103,7 @@ void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem & // Paint hex column const auto &bytes = *static_cast *>(data.value()); - const auto &colors = *static_cast *>(index.data(ColorsRole).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); @@ -115,7 +118,7 @@ void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem & painter->setPen(option.palette.color(QPalette::Text)); painter->fillRect(r, option.palette.color(QPalette::Window)); } - painter->fillRect(r, colors[i]); + painter->fillRect(r, toQColor(colors[i])); } else { painter->setPen(text_pen); } @@ -148,18 +151,35 @@ void TabBar::closeTabClicked() { // UnixSignalHandler -UnixSignalHandler::UnixSignalHandler(QObject *parent) : QObject(nullptr) { +UnixSignalHandler::UnixSignalHandler() { if (::socketpair(AF_UNIX, SOCK_STREAM, 0, sig_fd)) { qFatal("Couldn't create TERM socketpair"); } - sn = new QSocketNotifier(sig_fd[1], QSocketNotifier::Read, this); - connect(sn, &QSocketNotifier::activated, this, &UnixSignalHandler::handleSigTerm); + waiter = std::thread([this]() { + 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); + }); + std::signal(SIGINT, signalHandler); std::signal(SIGTERM, UnixSignalHandler::signalHandler); } UnixSignalHandler::~UnixSignalHandler() { + shutting_down.store(true); + int dummy = 0; + (void)!::write(sig_fd[0], &dummy, sizeof(dummy)); + if (waiter.joinable()) waiter.join(); ::close(sig_fd[0]); ::close(sig_fd[1]); } @@ -168,34 +188,174 @@ void UnixSignalHandler::signalHandler(int s) { (void)!::write(sig_fd[0], &s, sizeof(s)); } -void UnixSignalHandler::handleSigTerm() { - sn->setEnabled(false); - int tmp; - (void)!::read(sig_fd[1], &tmp, sizeof(tmp)); - - printf("\nexiting...\n"); - qApp->closeAllWindows(); - qApp->exit(); -} - // NameValidator -NameValidator::NameValidator(QObject *parent) : QRegExpValidator(QRegExp("^(\\w+)"), parent) {} +NameValidator::NameValidator(QObject *parent) : QValidator(parent) {} QValidator::State NameValidator::validate(QString &input, int &pos) const { + Q_UNUSED(pos); input.replace(' ', '_'); - return QRegExpValidator::validate(input, pos); + if (input.isEmpty()) return QValidator::Intermediate; + for (const QChar &c : input) { + if (!c.isLetterOrNumber() && c != '_') return QValidator::Invalid; + } + return QValidator::Acceptable; } -DoubleValidator::DoubleValidator(QObject *parent) : QDoubleValidator(parent) { - // Match locale of QString::toDouble() instead of system - QLocale locale(QLocale::C); - locale.setNumberOptions(QLocale::RejectGroupSeparator); - setLocale(locale); +// 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; + // Match ^\w+(,\w+)*$ ; a trailing comma is Intermediate (user still typing). + bool need_word = true; + for (const QChar &c : input) { + if (c.isLetterOrNumber() || c == '_') { + need_word = false; + } else if (c == ',' && !need_word) { + need_word = true; + } else { + return QValidator::Invalid; + } + } + return need_word ? QValidator::Intermediate : QValidator::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; + } + return QValidator::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; + + 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; + has_digit = true; + } else if (c == '.') { + if (!has_digit || dots >= 3) return QValidator::Invalid; + ++dots; + has_digit = false; + value = 0; + } else { + return QValidator::Invalid; + } + } + return (dots == 3 && has_digit) ? QValidator::Acceptable : QValidator::Intermediate; +} + +DoubleValidator::DoubleValidator(QObject *parent) : QValidator(parent) {} + +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; + } + + const char *start = bytes.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 QValidator::Invalid; + } + if (*end == '\0') { + // Reject inf/nan (strtod accepts them; QDoubleValidator / toDouble path should not). + return std::isfinite(value) ? QValidator::Acceptable : QValidator::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 QValidator::Intermediate; } namespace utils { +std::string homePath() { + const char *home = ::getenv("HOME"); + return home ? home : ""; +} + +std::filesystem::path configPath() { +#ifdef __APPLE__ + return std::filesystem::path(homePath()) / "Library/Preferences"; +#else + const char *xdg = ::getenv("XDG_CONFIG_HOME"); + return (xdg && xdg[0]) ? std::filesystem::path(xdg) : std::filesystem::path(homePath()) / ".config"; +#endif +} + +#ifdef __APPLE__ +static const char *clipboard_read_cmds[] = {"pbpaste"}; +static const char *clipboard_write_cmds[] = {"pbcopy"}; +#else +static const char *clipboard_read_cmds[] = {"wl-paste --no-newline 2>/dev/null", "xclip -selection clipboard -o 2>/dev/null", "xsel -ob 2>/dev/null"}; +static const char *clipboard_write_cmds[] = {"wl-copy 2>/dev/null", "xclip -selection clipboard 2>/dev/null", "xsel -ib 2>/dev/null"}; +#endif + +bool getClipboardText(std::string *text) { + text->clear(); + bool has_tool = false; + for (const char *cmd : clipboard_read_cmds) { + FILE *f = ::popen(cmd, "r"); + if (!f) continue; + std::string out; + char buf[4096]; + for (size_t n; (n = ::fread(buf, 1, sizeof(buf), f)) > 0;) out.append(buf, n); + int status = ::pclose(f); + if (status == 0) { + *text = std::move(out); + return true; + } + has_tool |= WIFEXITED(status) && WEXITSTATUS(status) != 127; // 127: command not found + } + return has_tool; // tool present but clipboard empty +} + +bool setClipboardText(const std::string &text) { + std::signal(SIGPIPE, SIG_IGN); + for (const char *cmd : clipboard_write_cmds) { + FILE *f = ::popen(cmd, "w"); + if (!f) continue; + size_t written = ::fwrite(text.data(), 1, text.size(), f); + if (::pclose(f) == 0 && written == text.size()) return true; + } + return false; +} + bool isDarkTheme() { QColor windowColor = QApplication::palette().color(QPalette::Window); return windowColor.lightness() < 128; @@ -257,10 +417,34 @@ void setTheme(int theme) { } QString formatSeconds(double sec, bool include_milliseconds, bool absolute_time) { - QString format = absolute_time ? "yyyy-MM-dd hh:mm:ss" - : (sec > 60 * 60 ? "hh:mm:ss" : "mm:ss"); - if (include_milliseconds) format += ".zzz"; - return QDateTime::fromMSecsSinceEpoch(sec * 1000).toString(format); + if (absolute_time) { + const auto ms_total = static_cast(std::llround(sec * 1000.0)); + const std::time_t secs = static_cast(ms_total / 1000); + int millis = static_cast(ms_total % 1000); + if (millis < 0) millis = -millis; + std::tm tm{}; + localtime_r(&secs, &tm); + char buf[64]; + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); + if (include_milliseconds) { + return QString::asprintf("%s.%03d", buf, millis); + } + return QString::fromUtf8(buf); + } + + // Relative duration (not wall-clock). + const bool show_hours = sec > 60 * 60; + int total_ms = static_cast(std::llround(std::max(0.0, sec) * 1000.0)); + const int hours = total_ms / (3600 * 1000); + const int minutes = (total_ms / (60 * 1000)) % 60; + const int seconds = (total_ms / 1000) % 60; + const int millis = total_ms % 1000; + if (show_hours) { + return include_milliseconds ? QString::asprintf("%02d:%02d:%02d.%03d", hours, minutes, seconds, millis) + : QString::asprintf("%02d:%02d:%02d", hours, minutes, seconds); + } + return include_milliseconds ? QString::asprintf("%02d:%02d.%03d", minutes, seconds, millis) + : QString::asprintf("%02d:%02d", minutes, seconds); } } // namespace utils @@ -281,20 +465,6 @@ QString signalToolTip(const cabana::Signal *sig) { .arg(sig->is_little_endian ? "Y" : "N").arg(sig->is_signed ? "Y" : "N"); } -void setSurfaceFormat() { - QSurfaceFormat fmt; -#ifdef __APPLE__ - fmt.setVersion(3, 2); - fmt.setProfile(QSurfaceFormat::OpenGLContextProfile::CoreProfile); - fmt.setRenderableType(QSurfaceFormat::OpenGL); -#else - fmt.setRenderableType(QSurfaceFormat::OpenGLES); -#endif - fmt.setSamples(16); - fmt.setStencilBufferSize(1); - QSurfaceFormat::setDefaultFormat(fmt); -} - void sigTermHandler(int s) { std::signal(s, SIG_DFL); qApp->quit(); @@ -305,57 +475,57 @@ void initApp(int argc, char *argv[], bool disable_hidpi) { std::signal(SIGINT, sigTermHandler); std::signal(SIGTERM, sigTermHandler); - QString app_dir; + std::filesystem::path app_dir; #ifdef __APPLE__ // Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering QApplication tmp(argc, argv); - app_dir = QCoreApplication::applicationDirPath(); + app_dir = QCoreApplication::applicationDirPath().toStdString(); if (disable_hidpi) { qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit()); } #else - app_dir = QFileInfo(util::readlink("/proc/self/exe").c_str()).path(); + app_dir = std::filesystem::path(util::readlink("/proc/self/exe")).parent_path(); #endif - qputenv("QT_DBL_CLICK_DIST", QByteArray::number(150)); + qputenv("QT_DBL_CLICK_DIST", "150"); // ensure the current dir matches the exectuable's directory - QDir::setCurrent(app_dir); - - setSurfaceFormat(); + 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; - QFile f(":/bootstrap-icons.svg"); - if (f.open(QIODevice::ReadOnly | QIODevice::Text)) { - std::string content = f.readAll().toStdString(); - const std::string sym_open = "(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); - } + // extract id + size_t id_start = content.find(id_attr, pos); + if (id_start != std::string::npos && id_start < end) { + id_start += id_attr.size(); + size_t id_end = content.find('"', id_start); + if (id_end != std::string::npos && id_end < end) { + std::string id = content.substr(id_start, id_end - id_start); + std::string svg_str = content.substr(pos, end - pos); + // replace with + svg_str.replace(0, 7, " ""); // "" (9) -> "" (6) + icons[id] = std::move(svg_str); } - pos = end; } + pos = end; } return icons; } diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index f839ffe7fe..5a3c62d118 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -1,26 +1,32 @@ #pragma once #include +#include #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 @@ -84,22 +90,53 @@ private: int h_margin, v_margin; }; -class NameValidator : public QRegExpValidator { +// 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; }; -class DoubleValidator : public QDoubleValidator { +// 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; }; namespace utils { QPixmap icon(const QString &id); +std::string homePath(); +std::filesystem::path configPath(); +bool getClipboardText(std::string *text); // false if no clipboard tool is available +bool setClipboardText(const std::string &text); bool isDarkTheme(); void setTheme(int theme); QString formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false); @@ -108,7 +145,22 @@ inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text); } inline QString toHex(const std::vector &dat, char separator = '\0') { - return QByteArray::fromRawData((const char *)dat.data(), dat.size()).toHex(separator).toUpper(); + static const char digits[] = "0123456789ABCDEF"; + QString hex; + hex.reserve(dat.size() * (separator ? 3 : 2)); + for (size_t i = 0; i < dat.size(); ++i) { + if (separator && i) hex += QLatin1Char(separator); + hex += QLatin1Char(digits[dat[i] >> 4]); + hex += QLatin1Char(digits[dat[i] & 0xf]); + } + return hex; +} + +// boundary conversions for the remaining Qt byte-array based state APIs +template +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()); } } @@ -147,20 +199,18 @@ private: void closeTabClicked(); }; -class UnixSignalHandler : public QObject { - Q_OBJECT - +// Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread +// (no Qt notifiers/timers). Exit is marshaled onto the GUI thread. +class UnixSignalHandler { public: - UnixSignalHandler(QObject *parent = nullptr); + UnixSignalHandler(); ~UnixSignalHandler(); static void signalHandler(int s); -public slots: - void handleSigTerm(); - private: inline static int sig_fd[2] = {}; - QSocketNotifier *sn; + std::atomic shutting_down{false}; + std::thread waiter; }; int num_decimals(double num); diff --git a/openpilot/tools/cabana/videowidget.cc b/openpilot/tools/cabana/videowidget.cc index 87d3cbec95..bd61573658 100644 --- a/openpilot/tools/cabana/videowidget.cc +++ b/openpilot/tools/cabana/videowidget.cc @@ -148,7 +148,7 @@ QWidget *VideoWidget::createCameraWidget() { camera_tab->setAutoHide(true); camera_tab->setExpanding(false); - l->addWidget(cam_widget = new StreamCameraView("camerad", VISION_STREAM_ROAD)); + l->addWidget(cam_widget = new StreamCameraView("camerad", VISION_STREAM_NARROW_ROAD)); cam_widget->setMinimumHeight(MIN_VIDEO_HEIGHT); cam_widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding); @@ -157,7 +157,7 @@ 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, [c = cam_widget]() { c->showPausedOverlay(); }); + 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); @@ -203,7 +203,7 @@ void VideoWidget::timeRangeChanged() { QString VideoWidget::formatTime(double sec, bool include_milliseconds) { if (settings.absolute_time) - sec = can->beginDateTime().addMSecs(sec * 1000).toMSecsSinceEpoch() / 1000.0; + sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time); } @@ -324,12 +324,6 @@ void Slider::mousePressEvent(QMouseEvent *e) { // StreamCameraView StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent) : CameraWidget(stream_name, stream_type, parent) { - fade_animation = new QPropertyAnimation(this, "overlayOpacity"); - fade_animation->setDuration(500); - fade_animation->setStartValue(0.2f); - fade_animation->setEndValue(0.7f); - fade_animation->setEasingCurve(QEasingCurve::InOutQuad); - connect(fade_animation, &QPropertyAnimation::valueChanged, this, QOverload<>::of(&StreamCameraView::update)); } void StreamCameraView::parseQLog(std::shared_ptr qlog) { @@ -362,8 +356,8 @@ void StreamCameraView::parseQLog(std::shared_ptr qlog) { update(); } -void StreamCameraView::paintGL() { - CameraWidget::paintGL(); +void StreamCameraView::paintEvent(QPaintEvent *event) { + CameraWidget::paintEvent(event); QPainter p(this); bool scrubbing = false; @@ -376,7 +370,7 @@ void StreamCameraView::paintGL() { } if (can->isPaused()) { - p.setPen(QColor(200, 200, 200, static_cast(255 * fade_animation->currentValue().toFloat()))); + p.setPen(QColor(200, 200, 200, static_cast(255 * 0.7f))); p.setFont(QFont(font().family(), 16, QFont::Bold)); p.drawText(rect(), Qt::AlignCenter, tr("PAUSED")); } diff --git a/openpilot/tools/cabana/videowidget.h b/openpilot/tools/cabana/videowidget.h index e52e92ebd1..09f7a9931b 100644 --- a/openpilot/tools/cabana/videowidget.h +++ b/openpilot/tools/cabana/videowidget.h @@ -7,7 +7,6 @@ #include #include -#include #include #include #include @@ -36,8 +35,7 @@ class StreamCameraView : public CameraWidget { public: StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent = nullptr); - void paintGL() override; - void showPausedOverlay() { fade_animation->start(); } + void paintEvent(QPaintEvent *event) override; void parseQLog(std::shared_ptr qlog); private: @@ -47,7 +45,6 @@ private: void drawScrubThumbnail(QPainter &p); void drawTime(QPainter &p, const QRect &rect, double seconds); - QPropertyAnimation *fade_animation; std::map big_thumbnails; std::map thumbnails; double thumbnail_dispaly_time = -1; diff --git a/openpilot/tools/camerastream/README.md b/openpilot/tools/camerastream/README.md index 8b77fc5990..9671199297 100644 --- a/openpilot/tools/camerastream/README.md +++ b/openpilot/tools/camerastream/README.md @@ -44,18 +44,18 @@ To actually display the stream, run `watch3` in separate terminal: ## compressed_vipc.py usage ``` $ python3 compressed_vipc.py -h -usage: compressed_vipc.py [-h] [--nvidia] [--cams CAMS] [--silent] addr +usage: compressed_vipc.py [-h] [--cams CAMS] [--server SERVER] [--silent] addr Decode video streams and broadcast on VisionIPC positional arguments: - addr Address of comma three + addr Address of comma three options: - -h, --help show this help message and exit - --nvidia Use nvidia instead of ffmpeg - --cams CAMS Cameras to decode - --silent Suppress debug output + -h, --help show this help message and exit + --cams CAMS Cameras to decode + --server SERVER choose vipc server name + --silent Suppress debug output ``` diff --git a/openpilot/tools/camerastream/compressed_vipc.py b/openpilot/tools/camerastream/compressed_vipc.py index 35e9d3dab2..e85c88d40c 100755 --- a/openpilot/tools/camerastream/compressed_vipc.py +++ b/openpilot/tools/camerastream/compressed_vipc.py @@ -1,17 +1,16 @@ #!/usr/bin/env python3 -import av -import av.video.format import os -import sys import argparse -import numpy as np import multiprocessing import time import signal +from collections import deque import openpilot.cereal.messaging as messaging -from msgq.visionipc import VisionIpcServer, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcServer +from openpilot.tools.camerastream.ffmpeg_decoder import Decoder, FFmpegError V4L2_BUF_FLAG_KEYFRAME = 8 @@ -20,28 +19,17 @@ V4L2_BUF_FLAG_KEYFRAME = 8 # then run this "./compressed_vipc.py " ENCODE_SOCKETS = { - VisionStreamType.VISION_STREAM_ROAD: "roadEncodeData", - VisionStreamType.VISION_STREAM_DRIVER: "driverEncodeData", + VisionStreamType.VISION_STREAM_NARROW_ROAD: "narrowRoadEncodeData", + VisionStreamType.VISION_STREAM_CABIN: "cabinEncodeData", VisionStreamType.VISION_STREAM_WIDE_ROAD: "wideRoadEncodeData", } -def decoder(addr, vipc_server, vst, nvidia, W, H, debug=False): +def decoder(addr, vipc_server, vst, W, H, debug=False): sock_name = ENCODE_SOCKETS[vst] if debug: print(f"start decoder for {sock_name}, {W}x{H}") - if nvidia: - os.environ["NV_LOW_LATENCY"] = "3" # both bLowLatency and CUVID_PKT_ENDOFPICTURE - sys.path += os.environ["LD_LIBRARY_PATH"].split(":") - import PyNvCodec as nvc - - nvDec = nvc.PyNvDecoder(W, H, nvc.PixelFormat.NV12, nvc.CudaVideoCodec.HEVC, 0) - cc1 = nvc.ColorspaceConversionContext(nvc.ColorSpace.BT_709, nvc.ColorRange.JPEG) - conv_yuv = nvc.PySurfaceConverter(W, H, nvc.PixelFormat.NV12, nvc.PixelFormat.YUV420, 0) - nvDwn_yuv = nvc.PySurfaceDownloader(W, H, nvc.PixelFormat.YUV420, 0) - img_yuv = np.ndarray((H*W//2*3), dtype=np.uint8) - else: - codec = av.CodecContext.create("hevc", "r") + codec = Decoder("hevc") os.environ["ZMQ"] = "1" messaging.reset_context() @@ -50,13 +38,22 @@ def decoder(addr, vipc_server, vst, nvidia, W, H, debug=False): last_idx = -1 seen_iframe = False - time_q = [] + time_q = deque() + + def resync(): + nonlocal seen_iframe + codec.reset() + seen_iframe = False + time_q.clear() + while 1: msgs = messaging.drain_sock(sock, wait_for_one=True) for evt in msgs: evta = getattr(evt, evt.which()) - if debug and evta.idx.encodeId != 0 and evta.idx.encodeId != (last_idx+1): - print("DROP PACKET!") + if last_idx != -1 and evta.idx.encodeId != (last_idx + 1): + if debug: + print("DROP PACKET!") + resync() last_idx = evta.idx.encodeId if not seen_iframe and not (evta.idx.flags & V4L2_BUF_FLAG_KEYFRAME): if debug: @@ -67,48 +64,48 @@ def decoder(addr, vipc_server, vst, nvidia, W, H, debug=False): frame_latency = ((evta.idx.timestampEof/1e9) - (evta.idx.timestampSof/1e9))*1000 process_latency = ((evt.logMonoTime/1e9) - (evta.idx.timestampEof/1e9))*1000 - # put in header (first) + # put in header (first) — VPS/SPS/PPS only, no frame expected if not seen_iframe: - if nvidia: - nvDec.DecodeSurfaceFromPacket(np.frombuffer(evta.header, dtype=np.uint8)) - else: - codec.decode(av.packet.Packet(evta.header)) + try: + codec.decode(evta.header) + except FFmpegError as e: + if debug: + print(f"HEADER ERROR: {e}") + resync() + continue seen_iframe = True - if nvidia: - rawSurface = nvDec.DecodeSurfaceFromPacket(np.frombuffer(evta.data, dtype=np.uint8)) - if rawSurface.Empty(): - if debug: - print("DROP SURFACE") - continue - convSurface = conv_yuv.Execute(rawSurface, cc1) - nvDwn_yuv.DownloadSingleSurface(convSurface, img_yuv) - else: - frames = codec.decode(av.packet.Packet(evta.data)) - if len(frames) == 0: - if debug: - print("DROP SURFACE") - continue - assert len(frames) == 1 - img_yuv = frames[0].to_ndarray(format=av.video.format.VideoFormat('yuv420p')).flatten() - uv_offset = H*W - y = img_yuv[:uv_offset] - uv = img_yuv[uv_offset:].reshape(2, -1).ravel('F') - img_yuv = np.hstack((y, uv)) + try: + img_yuv = codec.decode(evta.data) + except FFmpegError as e: + if debug: + print(f"DECODE ERROR: {e}") + resync() + continue - vipc_server.send(vst, img_yuv.data, cnt, int(time_q[0]*1e9), int(time.monotonic()*1e9)) + if img_yuv is None: + if debug: + print("DROP SURFACE") + continue + + if codec.width != W or codec.height != H: + if debug: + print(f"DECODE ERROR: decoded frame is {codec.width}x{codec.height}, expected {W}x{H}") + resync() + continue + + frame_start_time = time_q.popleft() + vipc_server.send(vst, img_yuv.data, cnt, int(frame_start_time*1e9), int(time.monotonic()*1e9)) cnt += 1 - pc_latency = (time.monotonic()-time_q[0])*1000 - time_q = time_q[1:] + pc_latency = (time.monotonic()-frame_start_time)*1000 if debug: print(f"{len(msgs):2d} {evta.idx.encodeId:4d} {evt.logMonoTime/1e9:.3f} {evta.idx.timestampEof/1e6:.3f} \ roll {frame_latency:6.2f} ms latency {process_latency:6.2f} ms + {network_latency:6.2f} ms + {pc_latency:6.2f} ms \ = {process_latency+network_latency+pc_latency:6.2f} ms", len(evta.data), sock_name) - class CompressedVipc: - def __init__(self, addr, vision_streams, server_name, nvidia=False, debug=False): + def __init__(self, addr, vision_streams, server_name, debug=False): print("getting frame sizes") os.environ["ZMQ"] = "1" messaging.reset_context() @@ -127,7 +124,7 @@ class CompressedVipc: self.procs = [] for vst in vision_streams: ed = sm[ENCODE_SOCKETS[vst]] - p = multiprocessing.Process(target=decoder, args=(addr, self.vipc_server, vst, nvidia, ed.width, ed.height, debug)) + p = multiprocessing.Process(target=decoder, args=(addr, self.vipc_server, vst, ed.width, ed.height, debug)) p.start() self.procs.append(p) @@ -143,20 +140,19 @@ class CompressedVipc: if __name__ == "__main__": parser = argparse.ArgumentParser(description="Decode video streams and broadcast on VisionIPC") parser.add_argument("addr", help="Address of comma three") - parser.add_argument("--nvidia", action="store_true", help="Use nvidia instead of ffmpeg") parser.add_argument("--cams", default="0,1,2", help="Cameras to decode") parser.add_argument("--server", default="camerad", help="choose vipc server name") parser.add_argument("--silent", action="store_true", help="Suppress debug output") args = parser.parse_args() vision_streams = [ - VisionStreamType.VISION_STREAM_ROAD, - VisionStreamType.VISION_STREAM_DRIVER, + VisionStreamType.VISION_STREAM_NARROW_ROAD, + VisionStreamType.VISION_STREAM_CABIN, VisionStreamType.VISION_STREAM_WIDE_ROAD, ] vsts = [vision_streams[int(x)] for x in args.cams.split(",")] - cvipc = CompressedVipc(args.addr, vsts, args.server, args.nvidia, debug=(not args.silent)) + cvipc = CompressedVipc(args.addr, vsts, args.server, debug=(not args.silent)) # register exit handler signal.signal(signal.SIGINT, lambda sig, frame: cvipc.kill()) diff --git a/openpilot/tools/camerastream/ffmpeg_decoder.py b/openpilot/tools/camerastream/ffmpeg_decoder.py new file mode 100644 index 0000000000..13cdc12df1 --- /dev/null +++ b/openpilot/tools/camerastream/ffmpeg_decoder.py @@ -0,0 +1,261 @@ +import ctypes +import errno +import os + +import ffmpeg +import numpy as np + + +AV_INPUT_BUFFER_PADDING_SIZE = 64 +AV_LOG_QUIET = -8 +SWS_FAST_BILINEAR = 1 + + +class FFmpegError(RuntimeError): + pass + + +class AVPacket(ctypes.Structure): + # Public prefix of AVPacket. Only data and size are modified here; the packet + # remains non-refcounted and points at Decoder._packet_buffer. + _fields_ = [ + ("buf", ctypes.c_void_p), + ("pts", ctypes.c_int64), + ("dts", ctypes.c_int64), + ("data", ctypes.POINTER(ctypes.c_uint8)), + ("size", ctypes.c_int), + ] + + +class AVFrame(ctypes.Structure): + # Public prefix of AVFrame through format. Stable within a libavutil major + _fields_ = [ + ("data", ctypes.POINTER(ctypes.c_uint8) * 8), + ("linesize", ctypes.c_int * 8), + ("extended_data", ctypes.POINTER(ctypes.POINTER(ctypes.c_uint8))), + ("width", ctypes.c_int), + ("height", ctypes.c_int), + ("nb_samples", ctypes.c_int), + ("format", ctypes.c_int), + ] + + +def _bind(fn, restype, *argtypes): + fn.restype = restype + fn.argtypes = list(argtypes) + return fn + + +def _load_libraries(): + avutil = ctypes.CDLL(os.path.join(ffmpeg.LIB_DIR, "libavutil.so.59"), mode=ctypes.RTLD_GLOBAL) + avcodec = ctypes.CDLL(os.path.join(ffmpeg.LIB_DIR, "libavcodec.so.61"), mode=ctypes.RTLD_GLOBAL) + swscale = ctypes.CDLL(os.path.join(ffmpeg.LIB_DIR, "libswscale.so.8"), mode=ctypes.RTLD_GLOBAL) + + c_int, c_char_p, c_void_p, c_size_t = ctypes.c_int, ctypes.c_char_p, ctypes.c_void_p, ctypes.c_size_t + c_uint8_p = ctypes.POINTER(ctypes.c_uint8) + c_void_p_p = ctypes.POINTER(c_void_p) + + _bind(avutil.av_log_set_level, None, c_int) + _bind(avutil.av_opt_set, c_int, c_void_p, c_char_p, c_char_p, c_int) + _bind(avutil.av_strerror, c_int, c_int, c_char_p, c_size_t) + _bind(avutil.av_get_pix_fmt, c_int, c_char_p) + + _bind(avcodec.avcodec_find_decoder_by_name, c_void_p, c_char_p) + _bind(avcodec.avcodec_alloc_context3, c_void_p, c_void_p) + _bind(avcodec.avcodec_open2, c_int, c_void_p, c_void_p, c_void_p) + _bind(avcodec.avcodec_free_context, None, c_void_p_p) + _bind(avcodec.avcodec_flush_buffers, None, c_void_p) + _bind(avcodec.avcodec_send_packet, c_int, c_void_p, ctypes.POINTER(AVPacket)) + _bind(avcodec.avcodec_receive_frame, c_int, c_void_p, ctypes.POINTER(AVFrame)) + _bind(avcodec.av_packet_alloc, ctypes.POINTER(AVPacket)) + _bind(avcodec.av_packet_free, None, ctypes.POINTER(ctypes.POINTER(AVPacket))) + _bind(avcodec.av_frame_alloc, ctypes.POINTER(AVFrame)) + _bind(avcodec.av_frame_free, None, ctypes.POINTER(ctypes.POINTER(AVFrame))) + _bind(avcodec.av_frame_unref, None, ctypes.POINTER(AVFrame)) + + _bind(swscale.sws_getCachedContext, c_void_p, + c_void_p, c_int, c_int, c_int, c_int, c_int, c_int, c_int, c_void_p, c_void_p, c_void_p) + _bind(swscale.sws_scale, c_int, + c_void_p, ctypes.POINTER(c_uint8_p), ctypes.POINTER(c_int), + c_int, c_int, ctypes.POINTER(c_uint8_p), ctypes.POINTER(c_int)) + _bind(swscale.sws_freeContext, None, c_void_p) + + avutil.av_log_set_level(AV_LOG_QUIET) + return avutil, avcodec, swscale +_avutil, _avcodec, _swscale = _load_libraries() + +_DataArray = ctypes.POINTER(ctypes.c_uint8) * 4 +_LinesizeArray = ctypes.c_int * 4 + +AV_PIX_FMT_NV12 = _avutil.av_get_pix_fmt(b"nv12") +assert AV_PIX_FMT_NV12 >= 0 + + +def _error_string(code: int) -> str: + buf = ctypes.create_string_buffer(256) + if _avutil.av_strerror(code, buf, len(buf)) == 0: + return buf.value.decode(errors="replace") + return f"FFmpeg error {code}" + + +def _check(code: int, operation: str) -> None: + if code < 0: + raise FFmpegError(f"{operation}: {_error_string(code)}") + + +class Decoder: + def __init__(self, codec_name: str = "hevc"): + self.closed = True + self._sws_context = ctypes.c_void_p() + self._packet_buffer = bytearray() + self._packet_address = 0 + self._packet_data = None + self._output = np.empty(0, dtype=np.uint8) + self._dst_data = _DataArray() + self._dst_linesize = _LinesizeArray() + self.width = 0 + self.height = 0 + self._src_format = -1 + + codec = _avcodec.avcodec_find_decoder_by_name(codec_name.encode()) + if not codec: + raise FFmpegError(f"decoder not found: {codec_name}") + + self._context = ctypes.c_void_p(_avcodec.avcodec_alloc_context3(codec)) + if not self._context: + raise MemoryError("avcodec_alloc_context3 failed") + + self._packet = _avcodec.av_packet_alloc() + if not self._packet: + _avcodec.avcodec_free_context(ctypes.byref(self._context)) + raise MemoryError("av_packet_alloc failed") + + self._frame = _avcodec.av_frame_alloc() + if not self._frame: + _avcodec.av_packet_free(ctypes.byref(self._packet)) + _avcodec.avcodec_free_context(ctypes.byref(self._context)) + raise MemoryError("av_frame_alloc failed") + + try: + # Frame threading holds decoded frames to populate worker pipelines. + # Slice threads can reduce decode time without adding that frame queue; + # four was the latency minimum on the replay camera workload. + _check(_avutil.av_opt_set(self._context, b"threads", b"4", 0), "set decoder threads") + _check(_avutil.av_opt_set(self._context, b"thread_type", b"slice", 0), "set decoder thread type") + _check(_avutil.av_opt_set(self._context, b"flags", b"+low_delay", 0), "set low-delay mode") + _check(_avcodec.avcodec_open2(self._context, codec, None), "open decoder") + except Exception: + _avcodec.av_frame_free(ctypes.byref(self._frame)) + _avcodec.av_packet_free(ctypes.byref(self._packet)) + _avcodec.avcodec_free_context(ctypes.byref(self._context)) + raise + + self.closed = False + + def __enter__(self): + self._ensure_open() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def _ensure_open(self) -> None: + if self.closed: + raise RuntimeError("decoder is closed") + + def _prepare_packet(self, data) -> None: + size = len(data) + required = size + AV_INPUT_BUFFER_PADDING_SIZE + if len(self._packet_buffer) < required: + # Grow-only; the address stays valid until the next reallocation. + self._packet_buffer = bytearray(required) + self._packet_address = ctypes.addressof(ctypes.c_uint8.from_buffer(self._packet_buffer)) + self._packet_data = ctypes.cast(self._packet_address, ctypes.POINTER(ctypes.c_uint8)) + + self._packet_buffer[:size] = data + ctypes.memset(self._packet_address + size, 0, AV_INPUT_BUFFER_PADDING_SIZE) + self._packet.contents.data = self._packet_data + self._packet.contents.size = size + + def _prepare_output(self, frame: AVFrame) -> None: + width, height, src_format = frame.width, frame.height, frame.format + if width <= 0 or height <= 0 or width % 2 or height % 2: + raise FFmpegError(f"unsupported frame dimensions: {width}x{height}") + if (width, height, src_format) == (self.width, self.height, self._src_format): + return + + sws_context = _swscale.sws_getCachedContext( + self._sws_context, width, height, src_format, + width, height, AV_PIX_FMT_NV12, SWS_FAST_BILINEAR, + None, None, None, + ) + if not sws_context: + raise FFmpegError("sws_getCachedContext failed") + self._sws_context = ctypes.c_void_p(sws_context) + + self.width, self.height = width, height + self._src_format = src_format + self._output = np.empty(width * height * 3 // 2, dtype=np.uint8) + output_address = self._output.ctypes.data + self._dst_data = _DataArray( + ctypes.cast(output_address, ctypes.POINTER(ctypes.c_uint8)), + ctypes.cast(output_address + width * height, ctypes.POINTER(ctypes.c_uint8)), + None, + None, + ) + self._dst_linesize = _LinesizeArray(width, width, 0, 0) + + def _receive(self) -> np.ndarray | None: + """Return one NV12 frame, or None if the decoder needs more input. + + The returned buffer is reused on the next successful decode; callers must + use or copy it before calling decode again. + """ + result = _avcodec.avcodec_receive_frame(self._context, self._frame) + if result == -errno.EAGAIN: + return None + _check(result, "receive decoded frame") + + try: + frame = self._frame.contents + self._prepare_output(frame) + rows = _swscale.sws_scale( + self._sws_context, frame.data, frame.linesize, 0, frame.height, + self._dst_data, self._dst_linesize, + ) + if rows != frame.height: + raise FFmpegError(f"convert decoded frame: produced {rows} of {frame.height} rows") + return self._output + finally: + _avcodec.av_frame_unref(self._frame) + + def decode(self, data) -> np.ndarray | None: + self._ensure_open() + if len(data) == 0: + return None + + self._prepare_packet(data) + result = _avcodec.avcodec_send_packet(self._context, self._packet) + # The packet buffer is ours, not FFmpeg's. Clear the borrowed pointer so + # packet teardown can never attempt to release it. + self._packet.contents.data = None + self._packet.contents.size = 0 + _check(result, "send packet to decoder") + return self._receive() + + def reset(self) -> None: + """Discard decoder state after a stream discontinuity.""" + self._ensure_open() + _avcodec.avcodec_flush_buffers(self._context) + + def close(self) -> None: + if self.closed: + return + self.closed = True + _swscale.sws_freeContext(self._sws_context) + _avcodec.av_frame_free(ctypes.byref(self._frame)) + _avcodec.av_packet_free(ctypes.byref(self._packet)) + _avcodec.avcodec_free_context(ctypes.byref(self._context)) + + def __del__(self): + self.close() diff --git a/openpilot/tools/clip/run.py b/openpilot/tools/clip/run.py index fa4b203862..81145e2259 100755 --- a/openpilot/tools/clip/run.py +++ b/openpilot/tools/clip/run.py @@ -11,6 +11,7 @@ import itertools import numpy as np import tqdm from argparse import ArgumentParser +from collections.abc import Callable from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed @@ -21,7 +22,8 @@ from openpilot.tools.lib.framereader import FrameReader, ffprobe from openpilot.selfdrive.test.process_replay.migration import migrate_all from openpilot.common.prefix import OpenpilotPrefix from openpilot.common.utils import Timer -from msgq.visionipc import VisionIpcServer, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcServer FRAMERATE = 20 DEMO_ROUTE, DEMO_START, DEMO_END = '5beb9b58bd12b691/0000010a--a51155e496', 90, 105 @@ -138,7 +140,7 @@ def iter_segment_frames(camera_paths, start_time, end_time, fps=20, use_qcam=Fal frames_per_seg = fps * 60 start_frame, end_frame = int(start_time * fps), int(end_time * fps) current_seg: int = -1 - seg_frames: FrameReader | np.ndarray | None = None + get_frame: Callable[[int], np.ndarray] | None = None for global_idx in range(start_frame, end_frame): seg_idx, local_idx = global_idx // frames_per_seg, global_idx % frames_per_seg @@ -157,12 +159,12 @@ def iter_segment_frames(camera_paths, start_time, end_time, fps=20, use_qcam=Fal if result.returncode != 0: raise RuntimeError(f"ffmpeg failed: {result.stderr.decode()}") seg_frames = np.frombuffer(result.stdout, dtype=np.uint8).reshape(-1, w * h * 3 // 2) + get_frame = seg_frames.__getitem__ else: - seg_frames = FrameReader(path, pix_fmt="nv12") + get_frame = FrameReader(path, pix_fmt="nv12").get - assert seg_frames is not None - frame = seg_frames[local_idx] if use_qcam else seg_frames.get(local_idx) - yield global_idx, frame + assert get_frame is not None + yield global_idx, get_frame(local_idx) class FrameQueue: @@ -318,7 +320,7 @@ def clip(route: Route, output: str, start: int, end: int, headless: bool = True, wide_frame_queue = FrameQueue(ecamera_paths, start, end, fps=FRAMERATE) vipc = VisionIpcServer("camerad") - vipc.create_buffers(VisionStreamType.VISION_STREAM_ROAD, 4, frame_queue.frame_w, frame_queue.frame_h) + vipc.create_buffers(VisionStreamType.VISION_STREAM_NARROW_ROAD, 4, frame_queue.frame_w, frame_queue.frame_h) if wide_frame_queue: vipc.create_buffers(VisionStreamType.VISION_STREAM_WIDE_ROAD, 4, wide_frame_queue.frame_w, wide_frame_queue.frame_h) vipc.start_listener() @@ -337,7 +339,7 @@ def clip(route: Route, output: str, start: int, end: int, headless: bool = True, if frame_idx >= len(message_chunks): break _, frame_bytes = frame_queue.get() - vipc.send(VisionStreamType.VISION_STREAM_ROAD, frame_bytes, frame_idx, int(frame_idx * 5e7), int(frame_idx * 5e7)) + vipc.send(VisionStreamType.VISION_STREAM_NARROW_ROAD, frame_bytes, frame_idx, int(frame_idx * 5e7), int(frame_idx * 5e7)) if wide_frame_queue: _, wide_bytes = wide_frame_queue.get() vipc.send(VisionStreamType.VISION_STREAM_WIDE_ROAD, wide_bytes, frame_idx, int(frame_idx * 5e7), int(frame_idx * 5e7)) diff --git a/openpilot/tools/jotpluggler/SConscript b/openpilot/tools/jotpluggler/SConscript index d5ebaffb98..bfe6e90742 100644 --- a/openpilot/tools/jotpluggler/SConscript +++ b/openpilot/tools/jotpluggler/SConscript @@ -102,7 +102,7 @@ event_extractors = jot_env.Command("generated_event_extractors.h", [ ) libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a")] + \ - ffmpeg_libs + ["bz2", "zstd", "m", "pthread", "usb-1.0"] + ffmpeg_libs + ["zstd", "m", "pthread", "usb-1.0"] if arch == "Darwin": jot_env["FRAMEWORKS"] = ["OpenGL", "Cocoa", "IOKit", "CoreFoundation", "CoreVideo", "CoreMedia", "VideoToolbox"] else: diff --git a/openpilot/tools/jotpluggler/app.cc b/openpilot/tools/jotpluggler/app.cc index 01eec15367..e6ba696bae 100644 --- a/openpilot/tools/jotpluggler/app.cc +++ b/openpilot/tools/jotpluggler/app.cc @@ -3,6 +3,7 @@ #include "tools/jotpluggler/common.h" #include "tools/jotpluggler/internal.h" #include "tools/jotpluggler/map.h" +#include "tools/jotpluggler/thumbnail.h" #include "common/hardware/hw.h" #include "imgui_impl_glfw.h" @@ -1033,10 +1034,12 @@ bool apply_special_item_to_pane(WorkspaceTab *tab, TabUiState *tab_state, int pa if (pane.title == UNTITLED_PANE_TITLE || previous_kind != PaneKind::Plot) { pane.title = spec->label; } - } else { + } else if (spec->kind == PaneKind::Camera) { pane.title = spec->label; resize_tab_pane_state(tab_state, tab->panes.size()); tab_state->camera_panes[static_cast(pane_index)].fit_to_pane = true; + } else { + pane.title = spec->label; } tab_state->active_pane_index = pane_index; return true; @@ -1565,6 +1568,8 @@ void draw_pane_windows(AppSession *session, UiState *state) { } if (pane.kind == PaneKind::Map) { draw_map_pane(session, state, &pane, static_cast(i)); + } else if (pane.kind == PaneKind::Thumbnail) { + draw_thumbnail_pane(session, state); } else if (pane.kind == PaneKind::Camera) { draw_camera_pane(session, state, tab_state, static_cast(i), pane); } else { @@ -1847,6 +1852,7 @@ int run(const Options &options) { for (std::unique_ptr &feed : session.pane_camera_feeds) { feed = std::make_unique(); } + session.thumbnail_view = std::make_unique(); sync_camera_feeds(&session); if (session.async_route_loading) { @@ -1892,6 +1898,7 @@ int run(const Options &options) { for (std::unique_ptr &feed : session.pane_camera_feeds) { feed.reset(); } + session.thumbnail_view.reset(); return 0; } catch (const std::exception &err) { std::cerr << err.what() << "\n"; diff --git a/openpilot/tools/jotpluggler/app.h b/openpilot/tools/jotpluggler/app.h index a38f889687..9a6777ee90 100644 --- a/openpilot/tools/jotpluggler/app.h +++ b/openpilot/tools/jotpluggler/app.h @@ -81,12 +81,13 @@ struct Curve { enum class PaneKind : uint8_t { Plot, Map, + Thumbnail, Camera, }; enum class CameraViewKind : uint8_t { Road, - Driver, + Cabin, WideRoad, QRoad, }; @@ -141,6 +142,12 @@ struct CameraFeedIndex { std::vector entries; }; +struct ThumbnailFrame { + double timestamp = 0.0; + int segment = -1; + std::vector jpeg; +}; + enum class LogOrigin : uint8_t { Log, OperatingSystem, @@ -315,9 +322,10 @@ struct RouteData { std::vector roots; std::vector can_messages; CameraFeedIndex road_camera; - CameraFeedIndex driver_camera; + CameraFeedIndex cabin_camera; CameraFeedIndex wide_road_camera; CameraFeedIndex qroad_camera; + std::vector thumbnails; GpsTrace gps_trace; std::vector logs; std::vector timeline; @@ -445,6 +453,7 @@ bool icon_menu_item(const char *glyph, class AsyncRouteLoader; class CameraFeedView; +class ThumbnailView; class StreamPoller; class MapDataManager; @@ -486,6 +495,7 @@ struct AppSession { std::unique_ptr route_loader; std::unique_ptr stream_poller; std::array, 4> pane_camera_feeds; + std::unique_ptr thumbnail_view; std::unique_ptr map_data; bool async_route_loading = false; double next_stream_custom_refresh_time = 0.0; @@ -885,3 +895,20 @@ private: struct Impl; std::unique_ptr impl_; }; + +class ThumbnailView { +public: + ThumbnailView(); + ~ThumbnailView(); + + ThumbnailView(const ThumbnailView &) = delete; + ThumbnailView &operator=(const ThumbnailView &) = delete; + + void setThumbnails(const std::vector &thumbnails); + void update(double tracker_time); + void drawSized(ImVec2 size, bool loading); + +private: + struct Impl; + std::unique_ptr impl_; +}; diff --git a/openpilot/tools/jotpluggler/common.cc b/openpilot/tools/jotpluggler/common.cc index 8f696657bd..50f5fc0b95 100644 --- a/openpilot/tools/jotpluggler/common.cc +++ b/openpilot/tools/jotpluggler/common.cc @@ -46,11 +46,11 @@ const char *special_item_label(std::string_view item_id) { } bool pane_kind_is_special(PaneKind kind) { - return kind == PaneKind::Map || kind == PaneKind::Camera; + return kind == PaneKind::Map || kind == PaneKind::Thumbnail || kind == PaneKind::Camera; } bool is_default_special_title(std::string_view title) { - if (title == "Map") return true; + if (title == "Map" || title == "Thumbnail") return true; return std::any_of(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) { return title == spec.label; }); diff --git a/openpilot/tools/jotpluggler/common.h b/openpilot/tools/jotpluggler/common.h index 14db83fd33..7149bbe54d 100644 --- a/openpilot/tools/jotpluggler/common.h +++ b/openpilot/tools/jotpluggler/common.h @@ -23,13 +23,14 @@ struct SpecialItemSpec { inline constexpr std::array kCameraViewSpecs = {{ {CameraViewKind::Road, "Road Camera", "road", "road", "camera_road", &RouteData::road_camera}, - {CameraViewKind::Driver, "Driver Camera", "driver", "driver", "camera_driver", &RouteData::driver_camera}, + {CameraViewKind::Cabin, "Cabin Camera", "driver", "driver", "camera_driver", &RouteData::cabin_camera}, {CameraViewKind::WideRoad, "Wide Road Camera", "wide", "wide_road", "camera_wide_road", &RouteData::wide_road_camera}, {CameraViewKind::QRoad, "qRoad Camera", "qroad", "qroad", "camera_qroad", &RouteData::qroad_camera}, }}; -inline constexpr std::array kSpecialItemSpecs = {{ +inline constexpr std::array kSpecialItemSpecs = {{ {"map", "Map", PaneKind::Map, CameraViewKind::Road}, + {"thumbnail", "Thumbnail", PaneKind::Thumbnail, CameraViewKind::Road}, {kCameraViewSpecs[0].special_item_id, kCameraViewSpecs[0].label, PaneKind::Camera, kCameraViewSpecs[0].view}, {kCameraViewSpecs[1].special_item_id, kCameraViewSpecs[1].label, PaneKind::Camera, kCameraViewSpecs[1].view}, {kCameraViewSpecs[2].special_item_id, kCameraViewSpecs[2].label, PaneKind::Camera, kCameraViewSpecs[2].view}, diff --git a/openpilot/tools/jotpluggler/generate_event_extractors.py b/openpilot/tools/jotpluggler/generate_event_extractors.py index be9bd49003..a424ebc237 100644 --- a/openpilot/tools/jotpluggler/generate_event_extractors.py +++ b/openpilot/tools/jotpluggler/generate_event_extractors.py @@ -62,6 +62,8 @@ class Generator: def __init__(self, event_schema): self.event_schema = event_schema self.fixed_paths = [] + self.event_base_slots = {} + self.static_enums = [] self.tmp_index = 0 self.lines = [] self.emits_memo = {} @@ -103,9 +105,13 @@ class Generator: self.emit(indent, f"append_dynamic_scalar_point({path_expr}, tm, {double_expr}, series);") else: slot = self.add_fixed_path(path) - if kind == "Enum": - self.emit_enum_capture(indent, cxx_string(path), enum_names(schema)) - self.emit(indent, f"append_fixed_scalar_point(&series->fixed_series[{slot}], tm, {double_expr});") + names = enum_names(schema) if kind == "Enum" else [] + if names: + enum_index = len(self.static_enums) + self.static_enums.append(names) + self.emit(indent, f"append_fixed_enum_point({slot}, {enum_index}, tm, {double_expr}, series);") + else: + self.emit(indent, f"append_fixed_scalar_point(&series->fixed_series[{slot}], tm, {double_expr});") return if type_kind == "struct": @@ -144,9 +150,13 @@ class Generator: self.emit(indent, f"if ({' && '.join(conditions)}) {{") indent += 2 - value_var = self.tmp("value") - self.emit(indent, f"const auto {value_var} = {get_call};") - self.emit_node(indent, type_kind, type_proto, value_schema, value_var, field_path, field_path_expr, dynamic_path) + # Scalar getters are only consumed once. Emitting them directly avoids + # thousands of single-use locals in the generated extractor. + value_expr = get_call + if kind is None: + value_expr = self.tmp("value") + self.emit(indent, f"const auto {value_expr} = {get_call};") + self.emit_node(indent, type_kind, type_proto, value_schema, value_expr, field_path, field_path_expr, dynamic_path) if conditions: indent -= 2 @@ -247,31 +257,43 @@ class Generator: self.emit(indent + 2, "}") self.emit(indent, "}") self.emit(indent, "if (skip_raw_can) {") - self.emit(indent + 2, "return true;") + self.emit(indent + 2, "return;") self.emit(indent, "}") - def emit_event_case(self, field_name): + def emit_event_reader(self, field_name): field = self.event_schema.fields[field_name] proto = field.proto type_kind = field_type(field) type_proto = field_type_proto(field) kind = scalar_kind(type_proto) schema = field.schema if kind == "Enum" or type_kind in NESTED_TYPE_KINDS else None - self.emit(4, f"case static_cast({proto.discriminantValue}): {{") valid_slot = self.add_fixed_path(f"/{field_name}/valid") - mono_slot = self.add_fixed_path(f"/{field_name}/logMonoTime") - seconds_slot = self.add_fixed_path(f"/{field_name}/t") - self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{valid_slot}], tm, event.getValid() ? 1.0 : 0.0);") - self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{mono_slot}], tm, static_cast(event.getLogMonoTime()));") - self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{seconds_slot}], tm, tm);") - if field_name in {"can", "sendcan"}: - self.emit_can_special(6, field_name) - if self.node_emits(type_kind, type_proto, schema): + self.add_fixed_path(f"/{field_name}/logMonoTime") + self.add_fixed_path(f"/{field_name}/t") + self.event_base_slots[proto.discriminantValue] = valid_slot + + emits_payload = self.node_emits(type_kind, type_proto, schema) + if field_name not in {"can", "sendcan"} and not emits_payload: + return None + + reader_name = f"append_event_{proto.discriminantValue}" + needs_can = field_name in {"can", "sendcan"} + header_index = len(self.lines) + self.emit(0, "") + if needs_can: + self.emit_can_special(2, field_name) + if emits_payload: payload = self.tmp("payload") - self.emit(6, f"const auto {payload} = event.{accessor('get', field_name)}();") - self.emit_node(6, type_kind, type_proto, schema, payload, f"/{field_name}", None, False) - self.emit(6, "return true;") - self.emit(4, "}") + self.emit(2, f"const auto {payload} = event.{accessor('get', field_name)}();") + self.emit_node(2, type_kind, type_proto, schema, payload, f"/{field_name}", None, False) + self.emit(0, "}") + self.emit(0, "") + if needs_can: + signature = "const cereal::Event::Reader &event, const dbc::Database *can_dbc, bool skip_raw_can, double tm, SeriesAccumulator *series" + else: + signature = "const cereal::Event::Reader &event, double tm, SeriesAccumulator *series" + self.lines[header_index] = f"__attribute__((noinline)) void {reader_name}({signature}) {{" + return reader_name, needs_can def generate(self): self.lines = [] @@ -298,11 +320,66 @@ class Generator: self.emit(2, "}") self.emit(0, "}") self.emit(0, "") + self.emit(0, "__attribute__((noinline)) void append_fixed_enum_point(size_t series_slot, size_t enum_index, double tm, double value, SeriesAccumulator *series);") # noqa: E501 + self.emit(0, "") + + self.emit(0, "// Keep each event payload behind its own optimizer boundary. Combining the") + self.emit(0, "// whole schema into one function creates much more code and runs slower.") + event_readers = {} + for field_name in self.event_schema.union_fields: + event_readers[field_name] = self.emit_event_reader(field_name) + + self.emit(0, "static const std::initializer_list static_event_enum_names[] = {") + for names in self.static_enums: + names_expr = "{" + ", ".join(cxx_string(name) for name in names) + "}" + self.emit(2, f"{names_expr},") + self.emit(0, "};") + self.emit(0, "") + self.emit(0, "__attribute__((noinline)) void append_fixed_enum_point(size_t series_slot, size_t enum_index, double tm, double value, SeriesAccumulator *series) {") # noqa: E501 + self.emit(2, "RouteSeries *fixed_series = &series->fixed_series[series_slot];") + self.emit(2, "capture_static_enum_info(fixed_series->path, static_event_enum_names[enum_index], series);") + self.emit(2, "fixed_series->times.push_back(tm);") + self.emit(2, "fixed_series->values.push_back(value);") + self.emit(0, "}") + self.emit(0, "") + self.emit(0, "bool append_event_static_reader(cereal::Event::Which which, const cereal::Event::Reader &event, const dbc::Database *can_dbc, bool skip_raw_can, double time_offset, SeriesAccumulator *series) {") # noqa: E501 - self.emit(2, "const double tm = static_cast(event.getLogMonoTime()) / 1.0e9 - time_offset;") + self.emit(2, "const auto log_mono_time = event.getLogMonoTime();") + self.emit(2, "const double tm = static_cast(log_mono_time) / 1.0e9 - time_offset;") + + invalid_slot = "static_cast(-1)" + max_discriminant = max(self.event_base_slots) + base_slots = [self.event_base_slots.get(i, invalid_slot) for i in range(max_discriminant + 1)] + self.emit(2, "static constexpr size_t event_base_slots[] = {") + for slot in base_slots: + self.emit(4, f"{slot},") + self.emit(2, "};") + self.emit(2, "const size_t event_index = static_cast(which);") + self.emit(2, "if (event_index >= sizeof(event_base_slots) / sizeof(event_base_slots[0])) {") + self.emit(4, "return false;") + self.emit(2, "}") + self.emit(2, "const size_t base_slot = event_base_slots[event_index];") + self.emit(2, f"if (base_slot == {invalid_slot}) {{") + self.emit(4, "return false;") + self.emit(2, "}") + self.emit(2, "RouteSeries *base_series = &series->fixed_series[base_slot];") + self.emit(2, "base_series[0].times.push_back(tm);") + self.emit(2, "base_series[0].values.push_back(event.getValid() ? 1.0 : 0.0);") + self.emit(2, "base_series[1].times.push_back(tm);") + self.emit(2, "base_series[1].values.push_back(static_cast(log_mono_time));") + self.emit(2, "base_series[2].times.push_back(tm);") + self.emit(2, "base_series[2].values.push_back(tm);") self.emit(2, "switch (which) {") for field_name in self.event_schema.union_fields: - self.emit_event_case(field_name) + field = self.event_schema.fields[field_name] + self.emit(4, f"case static_cast({field.proto.discriminantValue}):") + reader = event_readers[field_name] + if reader is not None: + if reader[1]: + self.emit(6, f"{reader[0]}(event, can_dbc, skip_raw_can, tm, series);") + else: + self.emit(6, f"{reader[0]}(event, tm, series);") + self.emit(6, "return true;") self.emit(4, "default:") self.emit(6, "return false;") self.emit(2, "}") diff --git a/openpilot/tools/jotpluggler/layout_io.cc b/openpilot/tools/jotpluggler/layout_io.cc index 5c70f7a42a..24e3a4b51f 100644 --- a/openpilot/tools/jotpluggler/layout_io.cc +++ b/openpilot/tools/jotpluggler/layout_io.cc @@ -62,6 +62,8 @@ json11::Json workspace_node_to_json(const WorkspaceNode &node, const WorkspaceTa }; if (pane.kind == PaneKind::Map) { obj["kind"] = "map"; + } else if (pane.kind == PaneKind::Thumbnail) { + obj["kind"] = "thumbnail"; } else if (pane.kind == PaneKind::Camera) { obj["kind"] = "camera"; obj["camera_view"] = camera_view_spec(pane.camera_view).layout_name; diff --git a/openpilot/tools/jotpluggler/layouts/camera-timings.json b/openpilot/tools/jotpluggler/layouts/camera-timings.json index 64decf15d3..643a7bc43a 100644 --- a/openpilot/tools/jotpluggler/layouts/camera-timings.json +++ b/openpilot/tools/jotpluggler/layouts/camera-timings.json @@ -1 +1 @@ -{"current_tab_index":0,"tabs":[{"name":"SOF / EOF (encodeIdx)","root":{"split":"vertical","sizes":[0.500885,0.499115],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverEncodeIdx/timestampSof","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/roadEncodeIdx/timestampSof","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadEncodeIdx/timestampSof","color":"#1ac938","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}},{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverEncodeIdx/timestampEof","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/roadEncodeIdx/timestampEof","color":"#9467bd","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadEncodeIdx/timestampEof","color":"#17becf","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}}]}},{"name":"model timings","root":{"split":"vertical","sizes":[0.5,0.5],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.016865,"bottom":0.015143},"curves":[{"name":"/modelV2/modelExecutionTime","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.1,"bottom":-0.1},"curves":[{"name":"/modelV2/frameDropPerc","color":"#f14cc1"}]}]}},{"name":"sensor info","root":{"split":"vertical","sizes":[1.0],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.1,"bottom":-0.1},"curves":[{"name":"/driverCameraState/sensor","color":"#bcbd22"},{"name":"/roadCameraState/sensor","color":"#1f77b4"},{"name":"/wideRoadCameraState/sensor","color":"#d62728"}]}]}},{"name":"SOF / EOF (cameraState)","root":{"split":"vertical","sizes":[0.500885,0.499115],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverCameraState/timestampSof","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/roadCameraState/timestampSof","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadCameraState/timestampSof","color":"#1ac938","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}},{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverCameraState/timestampEof","color":"#ff7f0e","transform":"derivative","derivative_dt":1.0},{"name":"/roadCameraState/timestampEof","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadCameraState/timestampEof","color":"#9467bd","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}}]}}]} +{"current_tab_index":0,"tabs":[{"name":"SOF / EOF (encodeIdx)","root":{"split":"vertical","sizes":[0.500885,0.499115],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/cabinEncodeIdx/timestampSof","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/narrowRoadEncodeIdx/timestampSof","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadEncodeIdx/timestampSof","color":"#1ac938","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}},{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/cabinEncodeIdx/timestampEof","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/narrowRoadEncodeIdx/timestampEof","color":"#9467bd","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadEncodeIdx/timestampEof","color":"#17becf","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}}]}},{"name":"model timings","root":{"split":"vertical","sizes":[0.5,0.5],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.016865,"bottom":0.015143},"curves":[{"name":"/modelV2/modelExecutionTime","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.1,"bottom":-0.1},"curves":[{"name":"/modelV2/frameDropPerc","color":"#f14cc1"}]}]}},{"name":"sensor info","root":{"split":"vertical","sizes":[1.0],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.1,"bottom":-0.1},"curves":[{"name":"/cabinCameraState/sensor","color":"#bcbd22"},{"name":"/narrowRoadCameraState/sensor","color":"#1f77b4"},{"name":"/wideRoadCameraState/sensor","color":"#d62728"}]}]}},{"name":"SOF / EOF (cameraState)","root":{"split":"vertical","sizes":[0.500885,0.499115],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/cabinCameraState/timestampSof","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/narrowRoadCameraState/timestampSof","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadCameraState/timestampSof","color":"#1ac938","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}},{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/cabinCameraState/timestampEof","color":"#ff7f0e","transform":"derivative","derivative_dt":1.0},{"name":"/narrowRoadCameraState/timestampEof","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadCameraState/timestampEof","color":"#9467bd","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}}]}}]} diff --git a/openpilot/tools/jotpluggler/layouts/cameras-and-map.json b/openpilot/tools/jotpluggler/layouts/cameras-and-map.json index 68c590f7bc..7e120fcd5c 100644 --- a/openpilot/tools/jotpluggler/layouts/cameras-and-map.json +++ b/openpilot/tools/jotpluggler/layouts/cameras-and-map.json @@ -1 +1 @@ -{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"curves": [], "kind": "map", "title": "Map"}, {"camera_view": "road", "curves": [], "kind": "camera", "title": "Road Camera"}], "sizes": [0.5, 0.5], "split": "horizontal"}, {"children": [{"camera_view": "wide_road", "curves": [], "kind": "camera", "title": "Wide Road Camera"}, {"camera_view": "driver", "curves": [], "kind": "camera", "title": "Driver Camera"}], "sizes": [0.5, 0.5], "split": "horizontal"}], "sizes": [0.5, 0.5], "split": "vertical"}}]} +{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"curves": [], "kind": "map", "title": "Map"}, {"camera_view": "road", "curves": [], "kind": "camera", "title": "Road Camera"}], "sizes": [0.5, 0.5], "split": "horizontal"}, {"children": [{"camera_view": "wide_road", "curves": [], "kind": "camera", "title": "Wide Road Camera"}, {"camera_view": "driver", "curves": [], "kind": "camera", "title": "Cabin Camera"}], "sizes": [0.5, 0.5], "split": "horizontal"}], "sizes": [0.5, 0.5], "split": "vertical"}}]} diff --git a/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json b/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json index 07a5c2fd6e..3197f40ad8 100644 --- a/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json +++ b/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json @@ -1 +1 @@ -{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"camera_view": "driver", "curves": [], "kind": "camera", "title": "Driver Camera"}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/alertLevel"}], "title": "..."}], "sizes": [0.5, 0.5], "split": "vertical"}, {"children": [{"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/activePolicy"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/faceDetected"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/eye"}, {"color": "#dc5234", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/phone"}, {"color": "#43a047", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/pose"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/awarenessPercent"}], "title": "..."}], "sizes": [0.25, 0.25, 0.25, 0.25], "split": "vertical"}], "sizes": [0.5, 0.5], "split": "horizontal"}}]} +{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"camera_view": "driver", "curves": [], "kind": "camera", "title": "Cabin Camera"}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/alertLevel"}], "title": "..."}], "sizes": [0.5, 0.5], "split": "vertical"}, {"children": [{"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/activePolicy"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/faceDetected"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/eye"}, {"color": "#dc5234", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/phone"}, {"color": "#43a047", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/pose"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/awarenessPercent"}], "title": "..."}], "sizes": [0.25, 0.25, 0.25, 0.25], "split": "vertical"}], "sizes": [0.5, 0.5], "split": "horizontal"}}]} diff --git a/openpilot/tools/jotpluggler/layouts/locationd_debug.json b/openpilot/tools/jotpluggler/layouts/locationd_debug.json index 0541427bc1..53112f31d6 100644 --- a/openpilot/tools/jotpluggler/layouts/locationd_debug.json +++ b/openpilot/tools/jotpluggler/layouts/locationd_debug.json @@ -1 +1 @@ -{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.166588,0.167062,0.166113,0.166588,0.167062,0.166588],"children":[{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1.025,"bottom":-0.025},"curves":[{"name":"/livePose/inputsOK","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":14.542814,"bottom":-5.586039},"curves":[{"name":"/accelerometer/acceleration/v/0","color":"#f14cc1"},{"name":"/accelerometer/acceleration/v/1","color":"#9467bd"},{"name":"/accelerometer/acceleration/v/2","color":"#17becf"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":0.988911,"bottom":-0.745939},"curves":[{"name":"/gyroscope/gyroUncalibrated/v/0","color":"#d62728"},{"name":"/gyroscope/gyroUncalibrated/v/1","color":"#1ac938"},{"name":"/gyroscope/gyroUncalibrated/v/2","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1.025,"bottom":-0.025},"curves":[{"name":"/accelerometer/__valid","color":"#17becf"},{"name":"/gyroscope/__valid","color":"#bcbd22"},{"name":"/carState/__valid","color":"#f14cc1"},{"name":"/liveCalibration/__valid","color":"#1ac938"},{"name":"/cameraOdometry/__valid","color":"#9467bd"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1000000000.292252,"bottom":999999999.735447},"curves":[{"name":"/gyroscope/__logMonoTime","color":"#1f77b4","transform":"derivative"},{"name":"/accelerometer/__logMonoTime","color":"#d62728","transform":"derivative"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":20790107743.93223,"bottom":-529653831.495853},"curves":[{"name":"/accelerometer/timestamp","color":"#bcbd22","transform":"derivative"},{"name":"/gyroscope/timestamp","color":"#1f77b4","transform":"derivative"}]}]}}]} +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.166588,0.167062,0.166113,0.166588,0.167062,0.166588],"children":[{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1.025,"bottom":-0.025},"curves":[{"name":"/deviceMotion/inputsOK","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":14.542814,"bottom":-5.586039},"curves":[{"name":"/accelerometer/acceleration/v/0","color":"#f14cc1"},{"name":"/accelerometer/acceleration/v/1","color":"#9467bd"},{"name":"/accelerometer/acceleration/v/2","color":"#17becf"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":0.988911,"bottom":-0.745939},"curves":[{"name":"/gyroscope/gyroUncalibrated/v/0","color":"#d62728"},{"name":"/gyroscope/gyroUncalibrated/v/1","color":"#1ac938"},{"name":"/gyroscope/gyroUncalibrated/v/2","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1.025,"bottom":-0.025},"curves":[{"name":"/accelerometer/__valid","color":"#17becf"},{"name":"/gyroscope/__valid","color":"#bcbd22"},{"name":"/carState/__valid","color":"#f14cc1"},{"name":"/extrinsicsCalibration/__valid","color":"#1ac938"},{"name":"/cameraOdometry/__valid","color":"#9467bd"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1000000000.292252,"bottom":999999999.735447},"curves":[{"name":"/gyroscope/__logMonoTime","color":"#1f77b4","transform":"derivative"},{"name":"/accelerometer/__logMonoTime","color":"#d62728","transform":"derivative"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":20790107743.93223,"bottom":-529653831.495853},"curves":[{"name":"/accelerometer/timestamp","color":"#bcbd22","transform":"derivative"},{"name":"/gyroscope/timestamp","color":"#1f77b4","transform":"derivative"}]}]}}]} diff --git a/openpilot/tools/jotpluggler/layouts/max-torque-debug.json b/openpilot/tools/jotpluggler/layouts/max-torque-debug.json index 3a87fb3217..b587b695a9 100644 --- a/openpilot/tools/jotpluggler/layouts/max-torque-debug.json +++ b/openpilot/tools/jotpluggler/layouts/max-torque-debug.json @@ -1 +1 @@ -{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.249724,0.250829,0.249724,0.249724],"children":[{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":6.050533,"bottom":-7.599037},"curves":[{"name":"Actual lateral accel (roll compensated)","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"Desired lateral accel (roll compensated)","color":"#ff7f0e","custom_python":{"linked_source":"/controlsState/desiredCurvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":5.384416,"bottom":-7.503945},"curves":[{"name":"roll compensated lateral acceleration","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/liveParameters/roll","/carState/steeringPressed","/carControl/latActive"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3, v4):\n if (v3 == 0 and v4 == 1):\n return (value * v1 ** 2) - (v2 * 9.81)\n return 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i], v4[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":1.05,"bottom":-1.05},"curves":[{"name":"/carState/steeringPressed","color":"#0097ff"},{"name":"/carOutput/actuatorsOutput/torque","color":"#d62728"}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":80.762969,"bottom":-2.181837},"curves":[{"name":"/carState/vEgo","color":"#f14cc1","transform":"scale","scale":2.23694,"offset":0.0}]}]}}]} +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.249724,0.250829,0.249724,0.249724],"children":[{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":6.050533,"bottom":-7.599037},"curves":[{"name":"Actual lateral accel (roll compensated)","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/vehicleParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"Desired lateral accel (roll compensated)","color":"#ff7f0e","custom_python":{"linked_source":"/controlsState/desiredCurvature","additional_sources":["/carState/vEgo","/vehicleParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":5.384416,"bottom":-7.503945},"curves":[{"name":"roll compensated lateral acceleration","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/vehicleParameters/roll","/carState/steeringPressed","/carControl/latActive"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3, v4):\n if (v3 == 0 and v4 == 1):\n return (value * v1 ** 2) - (v2 * 9.81)\n return 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i], v4[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":1.05,"bottom":-1.05},"curves":[{"name":"/carState/steeringPressed","color":"#0097ff"},{"name":"/carOutput/actuatorsOutput/torque","color":"#d62728"}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":80.762969,"bottom":-2.181837},"curves":[{"name":"/carState/vEgo","color":"#f14cc1","transform":"scale","scale":2.23694,"offset":0.0}]}]}}]} diff --git a/openpilot/tools/jotpluggler/layouts/torque-controller.json b/openpilot/tools/jotpluggler/layouts/torque-controller.json index 7e269e59e6..a794c725d6 100644 --- a/openpilot/tools/jotpluggler/layouts/torque-controller.json +++ b/openpilot/tools/jotpluggler/layouts/torque-controller.json @@ -1 +1 @@ -{"current_tab_index":0,"tabs":[{"name":"Lateral Plan Conformance","root":{"split":"vertical","sizes":[0.250949,0.249051,0.250949,0.249051],"children":[{"title":"desired vs actual lateral acceleration (closer means better conformance to plan)","range":{"left":0.000194,"right":1138.891674,"top":1.858161,"bottom":-1.823407},"curves":[{"name":"/controlsState/lateralControlState/torqueState/actualLateralAccel","color":"#1f77b4"},{"name":"/controlsState/lateralControlState/torqueState/desiredLateralAccel","color":"#d62728"}]},{"title":"desired vs actual lateral acceleration, road-roll factored out (closer means better conformance to plan)","range":{"left":0.000194,"right":1138.891674,"top":2.749816,"bottom":-3.723091},"curves":[{"name":"Actual lateral accel (roll compensated)","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"Desired lateral accel (roll compensated)","color":"#ff7f0e","custom_python":{"linked_source":"/controlsState/desiredCurvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"controller feed-forward vs actuator output (closer means controller prediction is more accurate)","range":{"left":0.000194,"right":1138.891674,"top":1.978032,"bottom":-1.570956},"curves":[{"name":"/carOutput/actuatorsOutput/torque","color":"#9467bd","transform":"scale","scale":-1.0,"offset":0.0},{"name":"/controlsState/lateralControlState/torqueState/f","color":"#1f77b4"},{"name":"/carState/steeringPressed","color":"#ff000f"}]},{"title":"vehicle speed","range":{"left":0.000194,"right":1138.891674,"top":105.981304,"bottom":-2.709314},"curves":[{"name":"carState.vEgo mph","color":"#d62728","custom_python":{"linked_source":"/carState/vEgo","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value * 2.23694\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"carState.vEgo kmh","color":"#1ac938","custom_python":{"linked_source":"/carState/vEgo","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value * 3.6\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"/carState/vEgo","color":"#ff7f0e"}]}]}},{"name":"Vehicle Dynamics","root":{"split":"vertical","sizes":[0.334282,0.331437,0.334282],"children":[{"title":"configured-initial vs online-learned steerRatio, set configured value to match learned","range":{"left":0.0,"right":1138.816328,"top":19.665784,"bottom":19.359553},"curves":[{"name":"/carParams/steerRatio","color":"#1f77b4"},{"name":"/liveParameters/steerRatio","color":"#1ac938"}]},{"title":"configured-initial vs online-learned tireStiffnessRatio, set configured value to match learned","range":{"left":0.0,"right":1138.816328,"top":1.11221,"bottom":0.995631},"curves":[{"name":"/carParams/tireStiffnessFactor","color":"#d62728"},{"name":"/liveParameters/stiffnessFactor","color":"#ff7f0e"}]},{"title":"live steering angle offsets for straight-ahead driving, large values here may indicate alignment problems","range":{"left":0.0,"right":1138.816328,"top":-1.081041,"bottom":-4.494133},"curves":[{"name":"/liveParameters/angleOffsetAverageDeg","color":"#f14cc1"},{"name":"/liveParameters/angleOffsetDeg","color":"#9467bd"}]}]}},{"name":"Actuator Performance","root":{"split":"vertical","sizes":[0.333333,0.333333,0.333333],"children":[{"title":"offline-calculated vs online-learned lateral accel scaling factor, accel obtained from 100% actuator output","range":{"left":0.0,"right":1138.920072,"top":1.21611,"bottom":0.539474},"curves":[{"name":"/liveTorqueParameters/latAccelFactorFiltered","color":"#1f77b4"},{"name":"/liveTorqueParameters/latAccelFactorRaw","color":"#d62728"},{"name":"/carParams/lateralTuning/torque/latAccelFactor","color":"#1c9222"}]},{"title":"learned lateral accel offset, vehicle-specific compensation to obtain true zero lateral accel","range":{"left":0.0,"right":1138.920072,"top":-0.304367,"bottom":-0.418688},"curves":[{"name":"/liveTorqueParameters/latAccelOffsetFiltered","color":"#1ac938"},{"name":"/liveTorqueParameters/latAccelOffsetRaw","color":"#ff7f0e"}]},{"title":"offline-calculated vs online-learned EPS friction factor, necessary to start moving the steering wheel","range":{"left":0.0,"right":1138.920072,"top":0.226389,"bottom":0.15805},"curves":[{"name":"/liveTorqueParameters/frictionCoefficientFiltered","color":"#f14cc1"},{"name":"/liveTorqueParameters/frictionCoefficientRaw","color":"#9467bd"},{"name":"/carParams/lateralTuning/torque/friction","color":"#1c9222"}]}]}},{"name":"Actuator Delay","root":{"split":"vertical","sizes":[0.30441,0.358464,0.337127],"children":[{"title":"actuator lag learning state, 0 = learning, 1 = learned/applying, 2 = invalid","range":{"left":0.0,"right":1138.749979,"top":1.025,"bottom":-0.025},"curves":[{"name":"/liveDelay/status","color":"#ff7f0e"}]},{"title":"offline default vs online estimated steering actuator lag","range":{"left":0.0,"right":1138.749979,"top":0.419648,"bottom":0.318362},"curves":[{"name":"/liveDelay/lateralDelay","color":"#1f77b4"},{"name":"/liveDelay/lateralDelayEstimate","color":"#d62728"},{"name":"opendbc default steering lag","color":"#1ac938","custom_python":{"linked_source":"/carParams/steerActuatorDelay","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value + 0.2\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"online estimated steering actuator lag, standard deviation","range":{"left":0.0,"right":1138.749979,"top":0.06732,"bottom":-0.001642},"curves":[{"name":"/liveDelay/lateralDelayEstimateStd","color":"#f14cc1"}]}]}},{"name":"Controls Performance","root":{"split":"vertical","sizes":[0.265655,0.251898,0.245731,0.236717],"children":[{"title":"rate-of-change limits on steering actuator (blue = original, green = rate-limited before CAN output)","range":{"left":0.000194,"right":1138.891921,"top":1.05,"bottom":-1.05},"curves":[{"name":"/carControl/actuators/torque","color":"#0c00f2"},{"name":"/carOutput/actuatorsOutput/torque","color":"#2cd63a"}]},{"title":"controller feed-forward vs actuator output (closer means controller prediction is more accurate)","range":{"left":0.000194,"right":1138.891921,"top":1.978032,"bottom":-1.570956},"curves":[{"name":"/carOutput/actuatorsOutput/torque","color":"#9467bd","transform":"scale","scale":-1.0,"offset":0.0},{"name":"/controlsState/lateralControlState/torqueState/f","color":"#1f77b4"},{"name":"/carState/steeringPressed","color":"#ff000f"}]},{"title":"proportional, integral, and feed-forward terms (actuator output = sum of PIF terms)","range":{"left":0.000194,"right":1138.891921,"top":2.099784,"bottom":-4.027542},"curves":[{"name":"/controlsState/lateralControlState/torqueState/f","color":"#0ab027"},{"name":"/controlsState/lateralControlState/torqueState/p","color":"#d62728"},{"name":"/controlsState/lateralControlState/torqueState/i","color":"#ffaf00"},{"name":"Zero","color":"#756a6a","custom_python":{"linked_source":"/carState/canValid","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return (0)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"road roll angle, from openpilot localizer","range":{"left":0.000194,"right":1138.891921,"top":0.109446,"bottom":-0.045525},"curves":[{"name":"/liveParameters/roll","color":"#f14cc1"}]}]}}]} +{"current_tab_index":0,"tabs":[{"name":"Lateral Plan Conformance","root":{"split":"vertical","sizes":[0.250949,0.249051,0.250949,0.249051],"children":[{"title":"desired vs actual lateral acceleration (closer means better conformance to plan)","range":{"left":0.000194,"right":1138.891674,"top":1.858161,"bottom":-1.823407},"curves":[{"name":"/controlsState/lateralControlState/torqueState/actualLateralAccel","color":"#1f77b4"},{"name":"/controlsState/lateralControlState/torqueState/desiredLateralAccel","color":"#d62728"}]},{"title":"desired vs actual lateral acceleration, road-roll factored out (closer means better conformance to plan)","range":{"left":0.000194,"right":1138.891674,"top":2.749816,"bottom":-3.723091},"curves":[{"name":"Actual lateral accel (roll compensated)","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/vehicleParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"Desired lateral accel (roll compensated)","color":"#ff7f0e","custom_python":{"linked_source":"/controlsState/desiredCurvature","additional_sources":["/carState/vEgo","/vehicleParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"controller feed-forward vs actuator output (closer means controller prediction is more accurate)","range":{"left":0.000194,"right":1138.891674,"top":1.978032,"bottom":-1.570956},"curves":[{"name":"/carOutput/actuatorsOutput/torque","color":"#9467bd","transform":"scale","scale":-1.0,"offset":0.0},{"name":"/controlsState/lateralControlState/torqueState/f","color":"#1f77b4"},{"name":"/carState/steeringPressed","color":"#ff000f"}]},{"title":"vehicle speed","range":{"left":0.000194,"right":1138.891674,"top":105.981304,"bottom":-2.709314},"curves":[{"name":"carState.vEgo mph","color":"#d62728","custom_python":{"linked_source":"/carState/vEgo","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value * 2.23694\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"carState.vEgo kmh","color":"#1ac938","custom_python":{"linked_source":"/carState/vEgo","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value * 3.6\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"/carState/vEgo","color":"#ff7f0e"}]}]}},{"name":"Vehicle Dynamics","root":{"split":"vertical","sizes":[0.334282,0.331437,0.334282],"children":[{"title":"configured-initial vs online-learned steerRatio, set configured value to match learned","range":{"left":0.0,"right":1138.816328,"top":19.665784,"bottom":19.359553},"curves":[{"name":"/carParams/steerRatio","color":"#1f77b4"},{"name":"/vehicleParameters/steerRatio","color":"#1ac938"}]},{"title":"configured-initial vs online-learned tireStiffnessRatio, set configured value to match learned","range":{"left":0.0,"right":1138.816328,"top":1.11221,"bottom":0.995631},"curves":[{"name":"/carParams/tireStiffnessFactor","color":"#d62728"},{"name":"/vehicleParameters/stiffnessFactor","color":"#ff7f0e"}]},{"title":"online-learned steering angle offsets for straight-ahead driving, large values here may indicate alignment problems","range":{"left":0.0,"right":1138.816328,"top":-1.081041,"bottom":-4.494133},"curves":[{"name":"/vehicleParameters/angleOffsetAverageDeg","color":"#f14cc1"},{"name":"/vehicleParameters/angleOffsetDeg","color":"#9467bd"}]}]}},{"name":"Actuator Performance","root":{"split":"vertical","sizes":[0.333333,0.333333,0.333333],"children":[{"title":"offline-calculated vs online-learned lateral accel scaling factor, accel obtained from 100% actuator output","range":{"left":0.0,"right":1138.920072,"top":1.21611,"bottom":0.539474},"curves":[{"name":"/lateralTorqueParameters/latAccelFactorFiltered","color":"#1f77b4"},{"name":"/lateralTorqueParameters/latAccelFactorRaw","color":"#d62728"},{"name":"/carParams/lateralTuning/torque/latAccelFactor","color":"#1c9222"}]},{"title":"learned lateral accel offset, vehicle-specific compensation to obtain true zero lateral accel","range":{"left":0.0,"right":1138.920072,"top":-0.304367,"bottom":-0.418688},"curves":[{"name":"/lateralTorqueParameters/latAccelOffsetFiltered","color":"#1ac938"},{"name":"/lateralTorqueParameters/latAccelOffsetRaw","color":"#ff7f0e"}]},{"title":"offline-calculated vs online-learned EPS friction factor, necessary to start moving the steering wheel","range":{"left":0.0,"right":1138.920072,"top":0.226389,"bottom":0.15805},"curves":[{"name":"/lateralTorqueParameters/frictionCoefficientFiltered","color":"#f14cc1"},{"name":"/lateralTorqueParameters/frictionCoefficientRaw","color":"#9467bd"},{"name":"/carParams/lateralTuning/torque/friction","color":"#1c9222"}]}]}},{"name":"Actuator Delay","root":{"split":"vertical","sizes":[0.30441,0.358464,0.337127],"children":[{"title":"actuator lag learning state, 0 = learning, 1 = learned/applying, 2 = invalid","range":{"left":0.0,"right":1138.749979,"top":1.025,"bottom":-0.025},"curves":[{"name":"/lateralDelay/status","color":"#ff7f0e"}]},{"title":"offline default vs online estimated steering actuator lag","range":{"left":0.0,"right":1138.749979,"top":0.419648,"bottom":0.318362},"curves":[{"name":"/lateralDelay/lateralDelay","color":"#1f77b4"},{"name":"/lateralDelay/lateralDelayEstimate","color":"#d62728"},{"name":"opendbc default steering lag","color":"#1ac938","custom_python":{"linked_source":"/carParams/steerActuatorDelay","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value + 0.2\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"online estimated steering actuator lag, standard deviation","range":{"left":0.0,"right":1138.749979,"top":0.06732,"bottom":-0.001642},"curves":[{"name":"/lateralDelay/lateralDelayEstimateStd","color":"#f14cc1"}]}]}},{"name":"Controls Performance","root":{"split":"vertical","sizes":[0.265655,0.251898,0.245731,0.236717],"children":[{"title":"rate-of-change limits on steering actuator (blue = original, green = rate-limited before CAN output)","range":{"left":0.000194,"right":1138.891921,"top":1.05,"bottom":-1.05},"curves":[{"name":"/carControl/actuators/torque","color":"#0c00f2"},{"name":"/carOutput/actuatorsOutput/torque","color":"#2cd63a"}]},{"title":"controller feed-forward vs actuator output (closer means controller prediction is more accurate)","range":{"left":0.000194,"right":1138.891921,"top":1.978032,"bottom":-1.570956},"curves":[{"name":"/carOutput/actuatorsOutput/torque","color":"#9467bd","transform":"scale","scale":-1.0,"offset":0.0},{"name":"/controlsState/lateralControlState/torqueState/f","color":"#1f77b4"},{"name":"/carState/steeringPressed","color":"#ff000f"}]},{"title":"proportional, integral, and feed-forward terms (actuator output = sum of PIF terms)","range":{"left":0.000194,"right":1138.891921,"top":2.099784,"bottom":-4.027542},"curves":[{"name":"/controlsState/lateralControlState/torqueState/f","color":"#0ab027"},{"name":"/controlsState/lateralControlState/torqueState/p","color":"#d62728"},{"name":"/controlsState/lateralControlState/torqueState/i","color":"#ffaf00"},{"name":"Zero","color":"#756a6a","custom_python":{"linked_source":"/carState/canValid","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return (0)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"road roll angle, from openpilot localizer","range":{"left":0.000194,"right":1138.891921,"top":0.109446,"bottom":-0.045525},"curves":[{"name":"/vehicleParameters/roll","color":"#f14cc1"}]}]}}]} diff --git a/openpilot/tools/jotpluggler/runtime.cc b/openpilot/tools/jotpluggler/runtime.cc index a1e47c7e8e..d7d0fc79c4 100644 --- a/openpilot/tools/jotpluggler/runtime.cc +++ b/openpilot/tools/jotpluggler/runtime.cc @@ -39,11 +39,11 @@ const bool kLogCameraTimings = env_flag_enabled("JOTP_CAMERA_TIMINGS"); CameraType decoder_camera_type(CameraViewKind view) { switch (view) { - case CameraViewKind::Driver: return DriverCam; + case CameraViewKind::Cabin: return CabinCam; case CameraViewKind::WideRoad: return WideRoadCam; - case CameraViewKind::QRoad: return RoadCam; + case CameraViewKind::QRoad: return NarrowRoadCam; case CameraViewKind::Road: - default: return RoadCam; + default: return NarrowRoadCam; } } @@ -59,17 +59,17 @@ bool stream_batch_has_data(const StreamExtractBatch &batch) { bool should_subscribe_stream_service(const std::string &name) { static const std::array kSkippedServices = {{ - "roadEncodeIdx", - "driverEncodeIdx", + "narrowRoadEncodeIdx", + "cabinEncodeIdx", "wideRoadEncodeIdx", - "qRoadEncodeIdx", - "roadEncodeData", - "driverEncodeData", + "qNarrowRoadEncodeIdx", + "narrowRoadEncodeData", + "cabinEncodeData", "wideRoadEncodeData", - "qRoadEncodeData", + "qNarrowRoadEncodeData", "livestreamWideRoadEncodeIdx", - "livestreamRoadEncodeIdx", - "livestreamDriverEncodeIdx", + "livestreamNarrowRoadEncodeIdx", + "livestreamCabinEncodeIdx", "thumbnail", }}; if (name == "rawAudioData") return false; diff --git a/openpilot/tools/jotpluggler/session.cc b/openpilot/tools/jotpluggler/session.cc index beb0a292be..4c694a8daa 100644 --- a/openpilot/tools/jotpluggler/session.cc +++ b/openpilot/tools/jotpluggler/session.cc @@ -19,6 +19,9 @@ void sync_camera_feeds(AppSession *session) { session->pane_camera_feeds[i]->setCameraIndex(session->route_data.*(kCameraViewSpecs[i].route_member), kCameraViewSpecs[i].view); } } + if (session->thumbnail_view) { + session->thumbnail_view->setThumbnails(session->route_data.thumbnails); + } } void apply_route_data(AppSession *session, UiState *state, RouteData route_data) { diff --git a/openpilot/tools/jotpluggler/sketch_layout.cc b/openpilot/tools/jotpluggler/sketch_layout.cc index ee307653f6..5440d9dd8d 100644 --- a/openpilot/tools/jotpluggler/sketch_layout.cc +++ b/openpilot/tools/jotpluggler/sketch_layout.cc @@ -45,9 +45,9 @@ struct RouteSelection { struct SegmentLogs { std::string rlog; std::string qlog; - std::string fcamera; - std::string dcamera; - std::string ecamera; + std::string narrow_road; + std::string cabin; + std::string wide_road; std::string qcamera; }; @@ -98,6 +98,7 @@ struct LoadedRouteArtifacts { std::vector can_messages; std::vector logs; std::vector timeline; + std::vector thumbnails; std::unordered_map enum_info; }; @@ -261,7 +262,8 @@ RouteSelection parse_route_selection(std::string route_name) { if (separator == "/") { size_t pos = range_str.find(':'); int begin_segment = 0; - if (!parse_segment_number(range_str.substr(0, pos), &begin_segment)) { + const std::string begin_str = range_str.substr(0, pos); + if (!begin_str.empty() && !parse_segment_number(begin_str, &begin_segment)) { return {}; } route.begin_segment = begin_segment; @@ -293,11 +295,11 @@ void add_log_file_to_segments(std::map *segments, int segment_ } else if (name == "qlog.bz2" || name == "qlog.zst" || name == "qlog") { segment.qlog = file; } else if (name == "fcamera.hevc") { - segment.fcamera = file; + segment.narrow_road = file; } else if (name == "dcamera.hevc") { - segment.dcamera = file; + segment.cabin = file; } else if (name == "ecamera.hevc") { - segment.ecamera = file; + segment.wide_road = file; } else if (name == "qcamera.ts") { segment.qcamera = file; } @@ -684,6 +686,25 @@ std::vector extract_segment_logs(const std::vector &events) { return logs; } +std::vector extract_segment_thumbnails(const std::vector &events, int segment) { + std::vector thumbnails; + for (const Event &event_record : events) { + if (event_record.which != cereal::Event::Which::THUMBNAIL) continue; + with_parseable_event(event_record.data, [&](const cereal::Event::Reader &event) { + const auto thumbnail = event.getThumbnail(); + const auto jpeg = thumbnail.getThumbnail(); + if (jpeg.size() == 0) return; + const uint64_t timestamp = thumbnail.getTimestampEof(); + ThumbnailFrame frame; + frame.timestamp = static_cast(timestamp != 0 ? timestamp : event.getLogMonoTime()) / 1.0e9; + frame.segment = segment; + frame.jpeg.assign(jpeg.begin(), jpeg.end()); + thumbnails.push_back(std::move(frame)); + }); + } + return thumbnails; +} + RouteMetadata extract_segment_metadata(const std::vector &events) { RouteMetadata metadata; for (const Event &event_record : events) { @@ -796,6 +817,8 @@ Pane parse_dock_area(const json11::Json &dock_area_node) { const std::string kind = dock_area_node["kind"].string_value(); if (kind == "map") { pane.kind = PaneKind::Map; + } else if (kind == "thumbnail") { + pane.kind = PaneKind::Thumbnail; } else if (kind == "camera") { pane.kind = PaneKind::Camera; const std::string camera_view = dock_area_node["camera_view"].string_value(); @@ -904,7 +927,9 @@ void append_scalar_point(RouteSeries *series, series->values.push_back(value); } -void append_fixed_scalar_point(RouteSeries *series, double tm, double value) { +// This has thousands of generated call sites. Inlining it duplicates vector +// growth logic throughout the extractor and is slower both to compile and run. +__attribute__((noinline)) void append_fixed_scalar_point(RouteSeries *series, double tm, double value) { series->times.push_back(tm); series->values.push_back(value); } @@ -1167,6 +1192,7 @@ RouteData build_route_data(std::vector &&series_list, std::vector &&can_messages, std::vector &&logs, std::vector &&timeline, + std::vector &&thumbnails, std::unordered_map &&enum_info, std::string car_fingerprint, std::string dbc_name) { @@ -1233,6 +1259,14 @@ RouteData build_route_data(std::vector &&series_list, route_data.x_min = timeline.front().start_time; route_data.x_max = timeline.back().end_time; } + std::sort(thumbnails.begin(), thumbnails.end(), [](const ThumbnailFrame &a, const ThumbnailFrame &b) { + return a.timestamp < b.timestamp; + }); + if (!route_data.has_time_range && !thumbnails.empty()) { + route_data.has_time_range = true; + route_data.x_min = thumbnails.front().timestamp; + route_data.x_max = thumbnails.back().timestamp; + } if (route_data.has_time_range) { const double time_offset = route_data.x_min; @@ -1254,6 +1288,9 @@ RouteData build_route_data(std::vector &&series_list, entry.start_time -= time_offset; entry.end_time -= time_offset; } + for (ThumbnailFrame &thumbnail : thumbnails) { + thumbnail.timestamp -= time_offset; + } route_data.x_max -= time_offset; route_data.x_min = 0.0; } @@ -1271,6 +1308,7 @@ RouteData build_route_data(std::vector &&series_list, merged_timeline.push_back(std::move(entry)); } route_data.timeline = std::move(merged_timeline); + route_data.thumbnails = std::move(thumbnails); std::sort(can_messages.begin(), can_messages.end(), [](const CanMessageData &a, const CanMessageData &b) { return std::make_tuple(a.id.service, a.id.bus, a.id.address) < std::make_tuple(b.id.service, b.id.bus, b.id.address); @@ -1524,6 +1562,7 @@ LoadedRouteArtifacts load_route_series_parallel( SeriesAccumulator series; std::vector logs; std::vector timeline; + std::vector thumbnails; }; const std::vector> segment_list(segments.begin(), segments.end()); @@ -1586,6 +1625,7 @@ LoadedRouteArtifacts load_route_series_parallel( results[index].series = extract_segment_series(reader.events, schema, can_dbc, skip_raw_can, worker_budget, segment_workers); results[index].logs = extract_segment_logs(reader.events); results[index].timeline = extract_segment_timeline(reader.events); + results[index].thumbnails = extract_segment_thumbnails(reader.events, segment_number); segment_stats.extract_seconds = std::chrono::duration(LoadStats::Clock::now() - extract_start).count(); segment_stats.event_count = reader.events.size(); segment_stats.series_count = populated_series_count(results[index].series); @@ -1612,6 +1652,7 @@ LoadedRouteArtifacts load_route_series_parallel( } std::vector logs; std::vector timeline; + std::vector thumbnails; for (SegmentResult &result : results) { if (!result.logs.empty()) { logs.insert(logs.end(), @@ -1623,12 +1664,18 @@ LoadedRouteArtifacts load_route_series_parallel( std::make_move_iterator(result.timeline.begin()), std::make_move_iterator(result.timeline.end())); } + if (!result.thumbnails.empty()) { + thumbnails.insert(thumbnails.end(), + std::make_move_iterator(result.thumbnails.begin()), + std::make_move_iterator(result.thumbnails.end())); + } } LoadedRouteArtifacts artifacts; artifacts.series = collect_series(std::move(merged)); artifacts.can_messages = std::move(merged.can_messages); artifacts.logs = std::move(logs); artifacts.timeline = std::move(timeline); + artifacts.thumbnails = std::move(thumbnails); artifacts.enum_info = std::move(merged.enum_info); stats->merge_end = LoadStats::Clock::now(); return artifacts; @@ -1834,14 +1881,15 @@ RouteData load_route_data(const std::string &route_name, std::move(artifacts.can_messages), std::move(artifacts.logs), std::move(artifacts.timeline), + std::move(artifacts.thumbnails), std::move(artifacts.enum_info), metadata.car_fingerprint, resolved_dbc); route_data.route_id = make_route_identifier(route, segments); - build_camera_index(segments, route_data, &SegmentLogs::fcamera, "roadEncodeIdx", &route_data.road_camera); - build_camera_index(segments, route_data, &SegmentLogs::dcamera, "driverEncodeIdx", &route_data.driver_camera); - build_camera_index(segments, route_data, &SegmentLogs::ecamera, "wideRoadEncodeIdx", &route_data.wide_road_camera); - build_camera_index(segments, route_data, &SegmentLogs::qcamera, "qRoadEncodeIdx", &route_data.qroad_camera); + build_camera_index(segments, route_data, &SegmentLogs::narrow_road, "narrowRoadEncodeIdx", &route_data.road_camera); + build_camera_index(segments, route_data, &SegmentLogs::cabin, "cabinEncodeIdx", &route_data.cabin_camera); + build_camera_index(segments, route_data, &SegmentLogs::wide_road, "wideRoadEncodeIdx", &route_data.wide_road_camera); + build_camera_index(segments, route_data, &SegmentLogs::qcamera, "qNarrowRoadEncodeIdx", &route_data.qroad_camera); stats.load_end = LoadStats::Clock::now(); stats.publish(RouteLoadStage::Finished, segments.size(), {}); stats.print_summary(route_data.series.size()); diff --git a/openpilot/tools/jotpluggler/test_jotpluggler.py b/openpilot/tools/jotpluggler/test_jotpluggler.py new file mode 100644 index 0000000000..cbcc0a8168 --- /dev/null +++ b/openpilot/tools/jotpluggler/test_jotpluggler.py @@ -0,0 +1,13 @@ +import subprocess +from pathlib import Path + + +JOTPLUGGLER_DIR = Path(__file__).parent + + +from openpilot.common.test import OpenpilotTestCase +class TestJotpluggler(OpenpilotTestCase): + def test_help(self): + result = subprocess.run(["./jotpluggler", "-h"], cwd=JOTPLUGGLER_DIR, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stderr diff --git a/openpilot/tools/jotpluggler/thumbnail.cc b/openpilot/tools/jotpluggler/thumbnail.cc new file mode 100644 index 0000000000..11e184323a --- /dev/null +++ b/openpilot/tools/jotpluggler/thumbnail.cc @@ -0,0 +1,253 @@ +#include "tools/jotpluggler/thumbnail.h" + +#include "imgui_impl_opengl3_loader.h" + +#include +#include +#include + +extern "C" { +#include +#include +} + +namespace { + +bool decode_jpeg(const std::vector &jpeg, int *width, int *height, std::vector *rgba) { + if (jpeg.empty()) return false; + + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_MJPEG); + AVCodecContext *context = codec != nullptr ? avcodec_alloc_context3(codec) : nullptr; + AVFrame *frame = av_frame_alloc(); + AVPacket *packet = av_packet_alloc(); + if (context == nullptr || frame == nullptr || packet == nullptr) { + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return false; + } + + const bool packet_ready = jpeg.size() <= static_cast(std::numeric_limits::max()) + && av_new_packet(packet, static_cast(jpeg.size())) >= 0; + if (packet_ready) { + std::copy(jpeg.begin(), jpeg.end(), packet->data); + } + const bool decoded = packet_ready + && avcodec_open2(context, codec, nullptr) >= 0 + && avcodec_send_packet(context, packet) >= 0 + && avcodec_receive_frame(context, frame) >= 0; + if (!decoded || frame->width <= 0 || frame->height <= 0) { + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return false; + } + + int chroma_x_shift = 0; + int chroma_y_shift = 0; + switch (static_cast(frame->format)) { + case AV_PIX_FMT_YUV420P: + case AV_PIX_FMT_YUVJ420P: + chroma_x_shift = 1; + chroma_y_shift = 1; + break; + case AV_PIX_FMT_YUV422P: + case AV_PIX_FMT_YUVJ422P: + chroma_x_shift = 1; + break; + case AV_PIX_FMT_YUV444P: + case AV_PIX_FMT_YUVJ444P: + break; + default: + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return false; + } + + *width = frame->width; + *height = frame->height; + rgba->resize(static_cast(*width) * static_cast(*height) * 4U); + const bool full_range = frame->color_range == AVCOL_RANGE_JPEG + || frame->format == AV_PIX_FMT_YUVJ420P + || frame->format == AV_PIX_FMT_YUVJ422P + || frame->format == AV_PIX_FMT_YUVJ444P; + for (int y = 0; y < *height; ++y) { + const uint8_t *y_row = frame->data[0] + y * frame->linesize[0]; + const uint8_t *u_row = frame->data[1] + (y >> chroma_y_shift) * frame->linesize[1]; + const uint8_t *v_row = frame->data[2] + (y >> chroma_y_shift) * frame->linesize[2]; + uint8_t *out = rgba->data() + static_cast(y) * static_cast(*width) * 4U; + for (int x = 0; x < *width; ++x) { + const double luma = full_range ? static_cast(y_row[x]) + : 1.164383 * (static_cast(y_row[x]) - 16.0); + const double u = static_cast(u_row[x >> chroma_x_shift]) - 128.0; + const double v = static_cast(v_row[x >> chroma_x_shift]) - 128.0; + const double red = luma + (full_range ? 1.402 : 1.596027) * v; + const double green = luma - (full_range ? 0.344136 : 0.391762) * u + - (full_range ? 0.714136 : 0.812968) * v; + const double blue = luma + (full_range ? 1.772 : 2.017232) * u; + out[x * 4 + 0] = static_cast(std::clamp(std::lround(red), 0L, 255L)); + out[x * 4 + 1] = static_cast(std::clamp(std::lround(green), 0L, 255L)); + out[x * 4 + 2] = static_cast(std::clamp(std::lround(blue), 0L, 255L)); + out[x * 4 + 3] = 255; + } + } + + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return true; +} + +std::string format_thumbnail_time(double seconds) { + const int rounded = std::max(0, static_cast(std::lround(seconds))); + const int hours = rounded / 3600; + const int minutes = (rounded % 3600) / 60; + const int secs = rounded % 60; + if (hours > 0) { + return util::string_format("%d:%02d:%02d", hours, minutes, secs); + } + return util::string_format("%02d:%02d", minutes, secs); +} + +} // namespace + +struct ThumbnailView::Impl { + ~Impl() { + destroy_texture(); + } + + void setThumbnails(const std::vector &next_thumbnails) { + destroy_texture(); + thumbnails = &next_thumbnails; + displayed_index = -1; + failed_index = -1; + } + + void update(double tracker_time) { + if (thumbnails == nullptr || thumbnails->empty()) return; + auto it = std::lower_bound(thumbnails->begin(), thumbnails->end(), tracker_time, + [](const ThumbnailFrame &frame, double time) { + return frame.timestamp < time; + }); + if (it == thumbnails->end()) { + it = std::prev(thumbnails->end()); + } else if (it != thumbnails->begin()) { + const auto previous = std::prev(it); + if (std::abs(previous->timestamp - tracker_time) <= std::abs(it->timestamp - tracker_time)) { + it = previous; + } + } + const int index = static_cast(std::distance(thumbnails->begin(), it)); + if (index == displayed_index || index == failed_index) return; + + int width = 0; + int height = 0; + std::vector rgba; + if (!decode_jpeg(it->jpeg, &width, &height, &rgba)) { + failed_index = index; + return; + } + + if (texture == 0) { + glGenTextures(1, &texture); + } + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, rgba.data()); + glBindTexture(GL_TEXTURE_2D, 0); + texture_width = width; + texture_height = height; + displayed_index = index; + failed_index = -1; + } + + void drawSized(ImVec2 size, bool loading) const { + size.x = std::max(1.0f, size.x); + size.y = std::max(1.0f, size.y); + ImGui::InvisibleButton("##thumbnail_sized", size); + const ImVec2 pane_min = ImGui::GetItemRectMin(); + const ImVec2 pane_max = ImGui::GetItemRectMax(); + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + draw_list->AddRectFilled(pane_min, pane_max, IM_COL32(24, 24, 24, 255)); + + if (texture != 0 && texture_width > 0 && texture_height > 0) { + const float scale = std::min(size.x / static_cast(texture_width), + size.y / static_cast(texture_height)); + const ImVec2 image_size(static_cast(texture_width) * scale, + static_cast(texture_height) * scale); + const ImVec2 image_min(pane_min.x + (size.x - image_size.x) * 0.5f, + pane_min.y + (size.y - image_size.y) * 0.5f); + const ImVec2 image_max(image_min.x + image_size.x, image_min.y + image_size.y); + draw_list->AddImage(static_cast(texture), image_min, image_max); + + if (thumbnails != nullptr && displayed_index >= 0 + && displayed_index < static_cast(thumbnails->size())) { + const ThumbnailFrame &frame = (*thumbnails)[static_cast(displayed_index)]; + const std::string label = util::string_format("%s · segment %d · %d/%zu", + format_thumbnail_time(frame.timestamp).c_str(), + frame.segment, + displayed_index + 1, + thumbnails->size()); + const ImVec2 text_size = ImGui::CalcTextSize(label.c_str()); + const ImVec2 label_min(image_min.x, std::max(image_min.y, image_max.y - text_size.y - 14.0f)); + draw_list->AddRectFilled(label_min, image_max, IM_COL32(0, 0, 0, 175)); + draw_list->AddText(ImVec2(label_min.x + 7.0f, label_min.y + 7.0f), IM_COL32_WHITE, label.c_str()); + } + return; + } + + const bool has_thumbnails = thumbnails != nullptr && !thumbnails->empty(); + const char *label = loading ? "loading" : (has_thumbnails ? "invalid thumbnail" : "no thumbnails"); + const ImVec2 text_size = ImGui::CalcTextSize(label); + draw_list->AddText(ImVec2(pane_min.x + (size.x - text_size.x) * 0.5f, + pane_min.y + (size.y - text_size.y) * 0.5f), + IM_COL32(187, 187, 187, 255), label); + } + + void destroy_texture() { + if (texture != 0) { + glDeleteTextures(1, &texture); + } + texture = 0; + texture_width = 0; + texture_height = 0; + } + + const std::vector *thumbnails = nullptr; + int displayed_index = -1; + int failed_index = -1; + GLuint texture = 0; + int texture_width = 0; + int texture_height = 0; +}; + +ThumbnailView::ThumbnailView() : impl_(std::make_unique()) {} +ThumbnailView::~ThumbnailView() = default; + +void ThumbnailView::setThumbnails(const std::vector &thumbnails) { + impl_->setThumbnails(thumbnails); +} + +void ThumbnailView::update(double tracker_time) { + impl_->update(tracker_time); +} + +void ThumbnailView::drawSized(ImVec2 size, bool loading) { + impl_->drawSized(size, loading); +} + +void draw_thumbnail_pane(AppSession *session, UiState *state) { + if (session->thumbnail_view == nullptr) { + ImGui::TextDisabled("Thumbnails unavailable"); + return; + } + if (state->has_tracker_time) { + session->thumbnail_view->update(state->tracker_time); + } + session->thumbnail_view->drawSized(ImGui::GetContentRegionAvail(), session->async_route_loading); +} diff --git a/openpilot/tools/jotpluggler/thumbnail.h b/openpilot/tools/jotpluggler/thumbnail.h new file mode 100644 index 0000000000..6970173a5c --- /dev/null +++ b/openpilot/tools/jotpluggler/thumbnail.h @@ -0,0 +1,5 @@ +#pragma once + +#include "tools/jotpluggler/app.h" + +void draw_thumbnail_pane(AppSession *session, UiState *state); diff --git a/openpilot/tools/joystick/joystickd.py b/openpilot/tools/joystick/joystickd.py index a1c00746b6..f8a2598361 100755 --- a/openpilot/tools/joystick/joystickd.py +++ b/openpilot/tools/joystick/joystickd.py @@ -9,6 +9,7 @@ from opendbc.car.vehicle_model import VehicleModel from openpilot.common.realtime import DT_CTRL, Ratekeeper from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.controls.lib.drive_helpers import should_stop LongCtrlState = car.CarControl.Actuators.LongControlState MAX_LAT_ACCEL = 3.0 @@ -20,7 +21,7 @@ def joystickd_thread(): CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) VM = VehicleModel(CP) - sm = messaging.SubMaster(['carState', 'onroadEvents', 'liveParameters', 'selfdriveState', 'testJoystick'], frequency=1. / DT_CTRL) + sm = messaging.SubMaster(['carState', 'onroadEvents', 'vehicleParameters', 'selfdriveState', 'testJoystick'], frequency=1. / DT_CTRL) pm = messaging.PubMaster(['carControl', 'controlsState']) rk = Ratekeeper(100, print_delay_threshold=None) @@ -33,6 +34,7 @@ def joystickd_thread(): CC.enabled = sm['selfdriveState'].enabled CC.latActive = sm['selfdriveState'].active and not sm['carState'].steerFaultTemporary and not sm['carState'].steerFaultPermanent CC.longActive = CC.enabled and not any(e.overrideLongitudinal for e in sm['onroadEvents']) and CP.openpilotLongitudinalControl + CC.cruiseControl.override = CC.enabled and not CC.longActive and CP.openpilotLongitudinalControl CC.cruiseControl.cancel = sm['carState'].cruiseState.enabled and (not CC.enabled or not CP.pcmCruise) CC.hudControl.leadDistanceBars = 2 @@ -48,12 +50,12 @@ def joystickd_thread(): if CC.longActive: actuators.accel = 4.0 * float(np.clip(joystick_axes[0], -1, 1)) - actuators.longControlState = LongCtrlState.pid if sm['carState'].vEgo > CP.vEgoStopping else LongCtrlState.stopping + actuators.longControlState = LongCtrlState.stopping if should_stop(sm['carState'].vEgo, actuators.accel) else LongCtrlState.pid CC.cruiseControl.resume = actuators.accel > 0.0 if CC.latActive: max_curvature = MAX_LAT_ACCEL / max(sm['carState'].vEgo ** 2, 5) - max_angle = math.degrees(VM.get_steer_from_curvature(max_curvature, sm['carState'].vEgo, sm['liveParameters'].roll)) + max_angle = math.degrees(VM.get_steer_from_curvature(max_curvature, sm['carState'].vEgo, sm['vehicleParameters'].roll)) actuators.torque = float(np.clip(joystick_axes[1], -1, 1)) actuators.steeringAngleDeg, actuators.curvature = actuators.torque * max_angle, actuators.torque * -max_curvature @@ -65,7 +67,7 @@ def joystickd_thread(): controlsState = cs_msg.controlsState controlsState.lateralControlState.init('debugState') - lp = sm['liveParameters'] + lp = sm['vehicleParameters'] steer_angle_without_offset = math.radians(sm['carState'].steeringAngleDeg - lp.angleOffsetDeg) controlsState.curvature = -VM.calc_curvature(steer_angle_without_offset, sm['carState'].vEgo, lp.roll) diff --git a/openpilot/tools/lib/auth.py b/openpilot/tools/lib/auth.py index 5988397d0a..6a685e38d9 100755 --- a/openpilot/tools/lib/auth.py +++ b/openpilot/tools/lib/auth.py @@ -32,9 +32,6 @@ from urllib.parse import parse_qs, urlencode from openpilot.tools.lib.api import APIError, CommaApi, UnauthorizedError from openpilot.tools.lib.auth_config import set_token, get_token -PORT = 3000 - - class ClientRedirectServer(HTTPServer): query_params: dict[str, Any] = {} @@ -54,11 +51,11 @@ class ClientRedirectHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(b'Return to the CLI to continue') - def log_message(self, *args): + def log_message(self, format: str, *args: object) -> None: # noqa: A002 # stdlib override pass # this prevent http server from dumping messages to stdout -def auth_redirect_link(method): +def auth_redirect_link(method, port): provider_id = { 'google': 'g', 'apple': 'a', @@ -67,7 +64,7 @@ def auth_redirect_link(method): params = { 'redirect_uri': f"https://api.comma.ai/v2/auth/{provider_id}/redirect/", - 'state': f'service,localhost:{PORT}', + 'state': f'service,localhost:{port}', } if method == 'google': @@ -98,9 +95,9 @@ def auth_redirect_link(method): def login(method): - oauth_uri = auth_redirect_link(method) - - web_server = ClientRedirectServer(('localhost', PORT), ClientRedirectHandler) + # Let the OS select an available port to avoid colliding with other services. + web_server = ClientRedirectServer(('localhost', 0), ClientRedirectHandler) + oauth_uri = auth_redirect_link(method, web_server.server_port) print(f'To sign in, use your browser and navigate to {oauth_uri}') webbrowser.open(oauth_uri, new=2) diff --git a/openpilot/tools/lib/comma_car_segments.py b/openpilot/tools/lib/comma_car_segments.py index cd19356d66..b27887ed29 100644 --- a/openpilot/tools/lib/comma_car_segments.py +++ b/openpilot/tools/lib/comma_car_segments.py @@ -74,7 +74,7 @@ def get_repo_url(path): response = requests.head(get_repo_raw_url(path)) - if "text/plain" in response.headers.get("content-type"): + if "text/plain" in response.headers.get("content-type", ""): # This is an LFS pointer, so download the raw data from lfs response = requests.get(get_repo_raw_url(path)) assert response.status_code == 200 diff --git a/openpilot/tools/lib/file_downloader.py b/openpilot/tools/lib/file_downloader.py index 68061b201e..efb06095be 100755 --- a/openpilot/tools/lib/file_downloader.py +++ b/openpilot/tools/lib/file_downloader.py @@ -5,17 +5,21 @@ Called by C++ replay/cabana via subprocess. Subcommands: route-files - Get route file URLs as JSON - download - Download URL to local cache, print local path + download - Download/decompress URL to local cache, print local path + decompress - Decompress a local log file, print temporary path devices - List user's devices as JSON device-routes - List routes for a device as JSON """ import argparse +import bz2 import hashlib import json import os +import shutil import sys import tempfile -import shutil + +import zstandard as zstd from openpilot.common.hardware.hw import Paths from openpilot.tools.lib.api import CommaApi, UnauthorizedError, APIError @@ -39,11 +43,60 @@ def api_call(func): sys.stdout.flush() -def cache_file_path(url): +def cache_file_path(url, compression=None): url_without_query = url.split("?")[0] + if compression: + url_without_query = f"decompressed-{compression}:{url_without_query}" return os.path.join(Paths.download_cache_root(), hashlib.sha256(url_without_query.encode()).hexdigest()) +def compression_type(data): + if data.startswith(b'BZh'): + return 'bz2' + if data.startswith(b'\x28\xb5\x2f\xfd'): + return 'zst' + return None + + +def make_decompressor(compression): + if compression == 'bz2': + return bz2.BZ2Decompressor() + if compression == 'zst': + return zstd.ZstdDecompressor().decompressobj() + raise ValueError(f"Unsupported compression type: {compression}") + + +def decompress_file(source, destination, compression=None): + with open(source, 'rb') as src, open(destination, 'wb') as dst: + header = src.read(4) + compression = compression or compression_type(header) + decompressor = make_decompressor(compression) + dst.write(decompressor.decompress(header)) + while data := src.read(1024 * 1024): + dst.write(decompressor.decompress(data)) + if not decompressor.eof: + raise EOFError(f"Compressed {compression} file ended before the end-of-stream marker") + + +def materialize_cached_file(source, url, compression): + local_path = cache_file_path(url, compression) + if os.path.exists(local_path): + return local_path + + tmp_fd, tmp_path = tempfile.mkstemp(dir=Paths.download_cache_root()) + os.close(tmp_fd) + try: + decompress_file(source, tmp_path, compression) + shutil.move(tmp_path, local_path) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + return local_path + + def cmd_route_files(args): api_call(lambda api: api.get(f"v1/route/{args.route}/files")) @@ -53,8 +106,19 @@ def cmd_download(args): use_cache = not args.no_cache if use_cache: + for compression in ('bz2', 'zst'): + decompressed_path = cache_file_path(url, compression) + if os.path.exists(decompressed_path): + sys.stdout.write(decompressed_path + "\n") + sys.stdout.flush() + return + local_path = cache_file_path(url) if os.path.exists(local_path): + with open(local_path, 'rb') as f: + compression = compression_type(f.read(4)) + if compression: + local_path = materialize_cached_file(local_path, url, compression) sys.stdout.write(local_path + "\n") sys.stdout.flush() return @@ -80,14 +144,30 @@ def cmd_download(args): try: downloaded = 0 chunk_size = 1024 * 1024 + compression = None + decompressor = None with os.fdopen(tmp_fd, 'wb') as f: for data in r.stream(chunk_size): - f.write(data) + if downloaded == 0: + compression = compression_type(data) + if compression: + decompressor = make_decompressor(compression) + f.write(decompressor.decompress(data) if decompressor else data) downloaded += len(data) sys.stderr.write(f"PROGRESS:{downloaded}:{total}\n") sys.stderr.flush() - if use_cache: + if decompressor and not decompressor.eof: + raise EOFError(f"Compressed {compression} file ended before the end-of-stream marker") + + if decompressor: + if use_cache: + output_path = cache_file_path(url, compression) + shutil.move(tmp_path, output_path) + else: + output_path = tmp_path + sys.stdout.write(output_path + "\n") + elif use_cache: shutil.move(tmp_path, local_path) sys.stdout.write(local_path + "\n") else: @@ -109,6 +189,24 @@ def cmd_download(args): sys.stdout.flush() +def cmd_decompress(args): + os.makedirs(Paths.download_cache_root(), exist_ok=True) + output_fd, output_path = tempfile.mkstemp(dir=Paths.download_cache_root()) + os.close(output_fd) + try: + decompress_file(args.path, output_path) + except Exception as e: + try: + os.unlink(output_path) + except OSError: + pass + sys.stderr.write(f"ERROR:{e}\n") + sys.stderr.flush() + sys.exit(1) + sys.stdout.write(output_path + "\n") + sys.stdout.flush() + + def cmd_devices(args): api_call(lambda api: api.get("v1/me/devices/")) @@ -139,6 +237,10 @@ def main(): p_dl.add_argument("--no-cache", action="store_true") p_dl.set_defaults(func=cmd_download) + p_dc = subparsers.add_parser("decompress") + p_dc.add_argument("path") + p_dc.set_defaults(func=cmd_decompress) + p_dev = subparsers.add_parser("devices") p_dev.set_defaults(func=cmd_devices) diff --git a/openpilot/tools/lib/file_sources.py b/openpilot/tools/lib/file_sources.py index cb7bf15114..2d2bfccfd7 100755 --- a/openpilot/tools/lib/file_sources.py +++ b/openpilot/tools/lib/file_sources.py @@ -12,7 +12,7 @@ Source = Callable[[SegmentRange, list[int], FileNames], dict[int, str]] InternalUnavailableException = Exception("Internal source not available") -def comma_api_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames) -> dict[int, str]: +def comma_api_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames, /) -> dict[int, str]: route = Route(sr.route_name) # comma api will have already checked if the file exists @@ -22,7 +22,7 @@ def comma_api_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames) -> d return {seg: route.qlog_paths()[seg] for seg in seg_idxs if route.qlog_paths()[seg] is not None} -def internal_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames, endpoint_url: str = DATA_ENDPOINT) -> dict[int, str]: +def internal_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames, /, endpoint_url: str = DATA_ENDPOINT) -> dict[int, str]: if not internal_source_available(endpoint_url): raise InternalUnavailableException @@ -32,11 +32,11 @@ def internal_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames, endpo return eval_source({seg: [get_internal_url(sr, seg, fn) for fn in fns] for seg in seg_idxs}) -def openpilotci_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames) -> dict[int, str]: +def openpilotci_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames, /) -> dict[int, str]: return eval_source({seg: [get_url(sr.route_name, seg, fn) for fn in fns] for seg in seg_idxs}) -def comma_car_segments_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames) -> dict[int, str]: +def comma_car_segments_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames, /) -> dict[int, str]: return eval_source({seg: get_comma_segments_url(sr.route_name, seg) for seg in seg_idxs}) diff --git a/openpilot/tools/lib/framereader.py b/openpilot/tools/lib/framereader.py index c923651563..62c75b95f7 100644 --- a/openpilot/tools/lib/framereader.py +++ b/openpilot/tools/lib/framereader.py @@ -28,7 +28,7 @@ class LRUCache: def __setitem__(self, key, value): self._cache[key] = value if len(self._cache) > self.capacity: - self._cache.popitem(last=False) + self._cache.popitem(last=False) def __contains__(self, key): return key in self._cache diff --git a/openpilot/tools/lib/log_time_series.py b/openpilot/tools/lib/log_time_series.py index 5e8aa4f73b..eba821bc84 100644 --- a/openpilot/tools/lib/log_time_series.py +++ b/openpilot/tools/lib/log_time_series.py @@ -80,5 +80,5 @@ if __name__ == "__main__": import sys from openpilot.tools.lib.logreader import LogReader m = msgs_to_time_series(LogReader(sys.argv[1])) - print(m['driverCameraState']['t']) - print(np.diff(m['driverCameraState']['timestampSof'])) + print(m['cabinCameraState']['t']) + print(np.diff(m['cabinCameraState']['timestampSof'])) diff --git a/openpilot/tools/lib/logreader.py b/openpilot/tools/lib/logreader.py index 805e411b53..fbfb28dbe0 100755 --- a/openpilot/tools/lib/logreader.py +++ b/openpilot/tools/lib/logreader.py @@ -22,6 +22,8 @@ from openpilot.tools.lib.file_sources import comma_api_source, internal_source, from openpilot.tools.lib.route import SegmentRange, FileName from openpilot.tools.lib.log_time_series import msgs_to_time_series +from openpilot.sunnypilot.tools.lib.sunnypilot_car_segments import sunnypilot_car_segments_source + LogMessage = type[capnp._DynamicStructReader] LogIterable = Iterable[LogMessage] RawLogIterable = Iterable[bytes] @@ -246,7 +248,7 @@ class LogReader: def __init__(self, identifier: str | list[str], default_mode: ReadMode = ReadMode.RLOG, sources: list[Source] | None = None, sort_by_time=False, only_union_types=False): if sources is None: - sources = [internal_source, comma_api_source, openpilotci_source, comma_car_segments_source] + sources = [internal_source, comma_api_source, openpilotci_source, comma_car_segments_source, sunnypilot_car_segments_source] self.default_mode = default_mode self.sources = sources diff --git a/openpilot/tools/lib/tests/test_caching.py b/openpilot/tools/lib/tests/test_caching.py index 0753ef1d3c..565bac40dd 100644 --- a/openpilot/tools/lib/tests/test_caching.py +++ b/openpilot/tools/lib/tests/test_caching.py @@ -3,8 +3,9 @@ import os import shutil import socket import tempfile -import pytest +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.selfdrive.test.helpers import http_server_context from openpilot.common.hardware.hw import Paths from openpilot.tools.lib.url_file import URLFile, prune_cache @@ -16,7 +17,7 @@ class CachingTestRequestHandler(http.server.BaseHTTPRequestHandler): def do_GET(self): if self.FILE_EXISTS: - self.send_response(206 if "Range" in self.headers else 200, b'1234') + self.send_response(206 if "Range" in self.headers else 200, '1234') else: self.send_response(404) self.end_headers() @@ -30,12 +31,11 @@ class CachingTestRequestHandler(http.server.BaseHTTPRequestHandler): self.end_headers() -@pytest.fixture def host(): with http_server_context(handler=CachingTestRequestHandler) as (host, port): yield f"http://{host}:{port}" -class TestFileDownload: +class TestFileDownload(OpenpilotTestCase): def test_pipeline_defaults(self, host): # TODO: parameterize the defaults so we don't rely on hard-coded values in xx @@ -59,7 +59,7 @@ class TestFileDownload: # ensure caching on by default and cache dir gets created os.environ.pop("DISABLE_FILEREADER_CACHE", None) if os.path.exists(Paths.download_cache_root()): - shutil.rmtree(Paths.download_cache_root()) + shutil.rmtree(Paths.download_cache_root(), ignore_errors=True) URLFile(f"{host}/test.txt").get_length() URLFile(f"{host}/test.txt").read() assert os.path.exists(Paths.download_cache_root()) @@ -117,7 +117,7 @@ class TestFileDownload: self.compare_loads(large_file_url, length - 100, 100) self.compare_loads(large_file_url) - @pytest.mark.parametrize("cache_enabled", [True, False]) + @parameterized.expand([True, False], names=("cache_enabled",)) def test_recover_from_missing_file(self, host, cache_enabled): if cache_enabled: os.environ.pop("DISABLE_FILEREADER_CACHE", None) @@ -135,7 +135,7 @@ class TestFileDownload: assert length == 4 -class TestCache: +class TestCache(OpenpilotTestCase): def test_prune_cache(self, monkeypatch): with tempfile.TemporaryDirectory() as tmpdir: monkeypatch.setattr(Paths, 'download_cache_root', staticmethod(lambda: tmpdir + "/")) diff --git a/openpilot/tools/lib/tests/test_comma_car_segments.py b/openpilot/tools/lib/tests/test_comma_car_segments.py index 1b0f07ee63..a678aad51d 100644 --- a/openpilot/tools/lib/tests/test_comma_car_segments.py +++ b/openpilot/tools/lib/tests/test_comma_car_segments.py @@ -1,13 +1,14 @@ -import pytest +import unittest import requests from opendbc.car.fingerprints import MIGRATION +from openpilot.common.test import OpenpilotTestCase from openpilot.tools.lib.comma_car_segments import get_comma_car_segments_database, get_url from openpilot.tools.lib.logreader import LogReader from openpilot.tools.lib.route import SegmentRange -@pytest.mark.skip(reason="huggingface is flaky, run this test manually to check for issues") -class TestCommaCarSegments: +@unittest.skip("huggingface is flaky, run this test manually to check for issues") +class TestCommaCarSegments(OpenpilotTestCase): def test_database(self): database = get_comma_car_segments_database() diff --git a/openpilot/tools/lib/tests/test_logreader.py b/openpilot/tools/lib/tests/test_logreader.py index a27a348d9e..9d5691abd8 100644 --- a/openpilot/tools/lib/tests/test_logreader.py +++ b/openpilot/tools/lib/tests/test_logreader.py @@ -4,15 +4,16 @@ import io import shutil import tempfile import os -import pytest +import unittest import requests +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized from openpilot.cereal import log as capnp_log -from openpilot.tools.lib.logreader import LogsUnavailable, LogIterable, LogReader, parse_indirect, ReadMode -from openpilot.tools.lib.file_sources import comma_api_source, InternalUnavailableException -from openpilot.tools.lib.route import SegmentRange +from openpilot.tools.lib.logreader import _LogFileReader, LogsUnavailable, LogIterable, LogReader, parse_indirect, ReadMode +from openpilot.tools.lib.file_sources import InternalUnavailableException +from openpilot.tools.lib.route import FileName, SegmentRange from openpilot.tools.lib.url_file import URLFileException NUM_SEGS = 17 # number of segments in the test route @@ -47,7 +48,46 @@ def setup_source_scenario(mocker, is_internal=False): yield -class TestLogReader: +class TestLogReader(OpenpilotTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.tmpdir = tempfile.TemporaryDirectory() + cls.qlog_path = os.path.join(cls.tmpdir.name, "qlog") + cls.rlog_path = os.path.join(cls.tmpdir.name, "rlog") + cls._write_test_log(cls.qlog_path, 10, include_car_params=True) + cls._write_test_log(cls.rlog_path, 100) + + @classmethod + def tearDownClass(cls): + cls.tmpdir.cleanup() + super().tearDownClass() + + @staticmethod + def _write_test_log(path, count, include_car_params=False): + events = [] + for i in range(count): + event = capnp_log.Event.new_message() + event.logMonoTime = count - i # deliberately unsorted + if include_car_params and i == 0: + event.init("carParams") + event.carParams.carFingerprint = "SUBARU OUTBACK 6TH GEN" + else: + event.init("can", 0) + events.append(event.to_bytes()) + + with open(path, "wb") as f: + f.write(b"".join(events)) + + def local_source(self, sr, seg_idxs, fns): + path = self.qlog_path if fns == FileName.QLOG else self.rlog_path + return dict.fromkeys(seg_idxs, path) + + def local_auto_source(self, sr, seg_idxs, fns): + if fns == FileName.RLOG: + return {} + return dict.fromkeys(seg_idxs, self.qlog_path) + @parameterized.expand([ (f"{TEST_ROUTE}", ALL_SEGS), (f"{TEST_ROUTE.replace('/', '|')}", ALL_SEGS), @@ -72,7 +112,7 @@ class TestLogReader: (f"https://useradmin.comma.ai/?onebox={TEST_ROUTE.replace('/', '|')}", ALL_SEGS), (f"https://useradmin.comma.ai/?onebox={TEST_ROUTE.replace('/', '%7C')}", ALL_SEGS), ]) - @pytest.mark.skip("this got flaky. internet tests are stupid.") + @unittest.skip("this got flaky. internet tests are stupid.") def test_indirect_parsing(self, identifier, expected): parsed = parse_indirect(identifier) sr = SegmentRange(parsed) @@ -90,7 +130,7 @@ class TestLogReader: sr = SegmentRange(identifier) assert str(sr) == expected - @pytest.mark.parametrize("cache_enabled", [True, False]) + @parameterized.expand([True, False], names=("cache_enabled",)) def test_direct_parsing(self, mocker, cache_enabled): file_exists_mock = mocker.patch("openpilot.tools.lib.filereader.file_exists") if cache_enabled: @@ -107,7 +147,7 @@ class TestLogReader: l = len(list(LogReader(f))) assert l > 100 - with pytest.raises(URLFileException) if not cache_enabled else pytest.raises(AssertionError): + with self.assertRaises(URLFileException if not cache_enabled else AssertionError): l = len(list(LogReader(QLOG_FILE.replace("/3/", "/200/")))) # file_exists should not be called for direct files @@ -126,47 +166,43 @@ class TestLogReader: (f"{TEST_ROUTE}--3a",), ]) def test_bad_ranges(self, segment_range): - with pytest.raises(AssertionError): + with self.assertRaises(AssertionError): _ = SegmentRange(segment_range).seg_idxs - @pytest.mark.parametrize("segment_range, api_call", [ + @parameterized.expand([ (f"{TEST_ROUTE}/0", False), (f"{TEST_ROUTE}/:2", False), (f"{TEST_ROUTE}/0:", True), (f"{TEST_ROUTE}/-1", True), (f"{TEST_ROUTE}", True), - ]) + ], names=("segment_range", "api_call")) def test_slicing_api_call(self, mocker, segment_range, api_call): max_seg_mock = mocker.patch("openpilot.tools.lib.route.get_max_seg_number_cached") max_seg_mock.return_value = NUM_SEGS _ = SegmentRange(segment_range).seg_idxs assert api_call == max_seg_mock.called - @pytest.mark.slow def test_modes(self): - qlog_len = len(list(LogReader(f"{TEST_ROUTE}/0", ReadMode.QLOG))) - rlog_len = len(list(LogReader(f"{TEST_ROUTE}/0", ReadMode.RLOG))) + qlog_len = len(list(LogReader(f"{TEST_ROUTE}/0", ReadMode.QLOG, sources=[self.local_source]))) + rlog_len = len(list(LogReader(f"{TEST_ROUTE}/0", ReadMode.RLOG, sources=[self.local_source]))) assert qlog_len * 6 < rlog_len - @pytest.mark.slow def test_modes_from_name(self): - qlog_len = len(list(LogReader(f"{TEST_ROUTE}/0/q"))) - rlog_len = len(list(LogReader(f"{TEST_ROUTE}/0/r"))) + qlog_len = len(list(LogReader(f"{TEST_ROUTE}/0/q", sources=[self.local_source]))) + rlog_len = len(list(LogReader(f"{TEST_ROUTE}/0/r", sources=[self.local_source]))) assert qlog_len * 6 < rlog_len - @pytest.mark.slow def test_list(self): - qlog_len = len(list(LogReader(f"{TEST_ROUTE}/0/q"))) - qlog_len_2 = len(list(LogReader([f"{TEST_ROUTE}/0/q", f"{TEST_ROUTE}/0/q"]))) + qlog_len = len(list(LogReader(self.qlog_path))) + qlog_len_2 = len(list(LogReader([self.qlog_path, self.qlog_path]))) assert qlog_len * 2 == qlog_len_2 - @pytest.mark.slow def test_multiple_iterations(self, mocker): - init_mock = mocker.patch("openpilot.tools.lib.logreader._LogFileReader") - lr = LogReader(f"{TEST_ROUTE}/0/q") + init_mock = mocker.patch("openpilot.tools.lib.logreader._LogFileReader", wraps=_LogFileReader) + lr = LogReader(self.qlog_path) qlog_len1 = len(list(lr)) qlog_len2 = len(list(lr)) @@ -175,47 +211,37 @@ class TestLogReader: assert qlog_len1 == qlog_len2 - @pytest.mark.slow def test_helpers(self): - lr = LogReader(f"{TEST_ROUTE}/0/q") + lr = LogReader(self.qlog_path) assert lr.first("carParams").carFingerprint == "SUBARU OUTBACK 6TH GEN" assert 0 < len(list(lr.filter("carParams"))) < len(list(lr)) - @parameterized.expand([(True,), (False,)]) - @pytest.mark.slow - def test_run_across_segments(self, cache_enabled): - if cache_enabled: - os.environ.pop("DISABLE_FILEREADER_CACHE", None) - else: - os.environ["DISABLE_FILEREADER_CACHE"] = "1" - lr = LogReader(f"{TEST_ROUTE}/0:4") + def test_run_across_segments(self): + lr = LogReader([self.qlog_path] * 4) assert len(lr.run_across_segments(4, noop)) == len(list(lr)) - @pytest.mark.slow def test_auto_mode(self, subtests, mocker): - lr = LogReader(f"{TEST_ROUTE}/0/q") + lr = LogReader(self.qlog_path) qlog_len = len(list(lr)) - log_paths_mock = mocker.patch("openpilot.tools.lib.route.Route.log_paths") - log_paths_mock.return_value = [None] * NUM_SEGS # Should fall back to qlogs since rlogs are not available with subtests.test("interactive_yes"): mocker.patch("sys.stdin", new=io.StringIO("y\n")) - lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO_INTERACTIVE, sources=[comma_api_source]) + lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO_INTERACTIVE, sources=[self.local_auto_source]) log_len = len(list(lr)) assert qlog_len == log_len with subtests.test("interactive_no"): mocker.patch("sys.stdin", new=io.StringIO("n\n")) - with pytest.raises(LogsUnavailable): - lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO_INTERACTIVE, sources=[comma_api_source]) + with self.assertRaises(LogsUnavailable): + lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO_INTERACTIVE, sources=[self.local_auto_source]) with subtests.test("non_interactive"): - lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO, sources=[comma_api_source]) + lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO, sources=[self.local_auto_source]) log_len = len(list(lr)) assert qlog_len == log_len - @pytest.mark.parametrize("is_internal", [True, False]) + @parameterized.expand([True, False], names=("is_internal",)) def test_auto_source_scenarios(self, mocker, is_internal): lr = LogReader(QLOG_FILE) qlog_len = len(list(lr)) @@ -225,12 +251,11 @@ class TestLogReader: log_len = len(list(lr)) assert qlog_len == log_len - @pytest.mark.slow def test_sort_by_time(self): - msgs = list(LogReader(f"{TEST_ROUTE}/0/q")) + msgs = list(LogReader(self.qlog_path)) assert msgs != sorted(msgs, key=lambda m: m.logMonoTime) - msgs = list(LogReader(f"{TEST_ROUTE}/0/q", sort_by_time=True)) + msgs = list(LogReader(self.qlog_path, sort_by_time=True)) assert msgs == sorted(msgs, key=lambda m: m.logMonoTime) def test_only_union_types(self): @@ -254,7 +279,7 @@ class TestLogReader: # ensure new message is added, but is not a union type msgs = list(LogReader(qlog.name)) assert len(msgs) == num_msgs + 1 - with pytest.raises(capnp.KjException): + with self.assertRaises(capnp.KjException): [m.which() for m in msgs] # should not be added when only_union_types=True diff --git a/openpilot/tools/lib/tests/test_route_library.py b/openpilot/tools/lib/tests/test_route_library.py index 491bb81327..392484c794 100644 --- a/openpilot/tools/lib/tests/test_route_library.py +++ b/openpilot/tools/lib/tests/test_route_library.py @@ -1,8 +1,9 @@ from collections import namedtuple +from openpilot.common.test import OpenpilotTestCase from openpilot.tools.lib.route import SegmentName -class TestRouteLibrary: +class TestRouteLibrary(OpenpilotTestCase): def test_segment_name_formats(self): Case = namedtuple('Case', ['input', 'expected_route', 'expected_segment_num', 'expected_data_dir']) diff --git a/openpilot/tools/lib/url_file.py b/openpilot/tools/lib/url_file.py index 3f72429b94..ec0a3d5815 100644 --- a/openpilot/tools/lib/url_file.py +++ b/openpilot/tools/lib/url_file.py @@ -21,7 +21,7 @@ logging.getLogger("urllib3").setLevel(logging.WARNING) def hash_url(link: str) -> str: - return md5((link.split("?")[0]).encode('utf-8')).hexdigest() + return md5(link.split("?", maxsplit=1)[0].encode('utf-8')).hexdigest() def prune_cache(new_entry: str | None = None) -> None: diff --git a/openpilot/tools/lib/vidindex.py b/openpilot/tools/lib/vidindex.py index 4aef6fb4d5..b200700887 100755 --- a/openpilot/tools/lib/vidindex.py +++ b/openpilot/tools/lib/vidindex.py @@ -269,7 +269,7 @@ def hevc_index(hevc_file_name: str, allow_corrupt: bool=False) -> tuple[list, in raise VideoFileInvalid("first byte must be 0x00") prefix_dat = b"" - frame_types = list() + frame_types = [] i = 1 # skip past first byte 0x00 try: diff --git a/openpilot/tools/longitudinal_maneuvers/generate_report.py b/openpilot/tools/longitudinal_maneuvers/generate_report.py index dbd9f6db91..2d7f81a7f8 100755 --- a/openpilot/tools/longitudinal_maneuvers/generate_report.py +++ b/openpilot/tools/longitudinal_maneuvers/generate_report.py @@ -44,14 +44,14 @@ def report(platform, route, _description, CP, ID, maneuvers): t_carControl, carControl = zip(*[(m.logMonoTime, m.carControl) for m in msgs if m.which() == 'carControl'], strict=True) t_carOutput, carOutput = zip(*[(m.logMonoTime, m.carOutput) for m in msgs if m.which() == 'carOutput'], strict=True) t_carState, carState = zip(*[(m.logMonoTime, m.carState) for m in msgs if m.which() == 'carState'], strict=True) - t_livePose, livePose = zip(*[(m.logMonoTime, m.livePose) for m in msgs if m.which() == 'livePose'], strict=True) + t_deviceMotion, deviceMotion = zip(*[(m.logMonoTime, m.deviceMotion) for m in msgs if m.which() == 'deviceMotion'], strict=True) t_longitudinalPlan, longitudinalPlan = zip(*[(m.logMonoTime, m.longitudinalPlan) for m in msgs if m.which() == 'longitudinalPlan'], strict=True) # make time relative seconds t_carControl = [(t - t_carControl[0]) / 1e9 for t in t_carControl] t_carOutput = [(t - t_carOutput[0]) / 1e9 for t in t_carOutput] t_carState = [(t - t_carState[0]) / 1e9 for t in t_carState] - t_livePose = [(t - t_livePose[0]) / 1e9 for t in t_livePose] + t_deviceMotion = [(t - t_deviceMotion[0]) / 1e9 for t in t_deviceMotion] t_longitudinalPlan = [(t - t_longitudinalPlan[0]) / 1e9 for t in t_longitudinalPlan] # maneuver validity @@ -70,7 +70,7 @@ def report(platform, route, _description, CP, ID, maneuvers): # Localizer is noisy, require two consecutive 20Hz frames above threshold prev_crossed = False - for t, lp in zip(t_livePose, livePose, strict=True): + for t, lp in zip(t_deviceMotion, deviceMotion, strict=True): crossed = (0 < aTarget < lp.accelerationDevice.x) or (0 > aTarget > lp.accelerationDevice.x) if crossed and prev_crossed: builder.append(f', crossed in {t:.3f}s') @@ -95,7 +95,7 @@ def report(platform, route, _description, CP, ID, maneuvers): ax[0].plot(t_carOutput, [m.actuatorsOutput.accel for m in carOutput], label='carOutput.actuatorsOutput.accel', linewidth=6) ax[0].plot(t_longitudinalPlan, [m.aTarget for m in longitudinalPlan], label='longitudinalPlan.aTarget', linewidth=6) ax[0].plot(t_carState, [m.aEgo for m in carState], label='carState.aEgo', linewidth=6) - ax[0].plot(t_livePose, [m.accelerationDevice.x for m in livePose], label='livePose.accelerationDevice.x', linewidth=6) + ax[0].plot(t_deviceMotion, [m.accelerationDevice.x for m in deviceMotion], label='deviceMotion.accelerationDevice.x', linewidth=6) # TODO localizer accel ax[0].set_ylabel('Acceleration (m/s^2)') #ax[0].set_ylim(-6.5, 6.5) diff --git a/openpilot/tools/longitudinal_maneuvers/maneuversd.py b/openpilot/tools/longitudinal_maneuvers/maneuversd.py index 1ad5370f20..3cba805f00 100755 --- a/openpilot/tools/longitudinal_maneuvers/maneuversd.py +++ b/openpilot/tools/longitudinal_maneuvers/maneuversd.py @@ -3,11 +3,11 @@ import numpy as np from dataclasses import dataclass from openpilot.cereal import messaging -from opendbc.car.structs import car from openpilot.common.constants import CV from openpilot.common.realtime import DT_MDL from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.controls.lib.drive_helpers import should_stop @dataclass @@ -59,7 +59,7 @@ class Maneuver: return float(action_accel) - def get_accel(self, v_ego: float, long_active: bool, standstill: bool, cruise_standstill: bool) -> float: + def get_accel(self, v_ego: float, long_active: bool, standstill: bool, cruise_standstill: bool, /) -> float: ready = abs(v_ego - self.initial_speed) < 0.3 and long_active and not cruise_standstill if self.initial_speed < 0.01: ready = ready and standstill @@ -117,8 +117,8 @@ MANEUVERS = [ initial_speed=20. * CV.MPH_TO_MS, ), Maneuver( - "brake step response: -4m/s^2 from 20mph", - [Action([-4], [3])], + "brake step response: -3.5m/s^2 from 20mph", + [Action([-3.5], [3])], repeat=2, initial_speed=20. * CV.MPH_TO_MS, ), @@ -129,8 +129,8 @@ MANEUVERS = [ initial_speed=20. * CV.MPH_TO_MS, ), Maneuver( - "gas step response: +4m/s^2 from 20mph", - [Action([4], [3])], + "gas step response: +2m/s^2 from 20mph", + [Action([2], [3])], repeat=2, initial_speed=20. * CV.MPH_TO_MS, ), @@ -139,8 +139,8 @@ MANEUVERS = [ def main(): params = Params() - cloudlog.info("joystickd is waiting for CarParams") - CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) + cloudlog.info("maneuversd is waiting for CarParams") + params.get("CarParams", block=True) sm = messaging.SubMaster(['carState', 'carControl', 'controlsState', 'selfdriveState', 'modelV2'], poll='modelV2') pm = messaging.PubMaster(['longitudinalPlan', 'longitudinalPlanSP', 'driverAssistance', 'alertDebug']) @@ -178,7 +178,7 @@ def main(): pm.send('alertDebug', alert_msg) longitudinalPlan.aTarget = accel - longitudinalPlan.shouldStop = v_ego < CP.vEgoStopping and accel < 1e-2 + longitudinalPlan.shouldStop = should_stop(v_ego, accel) longitudinalPlan.allowBrake = True longitudinalPlan.allowThrottle = True diff --git a/openpilot/tools/plotjuggler/README.md b/openpilot/tools/plotjuggler/README.md index efccbdc0bd..e87fc2c3d4 100644 --- a/openpilot/tools/plotjuggler/README.md +++ b/openpilot/tools/plotjuggler/README.md @@ -4,7 +4,7 @@ ## Installation -Once you've [set up the openpilot environment](../README.md), this command will download PlotJuggler and install our plugins: +Once you've [set up the openpilot environment](/tools/README.md), this command will download PlotJuggler and install our plugins: `cd openpilot/tools/plotjuggler && ./juggle.py --install` diff --git a/openpilot/tools/plotjuggler/layouts/camera-timings.xml b/openpilot/tools/plotjuggler/layouts/camera-timings.xml index f91cbd3f53..2abff4d508 100644 --- a/openpilot/tools/plotjuggler/layouts/camera-timings.xml +++ b/openpilot/tools/plotjuggler/layouts/camera-timings.xml @@ -8,13 +8,13 @@ - - + + - - + + @@ -29,13 +29,13 @@ - - + + - - + + @@ -76,8 +76,8 @@ - - + + @@ -91,13 +91,13 @@ - - + + - - + + @@ -112,13 +112,13 @@ - - + + - - + + @@ -145,4 +145,3 @@ - diff --git a/openpilot/tools/plotjuggler/layouts/locationd_debug.xml b/openpilot/tools/plotjuggler/layouts/locationd_debug.xml index 5377e1535c..6e1cd35039 100644 --- a/openpilot/tools/plotjuggler/layouts/locationd_debug.xml +++ b/openpilot/tools/plotjuggler/layouts/locationd_debug.xml @@ -8,7 +8,7 @@ - + @@ -36,7 +36,7 @@ - + @@ -97,4 +97,3 @@ - diff --git a/openpilot/tools/plotjuggler/layouts/max-torque-debug.xml b/openpilot/tools/plotjuggler/layouts/max-torque-debug.xml index 9a6693165e..2089b826d9 100644 --- a/openpilot/tools/plotjuggler/layouts/max-torque-debug.xml +++ b/openpilot/tools/plotjuggler/layouts/max-torque-debug.xml @@ -62,7 +62,7 @@ return 0 /controlsState/curvature /carState/vEgo - /liveParameters/roll + /vehicleParameters/roll /carState/steeringPressed /carControl/latActive @@ -73,7 +73,7 @@ return 0 /controlsState/desiredCurvature /carState/vEgo - /liveParameters/roll + /vehicleParameters/roll @@ -82,11 +82,10 @@ return 0 /controlsState/curvature /carState/vEgo - /liveParameters/roll + /vehicleParameters/roll - diff --git a/openpilot/tools/plotjuggler/layouts/torque-controller.xml b/openpilot/tools/plotjuggler/layouts/torque-controller.xml index 8e9a1a8526..671b47c355 100644 --- a/openpilot/tools/plotjuggler/layouts/torque-controller.xml +++ b/openpilot/tools/plotjuggler/layouts/torque-controller.xml @@ -53,7 +53,7 @@ - + @@ -61,15 +61,15 @@ - + - + - - + + @@ -82,8 +82,8 @@ - - + + @@ -91,16 +91,16 @@ - - + + - - + + @@ -114,15 +114,15 @@ - + - - + + @@ -130,7 +130,7 @@ - + @@ -174,7 +174,7 @@ - + @@ -221,7 +221,7 @@ /controlsState/desiredCurvature /carState/vEgo - /liveParameters/roll + /vehicleParameters/roll @@ -230,7 +230,7 @@ /controlsState/curvature /carState/vEgo - /liveParameters/roll + /vehicleParameters/roll @@ -247,4 +247,3 @@ - diff --git a/openpilot/tools/plotjuggler/test_plotjuggler.py b/openpilot/tools/plotjuggler/test_plotjuggler.py index d55aafe9be..10cd342dc8 100644 --- a/openpilot/tools/plotjuggler/test_plotjuggler.py +++ b/openpilot/tools/plotjuggler/test_plotjuggler.py @@ -5,17 +5,18 @@ import signal import subprocess import time -import pytest +import unittest +from openpilot.common.test import OpenpilotTestCase from openpilot.common.basedir import BASEDIR from openpilot.common.timeout import Timeout from openpilot.tools.plotjuggler.juggle import DEMO_ROUTE, install PJ_DIR = os.path.join(BASEDIR, "openpilot/tools/plotjuggler") -class TestPlotJuggler: +class TestPlotJuggler(OpenpilotTestCase): - @pytest.mark.skipif(not shutil.which('qmake'), reason="Qt not installed") + @unittest.skipIf(not shutil.which('qmake'), "Qt not installed") def test_demo(self): install() diff --git a/openpilot/tools/replay/README.md b/openpilot/tools/replay/README.md index 108df08935..1d500eac96 100644 --- a/openpilot/tools/replay/README.md +++ b/openpilot/tools/replay/README.md @@ -68,8 +68,8 @@ Options: internal, openpilotci, comma_api, car_segments, testing_closet --data_dir local directory with routes --prefix set OPENPILOT_PREFIX - --dcam load driver camera - --ecam load wide road camera + --cabin load cabin camera + --wide-road load wide road camera --no-loop stop at the end of the route --no-cache turn off local cache --qcam load qcamera @@ -103,11 +103,11 @@ openpilot/tools/plotjuggler/juggle.py --stream watch all three cameras simultaneously from your comma three routes with watch3 -simply replay a route using the `--dcam` and `--ecam` flags: +simply replay a route using the `--cabin` and `--wide-road` flags: ```bash # start a replay -cd openpilot/tools/replay && ./replay --demo --dcam --ecam +cd openpilot/tools/replay && ./replay --demo --cabin --wide-road # then start watch3 cd openpilot/selfdrive/ui && ./watch3.py diff --git a/openpilot/tools/replay/SConscript b/openpilot/tools/replay/SConscript index 643de97bb9..9e060bc829 100644 --- a/openpilot/tools/replay/SConscript +++ b/openpilot/tools/replay/SConscript @@ -9,11 +9,8 @@ base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] replay_lib_src = ["replay.cc", "consoleui.cc", "camera.cc", "filereader.cc", "logreader.cc", "framereader.cc", "route.cc", "util.cc", "seg_mgr.cc", "timeline.cc", "py_downloader.cc"] if arch != "Darwin": - replay_lib_src.append("qcom_decoder.cc") + replay_lib_src.append("#openpilot/system/loggerd/encoder/v4l_decoder.cc") replay_lib = replay_env.Library("replay", replay_lib_src, LIBS=base_libs, FRAMEWORKS=base_frameworks) Export('replay_lib') -replay_libs = [replay_lib] + ffmpeg_libs + ['bz2', 'zstd', 'ncurses'] + base_libs +replay_libs = [replay_lib] + ffmpeg_libs + ['ncurses'] + base_libs replay_env.Program("replay", ["main.cc"], LIBS=replay_libs, FRAMEWORKS=base_frameworks) - -if GetOption('extras'): - replay_env.Program('tests/test_replay', ['tests/test_replay.cc'], LIBS=replay_libs) diff --git a/openpilot/tools/replay/camera.h b/openpilot/tools/replay/camera.h index 9433018848..81b3bb1a81 100644 --- a/openpilot/tools/replay/camera.h +++ b/openpilot/tools/replay/camera.h @@ -5,6 +5,7 @@ #include #include +#include "openpilot/cereal/visionstream.h" #include "msgq/visionipc/visionipc_server.h" #include "common/queue.h" #include "tools/replay/framereader.h" @@ -32,8 +33,8 @@ protected: VisionBuf *getFrame(Camera &cam, FrameReader *fr, int32_t segment_id, uint32_t frame_id); Camera cameras_[MAX_CAMERAS] = { - {.type = RoadCam, .stream_type = VISION_STREAM_ROAD}, - {.type = DriverCam, .stream_type = VISION_STREAM_DRIVER}, + {.type = NarrowRoadCam, .stream_type = VISION_STREAM_NARROW_ROAD}, + {.type = CabinCam, .stream_type = VISION_STREAM_CABIN}, {.type = WideRoadCam, .stream_type = VISION_STREAM_WIDE_ROAD}, }; std::atomic publishing_ = 0; diff --git a/openpilot/tools/replay/consoleui.cc b/openpilot/tools/replay/consoleui.cc index eeb385f8a4..6dbc984b2f 100644 --- a/openpilot/tools/replay/consoleui.cc +++ b/openpilot/tools/replay/consoleui.cc @@ -62,7 +62,7 @@ ExitHandler do_exit; } // namespace -ConsoleUI::ConsoleUI(Replay *replay) : replay(replay), sm({"carState", "liveParameters"}) { +ConsoleUI::ConsoleUI(Replay *replay) : replay(replay), sm({"carState", "vehicleParameters"}) { // Initialize curses initscr(); clear(); @@ -176,7 +176,7 @@ void ConsoleUI::updateStatus() { std::string current_segment = " - " + std::to_string((int)(replay->currentSeconds() / 60)); write_item(0, 25, "TIME: ", time_string, current_segment, true); - auto p = sm["liveParameters"].getLiveParameters(); + auto p = sm["vehicleParameters"].getVehicleParameters(); write_item(1, 0, "STIFFNESS: ", util::string_format("%.2f %%", p.getStiffnessFactor() * 100), " "); write_item(1, 25, "SPEED: ", util::string_format("%.2f", sm["carState"].getCarState().getVEgo()), " m/s"); write_item(2, 0, "STEER RATIO: ", util::string_format("%.2f", p.getSteerRatio()), ""); diff --git a/openpilot/tools/replay/filereader.cc b/openpilot/tools/replay/filereader.cc index 93a6a1193f..6ad56bbfc6 100644 --- a/openpilot/tools/replay/filereader.cc +++ b/openpilot/tools/replay/filereader.cc @@ -1,5 +1,8 @@ #include "tools/replay/filereader.h" +#include +#include + #include "common/util.h" #include "tools/replay/py_downloader.h" @@ -10,5 +13,17 @@ std::string FileReader::read(const std::string &file, std::atomic *abort) if (local_path.empty()) return {}; return util::read_file(local_path); } + char header[4] = {}; + std::ifstream stream(file, std::ios::binary); + stream.read(header, sizeof(header)); + const std::string magic(header, stream.gcount()); + if (util::ends_with(file, ".bz2") || util::ends_with(file, ".zst") || + util::starts_with(magic, "BZh") || magic == "\x28\xB5\x2F\xFD") { + std::string local_path = PyDownloader::decompress(file, abort); + if (local_path.empty()) return {}; + std::string data = util::read_file(local_path); + unlink(local_path.c_str()); + return data; + } return util::read_file(file); } diff --git a/openpilot/tools/replay/framereader.cc b/openpilot/tools/replay/framereader.cc index 7c6f144148..19e5fa0d0f 100644 --- a/openpilot/tools/replay/framereader.cc +++ b/openpilot/tools/replay/framereader.cc @@ -42,7 +42,7 @@ struct DecoderManager { std::unique_ptr decoder; #ifndef __APPLE__ if (!Hardware::PC() && hw_decoder) { - decoder = std::make_unique(); + decoder = std::make_unique(); } else #endif { @@ -269,18 +269,18 @@ bool FFmpegVideoDecoder::copyBuffer(AVFrame *f, VisionBuf *buf) { } #ifndef __APPLE__ -bool QcomVideoDecoder::open(AVCodecParameters *codecpar, bool hw_decoder) { +bool V4LVideoDecoder::open(AVCodecParameters *codecpar, bool hw_decoder) { if (codecpar->codec_id != AV_CODEC_ID_HEVC) { rError("Hardware decoder only supports HEVC codec"); return false; } width = codecpar->width; height = codecpar->height; - msm_vidc.init(VIDEO_DEVICE, width, height, V4L2_PIX_FMT_HEVC); + v4l_decoder.init(V4LDecoder::DEVICE, width, height, V4L2_PIX_FMT_HEVC); return true; } -bool QcomVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) { +bool V4LVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) { int from_idx = idx; if (idx != reader->prev_idx + 1) { // seeking to the nearest key frame @@ -301,10 +301,10 @@ bool QcomVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) { reader->prev_idx = idx; bool result = false; AVPacket pkt; - msm_vidc.avctx = reader->input_ctx; + v4l_decoder.avctx = reader->input_ctx; for (int i = from_idx; i <= idx; ++i) { if (av_read_frame(reader->input_ctx, &pkt) == 0) { - result = msm_vidc.decodeFrame(&pkt, buf) && (i == idx); + result = v4l_decoder.decodeFrame(&pkt, buf) && (i == idx); av_packet_unref(&pkt); } } diff --git a/openpilot/tools/replay/framereader.h b/openpilot/tools/replay/framereader.h index 3609d64f8b..65feb5b3b7 100644 --- a/openpilot/tools/replay/framereader.h +++ b/openpilot/tools/replay/framereader.h @@ -7,7 +7,7 @@ #include "tools/replay/util.h" #ifndef __APPLE__ -#include "tools/replay/qcom_decoder.h" +#include "system/loggerd/encoder/v4l_decoder.h" #endif extern "C" { @@ -67,14 +67,14 @@ private: }; #ifndef __APPLE__ -class QcomVideoDecoder : public VideoDecoder { +class V4LVideoDecoder : public VideoDecoder { public: - QcomVideoDecoder() {}; - ~QcomVideoDecoder() override {}; + V4LVideoDecoder() {}; + ~V4LVideoDecoder() override {}; bool open(AVCodecParameters *codecpar, bool hw_decoder) override; bool decode(FrameReader *reader, int idx, VisionBuf *buf) override; private: - MsmVidc msm_vidc = MsmVidc(); + V4LDecoder v4l_decoder; }; #endif diff --git a/openpilot/tools/replay/lib/ui_helpers.py b/openpilot/tools/replay/lib/ui_helpers.py index 6a3e8c20ed..b9da0d225f 100644 --- a/openpilot/tools/replay/lib/ui_helpers.py +++ b/openpilot/tools/replay/lib/ui_helpers.py @@ -5,6 +5,7 @@ import numpy as np import pyray as rl from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.artist import Artist from matplotlib.offsetbox import AnchoredOffsetbox, HPacker, TextArea from openpilot.common.transformations.camera import get_view_frame_from_calib_frame @@ -119,11 +120,11 @@ def init_plots(arr, name_to_arr_idx, plot_xlims, plot_ylims, plot_names, plot_co idxs.append(name_to_arr_idx[item]) plot_select.append(i) # Build colored title: each label colored to match its plot line - title_texts = [] + title_texts: list[Artist] = [] for j2, (nm, cl) in enumerate(zip(pl_list, plot_colors[i], strict=False)): if j2 > 0: - title_texts.append(TextArea(", ", textprops=dict(color="white", fontsize=10))) - title_texts.append(TextArea(nm, textprops=dict(color=label_palette[cl], fontsize=10))) + title_texts.append(TextArea(", ", textprops={"color": "white", "fontsize": 10})) + title_texts.append(TextArea(nm, textprops={"color": label_palette[cl], "fontsize": 10})) packed = HPacker(children=title_texts, pad=0, sep=0) ab = AnchoredOffsetbox(loc='lower center', child=packed, bbox_to_anchor=(0.5, 1.0), bbox_transform=axs[i].transAxes, frameon=False, pad=0) diff --git a/openpilot/tools/replay/logreader.cc b/openpilot/tools/replay/logreader.cc index 54b69dc168..315aed9741 100644 --- a/openpilot/tools/replay/logreader.cc +++ b/openpilot/tools/replay/logreader.cc @@ -32,16 +32,6 @@ bool LogReader::load(const std::string &url, std::atomic *abort, bool loca } compressed_size_ = data.size(); download_seconds_ = std::chrono::duration(download_end - download_start).count(); - if (!data.empty()) { - const auto decompress_start = Clock::now(); - if (url.find(".bz2") != std::string::npos || util::starts_with(data, "BZh9")) { - data = decompressBZ2(data, abort); - } else if (url.find(".zst") != std::string::npos || util::starts_with(data, "\x28\xB5\x2F\xFD")) { - data = decompressZST(data, abort); - } - const auto decompress_end = Clock::now(); - decompress_seconds_ = std::chrono::duration(decompress_end - decompress_start).count(); - } decompressed_size_ = data.size(); bool success = !data.empty() && load(data.data(), data.size(), abort, progress); @@ -84,8 +74,8 @@ bool LogReader::load(const char *data, size_t size, std::atomic *abort, uint64_t mono_time = event.getLogMonoTime(); const Event &evt = events.emplace_back(which, mono_time, event_data); // Add encodeIdx packet again as a frame packet for the video stream - if (evt.which == cereal::Event::ROAD_ENCODE_IDX || - evt.which == cereal::Event::DRIVER_ENCODE_IDX || + if (evt.which == cereal::Event::NARROW_ROAD_ENCODE_IDX || + evt.which == cereal::Event::CABIN_ENCODE_IDX || evt.which == cereal::Event::WIDE_ROAD_ENCODE_IDX) { auto idx = capnp::AnyStruct::Reader(event).getPointerSection()[0].getAs(); if (idx.getType() == cereal::EncodeIndex::Type::FULL_H_E_V_C) { diff --git a/openpilot/tools/replay/logreader.h b/openpilot/tools/replay/logreader.h index 63f7468401..52e82c46ad 100644 --- a/openpilot/tools/replay/logreader.h +++ b/openpilot/tools/replay/logreader.h @@ -8,7 +8,7 @@ #include "openpilot/cereal/gen/cpp/log.capnp.h" #include "tools/replay/util.h" -const CameraType ALL_CAMERAS[] = {RoadCam, DriverCam, WideRoadCam}; +const CameraType ALL_CAMERAS[] = {NarrowRoadCam, CabinCam, WideRoadCam}; const int MAX_CAMERAS = std::size(ALL_CAMERAS); class Event { diff --git a/openpilot/tools/replay/main.cc b/openpilot/tools/replay/main.cc index bdb9cf4f35..50233189a1 100644 --- a/openpilot/tools/replay/main.cc +++ b/openpilot/tools/replay/main.cc @@ -26,8 +26,8 @@ Options: internal, openpilotci, comma_api, car_segments, testing_closet -d, --data_dir Local directory with routes -p, --prefix Set OPENPILOT_PREFIX - --dcam Load driver camera - --ecam Load wide road camera + --cabin Load cabin camera (alias: --dcam) + --wide-road Load wide road camera (alias: --ecam) --no-loop Stop at the end of the route --no-cache Turn off local cache --qcam Load qcamera @@ -62,8 +62,10 @@ bool parseArgs(int argc, char *argv[], ReplayConfig &config) { {"auto", no_argument, nullptr, 0}, {"data_dir", required_argument, nullptr, 'd'}, {"prefix", required_argument, nullptr, 'p'}, - {"dcam", no_argument, nullptr, 0}, - {"ecam", no_argument, nullptr, 0}, + {"cabin", no_argument, nullptr, 0}, + {"dcam", no_argument, nullptr, 0}, // deprecated alias + {"wide-road", no_argument, nullptr, 0}, + {"ecam", no_argument, nullptr, 0}, // deprecated alias {"no-loop", no_argument, nullptr, 0}, {"no-cache", no_argument, nullptr, 0}, {"qcam", no_argument, nullptr, 0}, @@ -76,8 +78,10 @@ bool parseArgs(int argc, char *argv[], ReplayConfig &config) { }; const std::map flag_map = { - {"dcam", REPLAY_FLAG_DCAM}, - {"ecam", REPLAY_FLAG_ECAM}, + {"cabin", REPLAY_FLAG_CABIN_CAMERA}, + {"dcam", REPLAY_FLAG_CABIN_CAMERA}, // deprecated alias + {"wide-road", REPLAY_FLAG_WIDE_ROAD}, + {"ecam", REPLAY_FLAG_WIDE_ROAD}, // deprecated alias {"no-loop", REPLAY_FLAG_NO_LOOP}, {"no-cache", REPLAY_FLAG_NO_FILE_CACHE}, {"qcam", REPLAY_FLAG_QCAMERA}, diff --git a/openpilot/tools/replay/py_downloader.cc b/openpilot/tools/replay/py_downloader.cc index d27a77e6ee..a265dfd6a3 100644 --- a/openpilot/tools/replay/py_downloader.cc +++ b/openpilot/tools/replay/py_downloader.cc @@ -152,6 +152,10 @@ std::string download(const std::string &url, bool use_cache, std::atomic * return runPython(args, abort); } +std::string decompress(const std::string &path, std::atomic *abort) { + return runPython({"decompress", path}, abort); +} + std::string getRouteFiles(const std::string &route) { return runPython({"route-files", route}); } diff --git a/openpilot/tools/replay/py_downloader.h b/openpilot/tools/replay/py_downloader.h index 535189784c..80fab6ab00 100644 --- a/openpilot/tools/replay/py_downloader.h +++ b/openpilot/tools/replay/py_downloader.h @@ -12,6 +12,9 @@ namespace PyDownloader { // Downloads url to local cache, returns local file path. Reports progress via installDownloadProgressHandler. std::string download(const std::string &url, bool use_cache = true, std::atomic *abort = nullptr); +// Decompresses a local log file and returns the temporary output path. +std::string decompress(const std::string &path, std::atomic *abort = nullptr); + // Returns JSON string of route files (same format as /v1/route/.../files API) std::string getRouteFiles(const std::string &route); diff --git a/openpilot/tools/replay/qcom_decoder.h b/openpilot/tools/replay/qcom_decoder.h deleted file mode 100644 index 1d7522cc70..0000000000 --- a/openpilot/tools/replay/qcom_decoder.h +++ /dev/null @@ -1,88 +0,0 @@ -#pragma once - -#include -#include - -#include "msgq/visionipc/visionbuf.h" - -extern "C" { - #include - #include -} - -#define V4L2_EVENT_MSM_VIDC_START (V4L2_EVENT_PRIVATE_START + 0x00001000) -#define V4L2_EVENT_MSM_VIDC_FLUSH_DONE (V4L2_EVENT_MSM_VIDC_START + 1) -#define V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT (V4L2_EVENT_MSM_VIDC_START + 3) -#define V4L2_CID_MPEG_MSM_VIDC_BASE 0x00992000 -#define V4L2_CID_MPEG_VIDC_VIDEO_DPB_COLOR_FORMAT (V4L2_CID_MPEG_MSM_VIDC_BASE + 44) -#define V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_MODE (V4L2_CID_MPEG_MSM_VIDC_BASE + 22) -#define V4L2_QCOM_CMD_FLUSH_CAPTURE (1 << 1) -#define V4L2_QCOM_CMD_FLUSH (4) - -#define VIDEO_DEVICE "/dev/video32" -#define OUTPUT_BUFFER_COUNT 8 -#define CAPTURE_BUFFER_COUNT 8 -#define FPS 20 - - -class MsmVidc { -public: - MsmVidc() = default; - ~MsmVidc(); - - bool init(const char* dev, size_t width, size_t height, uint64_t codec); - VisionBuf* decodeFrame(AVPacket* pkt, VisionBuf* buf); - - AVFormatContext* avctx = nullptr; - int fd = 0; - -private: - bool initialized = false; - bool reconfigure_pending = false; - bool frame_ready = false; - - VisionBuf* current_output_buf = nullptr; - VisionBuf out_buf; // Single input buffer - VisionBuf cap_bufs[CAPTURE_BUFFER_COUNT]; // Capture (output) buffers - - size_t w = 1928, h = 1208; - size_t cap_height = 0, cap_width = 0; - - int cap_buf_size = 0; - int out_buf_size = 0; - - size_t cap_plane_off[CAPTURE_BUFFER_COUNT] = {0}; - size_t cap_plane_stride[CAPTURE_BUFFER_COUNT] = {0}; - bool cap_buf_flag[CAPTURE_BUFFER_COUNT] = {false}; - - size_t out_buf_off[OUTPUT_BUFFER_COUNT] = {0}; - void* out_buf_addr[OUTPUT_BUFFER_COUNT] = {0}; - bool out_buf_flag[OUTPUT_BUFFER_COUNT] = {false}; - const int out_buf_cnt = OUTPUT_BUFFER_COUNT; - - const int subscriptions[2] = { - V4L2_EVENT_MSM_VIDC_FLUSH_DONE, - V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT - }; - - enum { EV_VIDEO, EV_COUNT }; - struct pollfd pfd[EV_COUNT] = {0}; - int ev[EV_COUNT] = {-1}; - int nfds = 0; - - VisionBuf* processEvents(); - bool setupOutput(); - bool subscribeEvents(); - bool setPlaneFormat(v4l2_buf_type type, uint32_t fourcc); - bool setFPS(uint32_t fps); - bool restartCapture(); - bool queueCaptureBuffer(int i); - bool queueOutputBuffer(int i, size_t size); - bool setDBP(); - bool setupPolling(); - bool sendPacket(int buf_index, AVPacket* pkt); - int getBufferUnlocked(); - VisionBuf* handleCapture(); - bool handleOutput(); - bool handleEvent(); -}; diff --git a/openpilot/tools/replay/replay.cc b/openpilot/tools/replay/replay.cc index 75aecfd0c2..6449fc30ea 100644 --- a/openpilot/tools/replay/replay.cc +++ b/openpilot/tools/replay/replay.cc @@ -251,13 +251,13 @@ void Replay::publishMessage(const Event *e) { void Replay::publishFrame(const Event *e) { CameraType cam; switch (e->which) { - case cereal::Event::ROAD_ENCODE_IDX: cam = RoadCam; break; - case cereal::Event::DRIVER_ENCODE_IDX: cam = DriverCam; break; + case cereal::Event::NARROW_ROAD_ENCODE_IDX: cam = NarrowRoadCam; break; + case cereal::Event::CABIN_ENCODE_IDX: cam = CabinCam; break; case cereal::Event::WIDE_ROAD_ENCODE_IDX: cam = WideRoadCam; break; default: return; // Invalid event type } - if ((cam == DriverCam && !hasFlag(REPLAY_FLAG_DCAM)) || (cam == WideRoadCam && !hasFlag(REPLAY_FLAG_ECAM))) + if ((cam == CabinCam && !hasFlag(REPLAY_FLAG_CABIN_CAMERA)) || (cam == WideRoadCam && !hasFlag(REPLAY_FLAG_WIDE_ROAD))) return; // Camera isdisabled auto seg_it = event_data_->segments.find(e->eidx_segnum); diff --git a/openpilot/tools/replay/replay.h b/openpilot/tools/replay/replay.h index ce6d75bd6d..7cd5fbecd0 100644 --- a/openpilot/tools/replay/replay.h +++ b/openpilot/tools/replay/replay.h @@ -16,8 +16,8 @@ enum REPLAY_FLAGS { REPLAY_FLAG_NONE = 0x0000, - REPLAY_FLAG_DCAM = 0x0002, - REPLAY_FLAG_ECAM = 0x0004, + REPLAY_FLAG_CABIN_CAMERA = 0x0002, + REPLAY_FLAG_WIDE_ROAD = 0x0004, REPLAY_FLAG_NO_LOOP = 0x0010, REPLAY_FLAG_NO_FILE_CACHE = 0x0020, REPLAY_FLAG_QCAMERA = 0x0040, diff --git a/openpilot/tools/replay/route.cc b/openpilot/tools/replay/route.cc index 326d28d726..f38c2b1971 100644 --- a/openpilot/tools/replay/route.cc +++ b/openpilot/tools/replay/route.cc @@ -180,9 +180,9 @@ void Route::addFileToSegment(int n, const std::string &file) { } else if (name == "qlog.bz2" || name == "qlog.zst" || name == "qlog") { segments_[n].qlog = file; } else if (name == "fcamera.hevc") { - segments_[n].road_cam = file; + segments_[n].narrow_road_cam = file; } else if (name == "dcamera.hevc") { - segments_[n].driver_cam = file; + segments_[n].cabin_cam = file; } else if (name == "ecamera.hevc") { segments_[n].wide_road_cam = file; } else if (name == "qcamera.ts") { @@ -195,11 +195,11 @@ void Route::addFileToSegment(int n, const std::string &file) { Segment::Segment(int n, const SegmentFile &files, uint32_t flags, const std::vector &filters, std::function callback) : seg_num(n), flags(flags), filters_(filters), on_load_finished_(callback) { - // [RoadCam, DriverCam, WideRoadCam, log]. fallback to qcamera/qlog + // [NarrowRoadCam, CabinCam, WideRoadCam, log]. fallback to qcamera/qlog const std::array file_list = { - (flags & REPLAY_FLAG_QCAMERA) || files.road_cam.empty() ? files.qcamera : files.road_cam, - flags & REPLAY_FLAG_DCAM ? files.driver_cam : "", - flags & REPLAY_FLAG_ECAM ? files.wide_road_cam : "", + (flags & REPLAY_FLAG_QCAMERA) || files.narrow_road_cam.empty() ? files.qcamera : files.narrow_road_cam, + flags & REPLAY_FLAG_CABIN_CAMERA ? files.cabin_cam : "", + flags & REPLAY_FLAG_WIDE_ROAD ? files.wide_road_cam : "", files.rlog.empty() ? files.qlog : files.rlog, }; for (int i = 0; i < file_list.size(); ++i) { diff --git a/openpilot/tools/replay/route.h b/openpilot/tools/replay/route.h index 50f3eba854..7a55997969 100644 --- a/openpilot/tools/replay/route.h +++ b/openpilot/tools/replay/route.h @@ -33,8 +33,8 @@ struct RouteIdentifier { struct SegmentFile { std::string rlog; std::string qlog; - std::string road_cam; - std::string driver_cam; + std::string narrow_road_cam; + std::string cabin_cam; std::string wide_road_cam; std::string qcamera; }; diff --git a/openpilot/tools/replay/tests/test_replay.cc b/openpilot/tools/replay/tests/test_replay.cc deleted file mode 100644 index 45fcc98191..0000000000 --- a/openpilot/tools/replay/tests/test_replay.cc +++ /dev/null @@ -1,18 +0,0 @@ -#define CATCH_CONFIG_MAIN -#include "catch2/catch.hpp" -#include "tools/replay/filereader.h" -#include "tools/replay/replay.h" - -const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; - -TEST_CASE("LogReader") { - SECTION("corrupt log") { - FileReader reader(true); - std::string corrupt_content = reader.read(TEST_RLOG_URL); - corrupt_content.resize(corrupt_content.length() / 2); - corrupt_content = decompressBZ2(corrupt_content); - LogReader log; - REQUIRE(log.load(corrupt_content.data(), corrupt_content.size())); - REQUIRE(log.events.size() > 0); - } -} diff --git a/openpilot/tools/replay/ui.py b/openpilot/tools/replay/ui.py index f5bd3f1d78..7f34f1303d 100755 --- a/openpilot/tools/replay/ui.py +++ b/openpilot/tools/replay/ui.py @@ -21,7 +21,7 @@ from openpilot.tools.replay.lib.ui_helpers import ( plot_lead, plot_model, ) -from msgq.visionipc import VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView os.environ['BASEDIR'] = BASEDIR @@ -57,7 +57,7 @@ def ui_thread(addr): font_path = os.path.join(BASEDIR, "openpilot/selfdrive/assets/fonts/JetBrainsMono-Medium.ttf") font = rl.load_font_ex(font_path, 32, None, 0) - camera_view = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD) + camera_view = CameraView("camerad", VisionStreamType.VISION_STREAM_NARROW_ROAD) # Overlay texture for model/lane line drawing overlay_img = np.zeros((480, 640, 4), dtype='uint8') @@ -76,13 +76,13 @@ def ui_thread(addr): 'longitudinalPlan', 'carControl', 'radarState', - 'liveCalibration', + 'extrinsicsCalibration', 'controlsState', 'selfdriveState', - 'liveTracks', + 'radarTracks', 'modelV2', - 'liveParameters', - 'roadCameraState', + 'vehicleParameters', + 'narrowRoadCameraState', ], addr=addr, ) @@ -152,13 +152,13 @@ def ui_thread(addr): sm.update(0) - camera = DEVICE_CAMERAS[("tici", str(sm['roadCameraState'].sensor))] - calib_scale = camera.fcam.width / 640.0 + camera = DEVICE_CAMERAS[("tici", str(sm['narrowRoadCameraState'].sensor))] + calib_scale = camera.narrow_road.width / 640.0 if camera_view.frame: num_px = camera_view.frame.width * camera_view.frame.height - intrinsic_matrix = camera.fcam.intrinsics + intrinsic_matrix = camera.narrow_road.intrinsics w = sm['controlsState'].lateralControlState.which() if w == 'lqrStateDEPRECATED': @@ -173,7 +173,7 @@ def ui_thread(addr): plot_arr[-1, name_to_arr_idx['angle_steers']] = sm['carState'].steeringAngleDeg plot_arr[-1, name_to_arr_idx['angle_steers_des']] = sm['carControl'].actuators.steeringAngleDeg plot_arr[-1, name_to_arr_idx['angle_steers_k']] = angle_steers_k - plot_arr[-1, name_to_arr_idx['gas']] = sm['carState'].gasDEPRECATED + plot_arr[-1, name_to_arr_idx['gas']] = sm['carState'].deprecated.gas # TODO gas is deprecated plot_arr[-1, name_to_arr_idx['computer_gas']] = np.clip(sm['carControl'].actuators.accel / 4.0, 0.0, 1.0) plot_arr[-1, name_to_arr_idx['user_brake']] = sm['carState'].brakePressed @@ -195,10 +195,10 @@ def ui_thread(addr): plot_lead(sm['radarState'], top_down) # draw all radar points - maybe_update_radar_points(sm['liveTracks'].points, top_down[1]) + maybe_update_radar_points(sm['radarTracks'].points, top_down[1]) - if sm.updated['liveCalibration'] and num_px: - rpyCalib = np.asarray(sm['liveCalibration'].rpyCalib) + if sm.updated['extrinsicsCalibration'] and num_px: + rpyCalib = np.asarray(sm['extrinsicsCalibration'].rpyCalib) calibration = Calibration(num_px, rpyCalib, intrinsic_matrix, calib_scale) # Update overlay texture (RGB img -> RGBA with non-black pixels visible) @@ -232,10 +232,10 @@ def ui_thread(addr): ("LONG CONTROL STATE: " + str(sm['controlsState'].longControlState), YELLOW), ("LONG MPC SOURCE: " + str(sm['longitudinalPlan'].longitudinalPlanSource), YELLOW), None, - ("ANGLE OFFSET (AVG): " + str(round(sm['liveParameters'].angleOffsetAverageDeg, 2)) + " deg", YELLOW), - ("ANGLE OFFSET (INSTANT): " + str(round(sm['liveParameters'].angleOffsetDeg, 2)) + " deg", YELLOW), - ("STIFFNESS: " + str(round(sm['liveParameters'].stiffnessFactor * 100.0, 2)) + " %", YELLOW), - ("STEER RATIO: " + str(round(sm['liveParameters'].steerRatio, 2)), YELLOW), + ("ANGLE OFFSET (AVG): " + str(round(sm['vehicleParameters'].angleOffsetAverageDeg, 2)) + " deg", YELLOW), + ("ANGLE OFFSET (INSTANT): " + str(round(sm['vehicleParameters'].angleOffsetDeg, 2)) + " deg", YELLOW), + ("STIFFNESS: " + str(round(sm['vehicleParameters'].stiffnessFactor * 100.0, 2)) + " %", YELLOW), + ("STEER RATIO: " + str(round(sm['vehicleParameters'].steerRatio, 2)), YELLOW), ] for i, line in enumerate(lines): diff --git a/openpilot/tools/replay/util.cc b/openpilot/tools/replay/util.cc index 7b308b5c3d..44e144443a 100644 --- a/openpilot/tools/replay/util.cc +++ b/openpilot/tools/replay/util.cc @@ -1,14 +1,10 @@ #include "tools/replay/util.h" -#include - #include #include #include #include #include -#include - #include "common/timing.h" #include "common/util.h" @@ -58,90 +54,6 @@ std::string getUrlWithoutQuery(const std::string &url) { return (idx == std::string::npos ? url : url.substr(0, idx)); } -std::string decompressBZ2(const std::string &in, std::atomic *abort) { - return decompressBZ2((std::byte *)in.data(), in.size(), abort); -} - -std::string decompressBZ2(const std::byte *in, size_t in_size, std::atomic *abort) { - if (in_size == 0) return {}; - - bz_stream strm = {}; - int bzerror = BZ2_bzDecompressInit(&strm, 0, 0); - assert(bzerror == BZ_OK); - - strm.next_in = (char *)in; - strm.avail_in = in_size; - std::string out(in_size * 5, '\0'); - do { - strm.next_out = (char *)(&out[strm.total_out_lo32]); - strm.avail_out = out.size() - strm.total_out_lo32; - - const char *prev_write_pos = strm.next_out; - bzerror = BZ2_bzDecompress(&strm); - if (bzerror == BZ_OK && prev_write_pos == strm.next_out) { - // content is corrupt - bzerror = BZ_STREAM_END; - rWarning("decompressBZ2 error: content is corrupt"); - break; - } - - if (bzerror == BZ_OK && strm.avail_in > 0 && strm.avail_out == 0) { - out.resize(out.size() * 2); - } - } while (bzerror == BZ_OK && !(abort && *abort)); - - BZ2_bzDecompressEnd(&strm); - if (bzerror == BZ_STREAM_END && !(abort && *abort)) { - out.resize(strm.total_out_lo32); - out.shrink_to_fit(); - return out; - } - return {}; -} - -std::string decompressZST(const std::string &in, std::atomic *abort) { - return decompressZST((std::byte *)in.data(), in.size(), abort); -} - -std::string decompressZST(const std::byte *in, size_t in_size, std::atomic *abort) { - ZSTD_DCtx *dctx = ZSTD_createDCtx(); - assert(dctx != nullptr); - - // Initialize input and output buffers - ZSTD_inBuffer input = {in, in_size, 0}; - - // Estimate and reserve memory for decompressed data - size_t estimatedDecompressedSize = ZSTD_getFrameContentSize(in, in_size); - if (estimatedDecompressedSize == ZSTD_CONTENTSIZE_ERROR || estimatedDecompressedSize == ZSTD_CONTENTSIZE_UNKNOWN) { - estimatedDecompressedSize = in_size * 2; // Use a fallback size - } - - std::string decompressedData; - decompressedData.reserve(estimatedDecompressedSize); - - const size_t bufferSize = ZSTD_DStreamOutSize(); // Recommended output buffer size - std::string outputBuffer(bufferSize, '\0'); - - while (input.pos < input.size && !(abort && *abort)) { - ZSTD_outBuffer output = {outputBuffer.data(), bufferSize, 0}; - - size_t result = ZSTD_decompressStream(dctx, &output, &input); - if (ZSTD_isError(result)) { - rWarning("decompressZST error: content is corrupt"); - break; - } - - decompressedData.append(outputBuffer.data(), output.pos); - } - - ZSTD_freeDCtx(dctx); - if (!(abort && *abort)) { - decompressedData.shrink_to_fit(); - return decompressedData; - } - return {}; -} - void precise_nano_sleep(int64_t nanoseconds, std::atomic &interrupt_requested) { struct timespec req, rem; req.tv_sec = nanoseconds / 1000000000; diff --git a/openpilot/tools/replay/util.h b/openpilot/tools/replay/util.h index 03a15787b2..5cffc11706 100644 --- a/openpilot/tools/replay/util.h +++ b/openpilot/tools/replay/util.h @@ -10,8 +10,8 @@ #include "openpilot/cereal/messaging/messaging.h" enum CameraType { - RoadCam = 0, - DriverCam, + NarrowRoadCam = 0, + CabinCam, WideRoadCam }; @@ -47,10 +47,6 @@ private: }; void precise_nano_sleep(int64_t nanoseconds, std::atomic &interrupt_requested); -std::string decompressBZ2(const std::string &in, std::atomic *abort = nullptr); -std::string decompressBZ2(const std::byte *in, size_t in_size, std::atomic *abort = nullptr); -std::string decompressZST(const std::string &in, std::atomic *abort = nullptr); -std::string decompressZST(const std::byte *in, size_t in_size, std::atomic *abort = nullptr); std::string getUrlWithoutQuery(const std::string &url); std::string formattedDataSize(size_t size); std::string extractFileName(const std::string& file); diff --git a/openpilot/tools/sim/bridge/common.py b/openpilot/tools/sim/bridge/common.py index 048bf6cb11..1d00e3b0f9 100644 --- a/openpilot/tools/sim/bridge/common.py +++ b/openpilot/tools/sim/bridge/common.py @@ -94,7 +94,7 @@ Ignition: {self.simulator_state.ignition} Engaged: {self.simulator_state.is_enga """) @abstractmethod - def spawn_world(self, q: Queue) -> World: + def spawn_world(self, q: Queue, /) -> World: pass def _run(self, q: Queue): diff --git a/openpilot/tools/sim/bridge/metadrive/metadrive_bridge.py b/openpilot/tools/sim/bridge/metadrive/metadrive_bridge.py index b8dc94cf86..4222ba45a7 100644 --- a/openpilot/tools/sim/bridge/metadrive/metadrive_bridge.py +++ b/openpilot/tools/sim/bridge/metadrive/metadrive_bridge.py @@ -29,11 +29,11 @@ def curve_block(length, angle=45, direction=0): def create_map(track_size=60): curve_len = track_size * 2 - return dict( - type=MapGenerateMethod.PG_MAP_FILE, - lane_num=2, - lane_width=4.5, - config=[ + return { + "type": MapGenerateMethod.PG_MAP_FILE, + "lane_num": 2, + "lane_width": 4.5, + "config": [ None, straight_block(track_size), curve_block(curve_len, 90), @@ -44,7 +44,7 @@ def create_map(track_size=60): straight_block(track_size), curve_block(curve_len, 90), ] - ) + } class MetaDriveBridge(SimulatorBridge): @@ -65,29 +65,29 @@ class MetaDriveBridge(SimulatorBridge): if self.dual_camera: sensors["rgb_wide"] = (RGBCameraWide, W, H) - config = dict( - use_render=self.should_render, - vehicle_config=dict( - enable_reverse=False, - render_vehicle=False, - image_source="rgb_road", - ), - sensors=sensors, - image_on_cuda=_cuda_enable, - image_observation=True, - interface_panel=[], - out_of_route_done=False, - on_continuous_line_done=False, - crash_vehicle_done=False, - crash_object_done=False, - arrive_dest_done=False, - traffic_density=0.0, # traffic is incredibly expensive - map_config=create_map(), - decision_repeat=1, - physics_world_step_size=self.TICKS_PER_FRAME/100, - preload_models=False, - show_logo=False, - anisotropic_filtering=False - ) + config = { + "use_render": self.should_render, + "vehicle_config": { + "enable_reverse": False, + "render_vehicle": False, + "image_source": "rgb_road", + }, + "sensors": sensors, + "image_on_cuda": _cuda_enable, + "image_observation": True, + "interface_panel": [], + "out_of_route_done": False, + "on_continuous_line_done": False, + "crash_vehicle_done": False, + "crash_object_done": False, + "arrive_dest_done": False, + "traffic_density": 0.0, # traffic is incredibly expensive + "map_config": create_map(), + "decision_repeat": 1, + "physics_world_step_size": self.TICKS_PER_FRAME/100, + "preload_models": False, + "show_logo": False, + "anisotropic_filtering": False + } return MetaDriveWorld(queue, config, self.test_duration, self.test_run, self.dual_camera) diff --git a/openpilot/tools/sim/bridge/metadrive/metadrive_process.py b/openpilot/tools/sim/bridge/metadrive/metadrive_process.py index 2486d87ff9..01d0473755 100644 --- a/openpilot/tools/sim/bridge/metadrive/metadrive_process.py +++ b/openpilot/tools/sim/bridge/metadrive/metadrive_process.py @@ -27,9 +27,9 @@ def apply_metadrive_patches(arrive_dest_done=True): # By default, metadrive won't try to use cuda images unless it's used as a sensor for vehicles, so patch that in def add_image_sensor_patched(self, name: str, cls, args): if self.global_config["image_on_cuda"]:# and name == self.global_config["vehicle_config"]["image_source"]: - sensor = cls(*args, self, cuda=True) + sensor = cls(*args, self, cuda=True) else: - sensor = cls(*args, self, cuda=False) + sensor = cls(*args, self, cuda=False) assert isinstance(sensor, ImageBuffer), "This API is for adding image sensor" self.sensors[name] = sensor diff --git a/openpilot/tools/sim/bridge/metadrive/metadrive_world.py b/openpilot/tools/sim/bridge/metadrive/metadrive_world.py index c5111289d0..54b461a46c 100644 --- a/openpilot/tools/sim/bridge/metadrive/metadrive_world.py +++ b/openpilot/tools/sim/bridge/metadrive/metadrive_world.py @@ -97,7 +97,7 @@ class MetaDriveWorld(World): self.op_engaged.set() # check moving 5 seconds after engaged, doesn't move right away - after_engaged_check = is_engaged and time.monotonic() - self.first_engage >= 5 and self.test_run + after_engaged_check = is_engaged and self.first_engage is not None and time.monotonic() - self.first_engage >= 5 and self.test_run x_dist = abs(curr_pos[0] - self.vehicle_last_pos[0]) y_dist = abs(curr_pos[1] - self.vehicle_last_pos[1]) diff --git a/openpilot/tools/sim/lib/camerad.py b/openpilot/tools/sim/lib/camerad.py index 8efb3e5dab..206e4fdf90 100644 --- a/openpilot/tools/sim/lib/camerad.py +++ b/openpilot/tools/sim/lib/camerad.py @@ -1,6 +1,7 @@ import numpy as np -from msgq.visionipc import VisionIpcServer, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcServer from openpilot.cereal import messaging from openpilot.tools.sim.lib.common import W, H @@ -37,20 +38,20 @@ def rgb_to_nv12(rgb): class Camerad: """Simulates the camerad daemon""" def __init__(self, dual_camera): - self.pm = messaging.PubMaster(['roadCameraState', 'wideRoadCameraState']) + self.pm = messaging.PubMaster(['narrowRoadCameraState', 'wideRoadCameraState']) self.frame_road_id = 0 self.frame_wide_id = 0 self.vipc_server = VisionIpcServer("camerad") - self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_ROAD, 5, W, H) + self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_NARROW_ROAD, 5, W, H) if dual_camera: self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_WIDE_ROAD, 5, W, H) self.vipc_server.start_listener() def cam_send_yuv_road(self, yuv): - self._send_yuv(yuv, self.frame_road_id, 'roadCameraState', VisionStreamType.VISION_STREAM_ROAD) + self._send_yuv(yuv, self.frame_road_id, 'narrowRoadCameraState', VisionStreamType.VISION_STREAM_NARROW_ROAD) self.frame_road_id += 1 def cam_send_yuv_wide_road(self, yuv): diff --git a/openpilot/tools/sim/lib/common.py b/openpilot/tools/sim/lib/common.py index 1324932131..eb29119fb4 100644 --- a/openpilot/tools/sim/lib/common.py +++ b/openpilot/tools/sim/lib/common.py @@ -40,7 +40,7 @@ class SimulatorState: self.is_engaged = False self.ignition = True - self.velocity: vec3 = None + self.velocity = vec3(0, 0, 0) self.bearing: float = 0 self.gps = GPSState() self.imu = IMUState() @@ -72,7 +72,7 @@ class World(ABC): self.exit_event = multiprocessing.Event() @abstractmethod - def apply_controls(self, steer_sim, throttle_out, brake_out): + def apply_controls(self, steer_sim, throttle_out, brake_out, /): pass @abstractmethod @@ -84,7 +84,7 @@ class World(ABC): pass @abstractmethod - def read_sensors(self, simulator_state: SimulatorState): + def read_sensors(self, simulator_state: SimulatorState, /): pass @abstractmethod diff --git a/openpilot/tools/sim/tests/conftest.py b/openpilot/tools/sim/tests/conftest.py deleted file mode 100644 index ddf6635276..0000000000 --- a/openpilot/tools/sim/tests/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest - -def pytest_addoption(parser): - parser.addoption("--test_duration", action="store", default=60, type=int, help="Seconds to run metadrive drive") - -@pytest.fixture -def test_duration(request): - return request.config.getoption("--test_duration") diff --git a/openpilot/tools/sim/tests/test_metadrive_bridge.py b/openpilot/tools/sim/tests/test_metadrive_bridge.py index 9be640d736..4e7560907b 100644 --- a/openpilot/tools/sim/tests/test_metadrive_bridge.py +++ b/openpilot/tools/sim/tests/test_metadrive_bridge.py @@ -1,17 +1,22 @@ -import pytest import warnings +import unittest +import importlib # Since metadrive depends on pkg_resources, and pkg_resources is deprecated as an API warnings.filterwarnings("ignore", category=DeprecationWarning) -from openpilot.tools.sim.bridge.metadrive.metadrive_bridge import MetaDriveBridge +try: + MetaDriveBridge = importlib.import_module("openpilot.tools.sim.bridge.metadrive.metadrive_bridge").MetaDriveBridge +except ModuleNotFoundError: + MetaDriveBridge = None from openpilot.tools.sim.tests.test_sim_bridge import TestSimBridgeBase -@pytest.mark.slow +@unittest.skipIf(MetaDriveBridge is None, "metadrive is not installed") class TestMetaDriveBridge(TestSimBridgeBase): - @pytest.fixture(autouse=True) - def setup_create_bridge(self, test_duration): + def setup_method(self): + super().openpilot_setup_method() self.test_duration = 30 def create_bridge(self): + assert MetaDriveBridge is not None return MetaDriveBridge(False, False, self.test_duration, True) diff --git a/openpilot/tools/sim/tests/test_sim_bridge.py b/openpilot/tools/sim/tests/test_sim_bridge.py index f93cc2ef50..a2c0611937 100644 --- a/openpilot/tools/sim/tests/test_sim_bridge.py +++ b/openpilot/tools/sim/tests/test_sim_bridge.py @@ -1,25 +1,27 @@ import os import subprocess import time -import pytest +import unittest from multiprocessing import Queue +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import messaging from openpilot.common.basedir import BASEDIR from openpilot.tools.sim.bridge.common import QueueMessageType SIM_DIR = os.path.join(BASEDIR, "openpilot/tools/sim") -class TestSimBridgeBase: +class TestSimBridgeBase(OpenpilotTestCase): @classmethod def setup_class(cls): if cls is TestSimBridgeBase: - raise pytest.skip("Don't run this base class, run test_metadrive_bridge.py instead") + raise unittest.SkipTest("Don't run this base class, run test_metadrive_bridge.py instead") def setup_method(self): self.processes = [] + @unittest.skip("TODO: re-enable simulator bridge test") def test_driving(self): # Startup manager and bridge.py. Check processes are running, then engage and verify. p_manager = subprocess.Popen("./launch_openpilot.sh", cwd=SIM_DIR) diff --git a/panda b/panda index 61b050f1bd..ea5a83a956 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 61b050f1bd36fad04d4ceccca8a72c92ee1422df +Subproject commit ea5a83a956d61c7540c1a13a8d76f08c24675d1b diff --git a/pyproject.toml b/pyproject.toml index f00717c5a9..6a13796d70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,12 @@ authors = [ {name = "Vehicle Researcher", email="user@comma.ai"} ] +# ------------------------------------------ +# > this dependencies list *only* goes down. +# ------------------------------------------ +# Linux and Python is all we need, otherwise +# we write and own the lines ourselves. +# ------------------------------------------ dependencies = [ # multiple users "sounddevice", # micd + soundd @@ -15,93 +21,66 @@ dependencies = [ "tqdm", # cars (fw_versions.py) on start + many one-off uses # core - "cffi", - "scons", + "scons==4.10.1", # 4.11 removed the qt3 tool still used to build Cabana "pycapnp==2.1.0", # 2.2 introduces a memory leak due to cyclic references - "Cython", - "setuptools", "numpy >=2.0", # vendored native dependencies - "comma-deps-bzip2", - "comma-deps-bootstrap-icons", "comma-deps-capnproto", - "comma-deps-catch2", "comma-deps-acados", "comma-deps-ffmpeg", "comma-deps-zstd", - "comma-deps-ncurses", "comma-deps-zeromq", - "comma-deps-libusb", "comma-deps-json11", "comma-deps-git-lfs", "comma-deps-gcc-arm-none-eabi", - # body / webrtcd - "av", - "aiortc", - - # logging - "pyzmq", - "sentry-sdk", - "xattr", # used in place of 'os.getxattr' for macOS compatibility - # athena - "PyJWT", + "PyJWT[crypto]", "websocket_client", # joystickd "inputs", # these should be removed + "pyzmq", + "sentry-sdk", "setproctitle", - - # logreader - "zstandard", + "jeepney", + "zstandard", # this can go once we're on Python 3.14+ # ui "comma-deps-raylib", - "qrcode", - "jeepney", - "pillow", ] [project.optional-dependencies] docs = [ - "Jinja2", "zensical", ] +dev = [ + "huggingface_hub", +] + testing = [ "coverage", - "hypothesis ==6.47.*", "ty", - "pytest", - "pytest-cpp", - "pytest-subtests", - # https://github.com/pytest-dev/pytest-xdist/pull/1229 - "pytest-xdist @ git+https://github.com/sshane/pytest-xdist@2b4372bd62699fb412c4fe2f95bf9f01bd2018da", - "pytest-mock", "ruff", "codespell", - "pre-commit-hooks", -] - -dev = [ - "matplotlib", ] tools = [ + "matplotlib", + "comma-deps-imgui", + "comma-deps-bootstrap-icons", + "comma-deps-libusb", + "comma-deps-ncurses", # this can be added back once it's stripped down some more #"metadrive-simulator @ git+https://github.com/commaai/metadrive.git@minimal ; (platform_machine != 'aarch64')", ] -[project.urls] -Homepage = "https://github.com/commaai/openpilot" - -[dependency-groups] submodules = [ "msgq", "opendbc", @@ -111,6 +90,14 @@ submodules = [ "tinygrad", ] +[project.urls] +Homepage = "https://github.com/commaai/openpilot" + +[dependency-groups] +standalone = [ + "openpilot[submodules]", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -123,24 +110,6 @@ packages = [ [tool.hatch.metadata] allow-direct-references = true -[tool.pytest.ini_options] -minversion = "6.0" -addopts = "-Werror --strict-config --strict-markers --durations=10 -n auto --dist=loadgroup" -cpp_files = "test_*" -cpp_harness = "openpilot/selfdrive/test/cpp_harness.py" -python_files = "test_*.py" -markers = [ - "slow: tests that take awhile to run and can be skipped with -m 'not slow'", - "tici: tests that are only meant to run on the C3/C3X", - "skip_tici_setup: mark test to skip tici setup fixture", - "nocapture: don't capture test output", - "shared_download_cache: share download cache between tests", - "xdist_group_class_property: group tests by a property of the class that contains them", -] -testpaths = [ - "openpilot", -] - [tool.codespell] quiet-level = 3 # if you've got a short variable name that's getting flagged, add it here @@ -155,20 +124,19 @@ lint.select = [ "E", "F", "W", "PIE", "C4", "ISC", "A", "B", "NPY", # numpy "UP", # pyupgrade + "ASYNC", + "B904", "B905", + "PLC0207", "TRY203", "TRY400", "TRY401", # try/excepts - "RUF008", "RUF100", + "RUF006", "RUF008", "RUF009", "RUF061", "RUF064", "RUF100", "RUF102", "RUF103", "RUF104", "TID251", "PLE", "PLR1704", ] lint.ignore = [ "E741", "E402", - "C408", - "ISC003", "B027", - "B024", - "NPY002", # new numpy random syntax is worse - "UP045", "UP007", # these don't play nice with raylib atm + "UP007", # this doesn't play nice with raylib atm ] line-length = 160 exclude = [ @@ -180,11 +148,7 @@ exclude = [ lint.flake8-implicit-str-concat.allow-multiline = false [tool.ruff.lint.flake8-tidy-imports.banned-api] -"pytest.main".msg = "pytest.main requires special handling that is easy to mess up!" -"unittest".msg = "Use pytest" -"time.time".msg = "Use time.monotonic" - -# raylib banned APIs +"time.time".msg = "Use time.monotonic. time.time can skip due to its reference clock, you probably want a monotonic clock" "pyray.measure_text_ex".msg = "Use openpilot.system.ui.lib.text_measure" "pyray.is_mouse_button_pressed".msg = "This can miss events. Use Widget._handle_mouse_press" "pyray.is_mouse_button_released".msg = "This can miss events. Use Widget._handle_mouse_release" @@ -202,21 +166,12 @@ exclude = [ [tool.ty.rules] unresolved-import = "ignore" # Cython-compiled modules (.pyx) unresolved-attribute = "ignore" # many from capnp and Cython modules -invalid-method-override = "ignore" # signature variance issues -possibly-missing-attribute = "ignore" # too many false positives -invalid-assignment = "ignore" # often intentional monkey-patching -no-matching-overload = "ignore" # numpy/ctypes overload matching issues -invalid-argument-type = "ignore" # many false positives from raylib, ctypes, numpy -call-non-callable = "ignore" # false positives from dynamic types -unsupported-operator = "ignore" # false positives from dynamic types -not-subscriptable = "ignore" # false positives from dynamic types [tool.uv] python-preference = "only-managed" -default-groups = ["submodules"] +default-groups = ["standalone"] override-dependencies = [ "opendbc", # panda pins opendbc from git for standalone use; always use our submodule - "av", # teleoprtc's av<13 pin is stale ] [tool.uv.sources] diff --git a/rednose_repo b/rednose_repo index 9e19086c26..28d4a7f69e 160000 --- a/rednose_repo +++ b/rednose_repo @@ -1 +1 @@ -Subproject commit 9e19086c26ca35708870d50ebcf237d65d0b163e +Subproject commit 28d4a7f69e80e1c3e0d24ca0733d7daeaeade3d0 diff --git a/release/ci/docker_build_sp.sh b/release/ci/docker_build_sp.sh deleted file mode 100755 index 369daf5233..0000000000 --- a/release/ci/docker_build_sp.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -set -e - -SCRIPT_DIR=$(dirname "$0") -OPENPILOT_DIR=$SCRIPT_DIR/../../ - -DOCKER_IMAGE=sunnypilot -DOCKER_FILE=Dockerfile.openpilot -DOCKER_REGISTRY=ghcr.io/sunnypilot -COMMIT_SHA=$(git rev-parse HEAD) - -if [ -n "$TARGET_ARCHITECTURE" ]; then - PLATFORM="linux/$TARGET_ARCHITECTURE" - TAG_SUFFIX="-$TARGET_ARCHITECTURE" -else - PLATFORM="linux/$(uname -m)" - TAG_SUFFIX="" -fi - -LOCAL_TAG=$DOCKER_IMAGE$TAG_SUFFIX -REMOTE_TAG=$DOCKER_REGISTRY/$LOCAL_TAG -REMOTE_SHA_TAG=$DOCKER_REGISTRY/$LOCAL_TAG:$COMMIT_SHA - -DOCKER_BUILDKIT=1 docker buildx build --provenance false --pull --platform $PLATFORM --load -t $DOCKER_IMAGE:latest -t $REMOTE_TAG -t $LOCAL_TAG -f $OPENPILOT_DIR/$DOCKER_FILE $OPENPILOT_DIR - -if [ -n "$PUSH_IMAGE" ]; then - docker push $REMOTE_TAG - docker tag $REMOTE_TAG $REMOTE_SHA_TAG - docker push $REMOTE_SHA_TAG -fi diff --git a/release/ci/install_github_runner.sh b/release/ci/install_github_runner.sh deleted file mode 100755 index 9f11e4841c..0000000000 --- a/release/ci/install_github_runner.sh +++ /dev/null @@ -1,260 +0,0 @@ -#!/usr/bin/env bash -set -e - -# Default values -DEFAULT_REPO_URL="https://github.com/sunnypilot" -START_AT_BOOT=false -RESTORE_MODE=false -RUNNER_VERSION="2.325.0" - -# Parse command line arguments -while [[ $# -gt 0 ]]; do - case $1 in - --start-at-boot) - START_AT_BOOT=true - shift - ;; - --token) - GITHUB_TOKEN="$2" - shift 2 - ;; - --repo) - REPO_URL="$2" - shift 2 - ;; - --restore) - RESTORE_MODE=true - shift - ;; - *) - if [ -z "$GITHUB_TOKEN" ]; then - GITHUB_TOKEN="$1" - elif [ -z "$REPO_URL" ]; then - REPO_URL="$1" - fi - shift - ;; - esac -done - -# Determine BASE_DIR based on mount point -if mountpoint -q /data/media; then - BASE_DIR="/data/media/0/github" -else - BASE_DIR="/data/github" -fi - -# Constants -RUNNER_USER="github-runner" -USER_GROUPS="comma,gpu,gpio,sudo" -RUNNER_DIR="${BASE_DIR}/runner" -BUILDS_DIR="${BASE_DIR}/builds" -LOGS_DIR="${BASE_DIR}/logs" -CACHE_DIR="${BASE_DIR}/cache" -OPENPILOT_DIR="${BASE_DIR}/openpilot" - -# Basic utility functions (no dependencies) -remount_rw() { - sudo mount -o remount,rw / -} - -remount_ro() { - sync || true # Try to sync but continue even if it fails - sudo mount -o remount,ro / # Always try to remount as read-only -} - -# Always ensure we try to remount as read-only on exit -trap remount_ro EXIT - -setup_runner_user() { - sudo useradd --comment 'GitHub Runner' --create-home --home-dir ${BASE_DIR} ${RUNNER_USER} --shell /bin/bash -G ${USER_GROUPS} || sudo usermod -aG ${USER_GROUPS} ${RUNNER_USER} -} - -create_sudoers_entry() { - sudo grep -qxF "${RUNNER_USER} ALL=(ALL) NOPASSWD: ALL" /etc/sudoers || echo "${RUNNER_USER} ALL=(ALL) NOPASSWD: ALL" | sudo tee -a /etc/sudoers -} - -set_directory_permissions() { - sudo chown -R ${RUNNER_USER}:comma "$BASE_DIR" - sudo chmod -R g+rwx "$BASE_DIR" - sudo find "$BASE_DIR" -type d -exec chmod g+s {} + -} - -setup_directories() { - echo "Creating necessary directories..." - sudo mkdir -p "$RUNNER_DIR" "$BUILDS_DIR" "$LOGS_DIR" "$CACHE_DIR" "$OPENPILOT_DIR" - mkdir -p "/data/openpilot" - sudo chown -R comma:comma "/data/openpilot" - sync -} - -wipe_bash_logout() { - export BASE_DIR - sudo -u ${RUNNER_USER} bash -c "touch ${BASE_DIR}/.bash_logout" - sudo -u ${RUNNER_USER} bash -c "truncate -s 0 '${BASE_DIR}/.bash_logout'" -} - -# System configuration functions (depends on basic utility functions) -setup_system_configs() { - echo "Setting up system configurations..." - remount_rw - setup_runner_user - create_sudoers_entry - remount_ro - set_directory_permissions - wipe_bash_logout -} - -# Runner setup functions -install_runner() { - echo "Downloading and setting up runner..." - cd "$RUNNER_DIR" - curl -o actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz -L https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz - sudo -u ${RUNNER_USER} tar -xzf ./actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz - sudo rm ./actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz - sudo chmod +x ./config.sh -} - -configure_runner() { - remount_rw - echo "Configuring runner..." - cd "$RUNNER_DIR" - sudo -u ${RUNNER_USER} ./config.sh --url "$REPO_URL" --token "$GITHUB_TOKEN" --name $(hostname) --runnergroup "tici-tizi" --labels "tici" --work "$BUILDS_DIR" --unattended - remount_ro -} - -create_service_template() { - echo "Creating service template..." - cat < "$RUNNER_DIR/bin/actions.runner.service.template" -[Unit] -Description={{Description}} -After=network-online.target nss-lookup.target time-sync.target -Wants=network-online.target nss-lookup.target time-sync.target -StartLimitInterval=5 -StartLimitBurst=10 - -[Service] -Type=simple -User=root -ExecStart=/usr/bin/unshare -m -- /bin/bash -c 'mount --bind ${OPENPILOT_DIR} /data/openpilot && setpriv --reuid={{User}} --regid={{User}} --init-groups env HOME=${BASE_DIR} USER={{User}} LOGNAME={{User}} MAIL=/var/mail/{{User}} {{RunnerRoot}}/runsvc.sh' -WorkingDirectory={{RunnerRoot}} -KillMode=process -KillSignal=SIGTERM -TimeoutStopSec=5min -Restart=always -RestartSec=120 - -[Install] -WantedBy=multi-user.target -EOL -} - -install_service() { - local service_name - if [ -f "${RUNNER_DIR}/.service" ]; then - service_name=$(cat "${RUNNER_DIR}/.service") - else - service_name="actions.runner.sunnypilot.$(uname -n)" - fi - - create_service_template - remount_rw - local service_path="/etc/systemd/system/${service_name}" - echo "Installing systemd service..." - if [ -f "${service_path}" ]; then - echo "Service ${service_path} found in systemd, we will delete it" - sudo rm -f "${service_path}" - fi - - cd "$RUNNER_DIR" - sudo ./svc.sh install $RUNNER_USER - - if [ "$START_AT_BOOT" = false ]; then - sudo systemctl disable "${service_name}" - fi - remount_ro -} - -check_restore_prerequisites() { - local can_restore=false - local service_name="" - - # Check if base runner directory exists - if [ ! -d "${RUNNER_DIR}" ]; then - echo "ERROR: Runner directory ${RUNNER_DIR} does not exist" - echo "This directory is required for restore operations" - exit 1 - fi - - # First check if we have the required files for restoration - if [ -f "${RUNNER_DIR}/.credentials" ] && [ -f "${RUNNER_DIR}/.service" ]; then - can_restore=true - service_name=$(cat "${RUNNER_DIR}/.service") - echo "Found required runner configuration files" - else - echo "Missing required runner configuration files" - echo "Required: .credentials and .service files in ${RUNNER_DIR}" - exit 1 - fi - - if ! id "${RUNNER_USER}" &>/dev/null; then - echo "User ${RUNNER_USER} does not exist" - fi - - # Only proceed if we can restore AND need to restore - if [ "$can_restore" = true ]; then - echo "Restoration is possible" - return 0 - else - echo "No restoration possible" - exit 0 - fi -} - -perform_restore() { - echo "Starting runner restoration..." - setup_directories - setup_system_configs - install_service - echo "Runner restoration completed successfully" -} - -perform_install() { - echo "Starting fresh installation..." - setup_directories - setup_system_configs - install_runner - set_directory_permissions - configure_runner - install_service - echo "Installation completed successfully" -} - -main() { - if [ "$RESTORE_MODE" = true ]; then - echo "Running in restore mode - will only restore system configurations..." - check_restore_prerequisites - perform_restore - else - # Check required arguments for normal installation - if [ -z "$GITHUB_TOKEN" ]; then - echo "Usage: $0 [--start-at-boot] [--token ] [--repo ] [--restore]" - echo "Required argument (except for --restore): github_token" - echo "Optional arguments:" - echo " --start-at-boot Enable auto-start at boot (default: false)" - echo " --repo Repository URL (default: ${DEFAULT_REPO_URL})" - echo " --restore Restore existing runner configuration" - exit 1 - fi - - # Set repository URL if not provided - REPO_URL="${REPO_URL:-$DEFAULT_REPO_URL}" - perform_install - fi - - echo "Starting runner service..." - cd "$RUNNER_DIR" - sudo ./svc.sh start -} - -main diff --git a/release/ci/model_generator.py b/release/ci/model_generator.py index afee782beb..2d35d319c2 100755 --- a/release/ci/model_generator.py +++ b/release/ci/model_generator.py @@ -5,8 +5,6 @@ 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 pickle import sys import hashlib import json @@ -14,46 +12,8 @@ import re from pathlib import Path from datetime import datetime, UTC -REQUIRED_OUTPUT_KEYS = frozenset({ - "plan", - "lane_lines", - "road_edges", - "lead", - "desire_state", - "desire_pred", - "meta", - "lead_prob", - "lane_lines_prob", - "pose", - "wide_from_device_euler", - "road_transform", - "hidden_state", -}) -OPTIONAL_OUTPUT_KEYS = frozenset({ - "planplus", - "sim_pose", - "desired_curvature", -}) - -def validate_model_outputs(metadata_paths: list[Path]) -> None: - combined_keys: set[str] = set() - for path in metadata_paths: - if path.stat().st_size == 0: - print(f"skipping empty metadata: {path}") - continue - with open(path, "rb") as f: - metadata = pickle.load(f) - combined_keys.update(metadata.get("output_slices", {}).keys()) - missing = REQUIRED_OUTPUT_KEYS - combined_keys - if missing: - raise ValueError(f"Combined model metadata is missing required output keys: {sorted(missing)}") - detected_optional = sorted(OPTIONAL_OUTPUT_KEYS & combined_keys) - if detected_optional: - print(f"Optional output keys detected: {detected_optional}") - - -def create_short_name(full_name): +def create_short_name(full_name: str) -> str: # Remove parentheses and extract alphanumeric words clean_name = re.sub(r'\([^)]*\)', '', full_name) words = [re.sub(r'[^a-zA-Z0-9]', '', word) for word in clean_name.split() if re.sub(r'[^a-zA-Z0-9]', '', word)] @@ -88,24 +48,33 @@ def create_short_name(full_name): return result[:8] -def _read_pkl_bytes(pkl_path: Path) -> bytes: +def create_pkl_name(full_name: str) -> str: + pkl = re.sub(r'[^a-zA-Z0-9]+', '_', full_name).strip('_').lower() + return pkl + + +def _hash_pkl(pkl_path: Path) -> str: manifest = Path(f"{pkl_path}.chunkmanifest") if manifest.exists(): num_chunks = int(manifest.read_text().strip()) - parts = [] - for i in range(num_chunks): - chunk = Path(f"{pkl_path}.chunk{i + 1:02d}of{num_chunks:02d}") - parts.append(chunk.read_bytes()) - return b''.join(parts) - return pkl_path.read_bytes() + paths = [Path(f"{pkl_path}.chunk{i + 1:02d}of{num_chunks:02d}") for i in range(num_chunks)] + else: + paths = [pkl_path] + + digest = hashlib.sha256() + for path in paths: + with path.open('rb') as f: + while block := f.read(1024 * 1024): + digest.update(block) + return digest.hexdigest() def _find_driving_pkl(output_path: Path) -> Path | None: - for pattern in ('driving_tinygrad.pkl', 'driving_*_tinygrad.pkl'): + for pattern in ('*driving_tinygrad.pkl', '*driving_*_tinygrad.pkl'): matches = sorted(output_path.glob(pattern)) if matches: return matches[0] - for pattern in ('driving_tinygrad.pkl.chunkmanifest', 'driving_*_tinygrad.pkl.chunkmanifest'): + for pattern in ('*driving_tinygrad.pkl.chunkmanifest', '*driving_*_tinygrad.pkl.chunkmanifest'): matches = sorted(output_path.glob(pattern)) if matches: return Path(str(matches[0]).removesuffix('.chunkmanifest')) @@ -121,49 +90,54 @@ def _rename_pkl_with_chunks(old_pkl: Path, new_pkl: Path) -> Path: return old_pkl.rename(new_pkl) -def generate_metadata(model_path: Path, output_dir: Path, short_name: str, driving_pkl: Path): - base = model_path.stem - metadata_file = output_dir / f"{base}_metadata.pkl" +def _hash_onnx_files(model_dir: Path) -> str | None: + onnx_files = sorted(model_dir.glob("*.onnx")) + if not onnx_files: + return None + digest = hashlib.sha256() + for f in onnx_files: + with f.open('rb') as fh: + while block := fh.read(1024 * 1024): + digest.update(block) + return digest.hexdigest() - if short_name: - renamed_meta = output_dir / f"{base}_{short_name.lower()}_metadata.pkl" - if metadata_file.exists() and not renamed_meta.exists(): - metadata_file = metadata_file.rename(renamed_meta) - elif renamed_meta.exists(): - metadata_file = renamed_meta - if not metadata_file.exists(): - print(f"Warning: Missing metadata for {base} ({metadata_file}), skipping", file=sys.stderr) - return +def generate_chunked_model(driving_pkl: Path) -> dict: + tinygrad_hash = _hash_pkl(driving_pkl) - tinygrad_hash = hashlib.sha256(_read_pkl_bytes(driving_pkl)).hexdigest() + chunks_config = [] + manifest_file = Path(f"{driving_pkl}.chunkmanifest") + if manifest_file.exists(): + num_chunks = int(manifest_file.read_text().strip()) + for i in range(num_chunks): + chunk_path = Path(f"{driving_pkl}.chunk{i + 1:02d}of{num_chunks:02d}") + if chunk_path.exists(): + chunk_hash = hashlib.sha256(chunk_path.read_bytes()).hexdigest() + chunks_config.append({ + "file_name": chunk_path.name, + "sha256": chunk_hash + }) - with open(metadata_file, 'rb') as f: - metadata_hash = hashlib.sha256(f.read()).hexdigest() - - model_type = "offPolicy" if "off_policy" in base else "onPolicy" if "on_policy" in base else base.split("_")[-1] - - return { - "type": model_type, - "artifact": { - "file_name": driving_pkl.name, - "download_uri": { - "url": "https://gitlab.com/sunnypilot/public/docs.sunnypilot.ai/-/raw/main/", - "sha256": tinygrad_hash - } - }, - "metadata": { - "file_name": metadata_file.name, - "download_uri": { - "url": "https://gitlab.com/sunnypilot/public/docs.sunnypilot.ai/-/raw/main/", - "sha256": metadata_hash - } + artifact_data = { + "file_name": driving_pkl.name, + "download_uri": { + "url": "https://gitlab.com/sunnypilot/public/docs.sunnypilot.ai/-/raw/main/", + "sha256": tinygrad_hash } } + if chunks_config: + artifact_data["chunks"] = chunks_config -def create_metadata_json(models: list, output_dir: Path, custom_name=None, short_name=None, is_20hz=False, upstream_branch="unknown"): - metadata_json = { + return { + "type": "chunked", + "artifact": artifact_data, + } + + +def create_metadata_json(models: list, output_dir: Path, custom_name=None, short_name=None, is_20hz=False, upstream_branch="unknown", + onnx_sha256=None, is_big=False) -> None: + bundle_json = { "short_name": short_name, "display_name": custom_name or upstream_branch, "is_20hz": is_20hz, @@ -175,68 +149,54 @@ 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, } + if onnx_sha256: + bundle_json["onnx_sha256"] = onnx_sha256 + # Write metadata to output_dir + metadata_json = { + "bundles": [bundle_json] + } + with open(output_dir / "metadata.json", "w") as f: json.dump(metadata_json, f, indent=2) - - print(f"Generated metadata.json with {len(models)} models.") + print("Generated metadata.json") if __name__ == "__main__": import argparse - import glob - parser = argparse.ArgumentParser(description="Generate metadata for model files") - parser.add_argument("--model-dir", default="./models", help="Directory containing ONNX model files") + parser = argparse.ArgumentParser(description="Generate metadata JSON for the compiled JIT model") + parser.add_argument("--model-dir", default="./models", help="Directory containing the model files") parser.add_argument("--output-dir", default="./output", help="Output directory for metadata") parser.add_argument("--custom-name", help="Custom display name for the model") parser.add_argument("--is-20hz", action="store_true", help="Whether this is a 20Hz model") - parser.add_argument("--validate-only", action="store_true") parser.add_argument("--upstream-branch", default="unknown", help="Upstream branch name") args = parser.parse_args() - if args.validate_only: - metadata_paths = glob.glob(os.path.join(args.model_dir, "*_metadata.pkl")) - if not metadata_paths: - print(f"No metadata files found in {args.model_dir}", file=sys.stderr) - sys.exit(1) - validate_model_outputs([Path(p) for p in metadata_paths]) - print(f"Validated {len(metadata_paths)} metadata files successfully.") - sys.exit(0) - - # Find all ONNX files in the given directory - model_paths = glob.glob(os.path.join(args.model_dir, "*.onnx")) - if not model_paths: - print(f"No ONNX files found in {args.model_dir}", file=sys.stderr) - sys.exit(1) - _output_dir = Path(args.output_dir) _output_dir.mkdir(exist_ok=True, parents=True) _short_name = create_short_name(args.custom_name) if args.custom_name else None + _pkl = create_pkl_name(args.custom_name) if args.custom_name else None _driving_pkl = _find_driving_pkl(_output_dir) if not _driving_pkl: print(f"No driving_tinygrad.pkl found in {_output_dir}", file=sys.stderr) sys.exit(1) - if _short_name: - new_pkl = _output_dir / f"driving_{_short_name.lower()}_tinygrad.pkl" + is_big = _driving_pkl.name.startswith('big_') + + if _pkl: + new_pkl = _output_dir / f"driving_{_pkl}_tinygrad.pkl" if not new_pkl.exists(): _driving_pkl = _rename_pkl_with_chunks(_driving_pkl, new_pkl) else: _driving_pkl = new_pkl - _models = [] - - for _model_path in model_paths: - _model_metadata = generate_metadata(Path(_model_path), _output_dir, _short_name, _driving_pkl) - if _model_metadata: - _models.append(_model_metadata) - - if _models: - create_metadata_json(_models, _output_dir, args.custom_name, _short_name, args.is_20hz, args.upstream_branch) - else: - print("No models processed.", file=sys.stderr) + _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, is_big=is_big) diff --git a/release/ci/publish.sh b/release/ci/publish.sh index 27904caf5d..4b328a035c 100755 --- a/release/ci/publish.sh +++ b/release/ci/publish.sh @@ -47,11 +47,17 @@ 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 . +# gitlinks break the release tree on device +if git ls-files -s | awk '$1 == "160000" { found = 1; print } END { exit !found }'; then + echo "Error: submodules found in release tree." + exit 1 +fi + # include source commit hash and build date in commit GIT_HASH=$(git --git-dir=$SOURCE_DIR/.git rev-parse HEAD) DATETIME=$(date '+%Y-%m-%dT%H:%M:%S') diff --git a/release/ci/uninstall_github_runner.sh b/release/ci/uninstall_github_runner.sh deleted file mode 100755 index 5f3acfbafd..0000000000 --- a/release/ci/uninstall_github_runner.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -# Determine BASE_DIR based on mount point -if mountpoint -q /data/media; then - GITHUB_BASE_DIR="/data/media/0/github" -else - GITHUB_BASE_DIR="/data/github" -fi - -# Define directories and user -BIN_DIR="$GITHUB_BASE_DIR/bin" -BUILDS_DIR="$GITHUB_BASE_DIR/builds" -OPENPILOT_DIR="$GITHUB_BASE_DIR/openpilot" -LOGS_DIR="$GITHUB_BASE_DIR/logs" -CACHE_DIR="$GITHUB_BASE_DIR/cache" -RUNNER_USERNAME="github-runner" -# Define the systemd service name -SERVICE_NAME="github-runner" -USER_GROUPS="comma,gpu,gpio,sudo" - -# Function to stop and disable the systemd service -stop_and_uninstall_service() { - cd $GITHUB_BASE_DIR/runner - sudo ./svc.sh stop - sudo ./svc.sh uninstall -} - -# Function to remove the systemd service file -remove_runner() { - cd $GITHUB_BASE_DIR/runner - sudo rm .runner - sudo su -c './config.sh remove' github-runner -} - -# Function to delete the Github Runner directories -delete_directories() { - sudo rm -rf "$BIN_DIR/github-runner" - sudo rm -rf "$GITHUB_BASE_DIR" "$BIN_DIR" "$BUILDS_DIR" "$LOGS_DIR" "$CACHE_DIR" "$OPENPILOT_DIR" -} - -# Function to remove the Github Runner user -delete_user() { - for group in ${USER_GROUPS//,/ } - do - sudo gpasswd -d ${RUNNER_USERNAME} ${group} - done - sudo userdel -r ${RUNNER_USERNAME} -} - -# Function to remove sudoers entry -remove_sudoers_entry() { - sudo sed -i.bak "/${RUNNER_USERNAME} ALL=(ALL) NOPASSWD: ALL/d" /etc/sudoers -} - -# Make filesystem writable -sudo mount -o remount rw / - -# Ensure filesystem is remounted as read-only on script exit -trap "sudo mount -o remount ro /" EXIT - -# Call functions -stop_and_uninstall_service -remove_runner -delete_directories -delete_user -remove_sudoers_entry -# End of uninstall script diff --git a/release/ci/upload_default_model.py b/release/ci/upload_default_model.py new file mode 100644 index 0000000000..eac48e3be4 --- /dev/null +++ b/release/ci/upload_default_model.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +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 argparse +import hashlib +import json +import tempfile + +from huggingface_hub import HfApi, hf_hub_download + + +def hash_file(path: str) -> str: + digest = hashlib.sha256() + with open(path, 'rb') as f: + while block := f.read(1024 * 1024): + digest.update(block) + return digest.hexdigest() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--hf-repo", required=True) + parser.add_argument("--hf-defaults-path", required=True) + parser.add_argument("--artifact-name", required=True) + parser.add_argument("--model-dir", required=True) + parser.add_argument("--onnx-path", required=True) + parser.add_argument("--onnx-ref", required=True) + parser.add_argument("--model-name", required=True) + parser.add_argument("--tinygrad-ref", required=True) + parser.add_argument("--run-number", required=True) + args = parser.parse_args() + + 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}" + + print(f"ONNX hash: {onnx_sha256}") + print(f"ONNX ref: {args.onnx_ref} (short: {short_ref})") + print(f"Folder: {folder_name}") + + metadata_path = f"{args.model_dir}/metadata.json" + with open(metadata_path) as f: + metadata = json.load(f) + + bundle = metadata['bundles'][0] + bundle['display_name'] = args.model_name + bundle['onnx_sha256'] = onnx_sha256 + bundle['onnx_ref'] = args.onnx_ref + + artifact = bundle['models'][0]['artifact'] + hf_base = f"https://huggingface.co/datasets/{args.hf_repo}/resolve/main/{args.hf_defaults_path}/{folder_name}" + artifact['download_uri']['url'] = f"{hf_base}/{artifact['file_name']}" + for chunk in artifact.get('chunks', []): + chunk['url'] = f"{hf_base}/{chunk['file_name']}" + + print(f"Uploading model to {args.hf_defaults_path}/{folder_name}/") + api.upload_folder( + folder_path=args.model_dir, + path_in_repo=f"{args.hf_defaults_path}/{folder_name}", + repo_id=args.hf_repo, + repo_type="dataset", + ) + + json_filename = f"{args.hf_defaults_path}/default_models.json" + try: + local_path = hf_hub_download(repo_id=args.hf_repo, repo_type='dataset', filename=json_filename) + with open(local_path) as f: + defaults_json = json.load(f) + except Exception: + defaults_json = {"tinygrad_ref": args.tinygrad_ref, "bundles": []} + + defaults_json['tinygrad_ref'] = args.tinygrad_ref + + existing_idx = next((i for i, b in enumerate(defaults_json['bundles']) + if b.get('onnx_sha256') == onnx_sha256), None) + if existing_idx is not None: + defaults_json['bundles'][existing_idx] = bundle + else: + defaults_json['bundles'].append(bundle) + + print(json.dumps(defaults_json, indent=2)) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(defaults_json, f, indent=2) + tmp_path = f.name + + api.upload_file( + path_or_fileobj=tmp_path, + path_in_repo=json_filename, + repo_id=args.hf_repo, + repo_type="dataset", + ) + + print(f"Updated {json_filename}") + + +if __name__ == "__main__": + main() diff --git a/scripts/docs.py b/scripts/docs.py deleted file mode 100644 index d60bfb791f..0000000000 --- a/scripts/docs.py +++ /dev/null @@ -1,63 +0,0 @@ -""" - wrapper that materializes symlinks in docs/ before build - - we can delete this once zensical supports symlinks: - https://github.com/zensical/backlog/issues/55 -""" -import os -import shutil -import signal -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -DOCS_DIR = REPO_ROOT / "docs" -SITE_DIR = REPO_ROOT / "docs_site" -sys.path.insert(0, str(REPO_ROOT)) -# Local docs build helpers live under docs/ so they stay near the content -# source. The wrapper prunes them from docs_site/ after build. -sys.path.insert(0, str(DOCS_DIR)) - - -def _materialize(docs: Path) -> dict[Path, str]: - originals: dict[Path, str] = {} - for link in docs.rglob("*"): - if not link.is_symlink(): - continue - target = link.resolve() - if not target.is_file(): - continue - originals[link] = os.readlink(link) - link.unlink() - shutil.copy2(target, link) - return originals - - -def _restore(originals: dict[Path, str]) -> None: - for link, target in originals.items(): - link.unlink(missing_ok=True) - os.symlink(target, link) - - -def _raise_interrupt(*_): - raise KeyboardInterrupt - - -def _prune_site_output() -> None: - shutil.rmtree(SITE_DIR / "ext", ignore_errors=True) - - -def main() -> None: - signal.signal(signal.SIGTERM, _raise_interrupt) - originals = _materialize(DOCS_DIR) - try: - from zensical.main import cli - cli(standalone_mode=False) - if len(sys.argv) > 1 and sys.argv[1] == "build": - _prune_site_output() - finally: - _restore(originals) - - -if __name__ == "__main__": - main() diff --git a/scripts/lint/check_added_large_files.py b/scripts/lint/check_added_large_files.py new file mode 100755 index 0000000000..c1aa820181 --- /dev/null +++ b/scripts/lint/check_added_large_files.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +import argparse +import math +import os +import subprocess + + +def lfs_files(filenames: list[str]) -> set[str]: + if not filenames: + return set() + + result = subprocess.run( + ("git", "check-attr", "filter", "-z", "--stdin"), + input="\0".join(filenames), + check=True, + capture_output=True, + text=True, + ) + fields = result.stdout.rstrip("\0").split("\0") if result.stdout else [] + return {fields[i] for i in range(0, len(fields), 3) if fields[i + 2] == "lfs"} + + +def check_added_large_files(filenames: list[str], max_kb: int) -> int: + failed = False + ignored = lfs_files(filenames) + for filename in filenames: + if filename in ignored: + continue + + size_kb = math.ceil(os.stat(filename).st_size / 1024) + if size_kb > max_kb: + print(f"{filename} ({size_kb} KB) exceeds {max_kb} KB.") + failed = True + + return int(failed) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Check that tracked files do not exceed a size limit.") + parser.add_argument("filenames", nargs="*") + parser.add_argument("--maxkb", type=int, default=500, help="maximum allowable size in KiB") + args = parser.parse_args() + return check_added_large_files(args.filenames, args.maxkb) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lint/check_indentation.py b/scripts/lint/check_indentation.py new file mode 100755 index 0000000000..533dd2efa9 --- /dev/null +++ b/scripts/lint/check_indentation.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +import argparse +import tokenize + + +# TODO: remove this once https://github.com/astral-sh/ruff/issues/8705 is closed +def check_indentation(filename: str, indent_width: int = 2) -> bool: + failed = False + indent_stack = [0] + + with tokenize.open(filename) as f: + tokens = tokenize.generate_tokens(f.readline) + for token in tokens: + if token.type == tokenize.INDENT: + indentation = token.string + width = len(indentation) + expected = indent_stack[-1] + indent_width + + if indentation != " " * expected: + found = "indentation containing tabs" if "\t" in indentation else f"{width} spaces" + print(f"{filename}:{token.start[0]}:1: expected {expected} spaces, found {found}") + failed = True + indent_stack.append(width) + elif token.type == tokenize.DEDENT: + indent_stack.pop() + + return failed + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Check Python block indentation.") + parser.add_argument("filenames", nargs="+") + args = parser.parse_args() + + failed = False + for filename in args.filenames: + failed |= check_indentation(filename) + raise SystemExit(failed) diff --git a/scripts/lint/check_shebang_scripts_are_executable.py b/scripts/lint/check_shebang_scripts_are_executable.py new file mode 100755 index 0000000000..7288640091 --- /dev/null +++ b/scripts/lint/check_shebang_scripts_are_executable.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +import argparse +import shlex +import subprocess +import sys + + +def staged_modes(filenames: list[str]) -> list[tuple[str, str]]: + if not filenames: + return [] + + result = subprocess.run( + ("git", "ls-files", "-z", "--stage", "--", *filenames), + check=True, + capture_output=True, + text=True, + ) + entries = result.stdout.rstrip("\0").split("\0") if result.stdout else [] + return [(entry.split(" ", 1)[0], entry.split("\t", 1)[1]) for entry in entries] + + +def has_shebang(filename: str) -> bool: + with open(filename, "rb") as f: + return f.read(2) == b"#!" + + +def check_shebang_scripts_are_executable(filenames: list[str]) -> int: + failed = False + for mode, filename in staged_modes(filenames): + if mode != "100755" and has_shebang(filename): + quoted = shlex.quote(filename) + print("\n".join(( + f"{filename}: has a shebang but is not marked executable!", + f" If it is supposed to be executable, try: `chmod +x {quoted}`", + " If it is not supposed to be executable, double-check its shebang is wanted.\n", + )), file=sys.stderr) + failed = True + + return int(failed) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Check that tracked files with shebangs are executable.") + parser.add_argument("filenames", nargs="*") + args = parser.parse_args() + return check_shebang_scripts_are_executable(args.filenames) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lint/lint.sh b/scripts/lint/lint.sh index 024a743912..8f3aab4e52 100755 --- a/scripts/lint/lint.sh +++ b/scripts/lint/lint.sh @@ -46,8 +46,9 @@ function run_tests() { PYTHON_FILES=$2 run "ruff" ruff check openpilot --quiet - run "check_added_large_files" python3 -m pre_commit_hooks.check_added_large_files --enforce-all $ALL_FILES --maxkb=120 - run "check_shebang_scripts_are_executable" python3 -m pre_commit_hooks.check_shebang_scripts_are_executable $ALL_FILES + run "check_indentation" $DIR/check_indentation.py $PYTHON_FILES + run "check_added_large_files" $DIR/check_added_large_files.py --maxkb=120 $ALL_FILES + run "check_shebang_scripts_are_executable" $DIR/check_shebang_scripts_are_executable.py $ALL_FILES run "check_shebang_format" $DIR/check_shebang_format.sh $ALL_FILES run "check_nomerge_comments" $DIR/check_nomerge_comments.sh $ALL_FILES @@ -66,6 +67,7 @@ function help() { echo "" echo -e "${BOLD}${UNDERLINE}Tests:${NC}" echo -e " ${BOLD}ruff${NC}" + echo -e " ${BOLD}check_indentation${NC}" echo -e " ${BOLD}ty${NC}" echo -e " ${BOLD}codespell${NC}" echo -e " ${BOLD}check_added_large_files${NC}" diff --git a/scripts/reporter.py b/scripts/reporter.py deleted file mode 100755 index 5de5521835..0000000000 --- a/scripts/reporter.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python3 -import os -import glob - -from tinygrad.nn.onnx import OnnxPBParser - -BASEDIR = os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), "../")) - -MASTER_PATH = os.getenv("MASTER_PATH", BASEDIR) -MODEL_PATH = "/openpilot/selfdrive/modeld/models/" - - -class MetadataOnnxPBParser(OnnxPBParser): - def _parse_ModelProto(self) -> dict: - obj = {"metadata_props": []} - for fid, wire_type in self._parse_message(self.reader.len): - match fid: - case 14: - obj["metadata_props"].append(self._parse_StringStringEntryProto()) - case _: - self.reader.skip_field(wire_type) - return obj - - -def get_checkpoint(f): - model = MetadataOnnxPBParser(f).parse() - metadata = {prop["key"]: prop["value"] for prop in model["metadata_props"]} - # "" or "...//"; combined models list vision then policy - parts = metadata['model_checkpoint'].split('/') - return parts[-2] if len(parts) > 1 else parts[0] - - -if __name__ == "__main__": - print("| | master | PR branch |") - print("|-| ----- | --------- |") - - for f in glob.glob(BASEDIR + MODEL_PATH + "/*.onnx"): - fn = os.path.basename(f) - master_path = MASTER_PATH + MODEL_PATH + fn - if os.path.exists(master_path): - master = get_checkpoint(master_path) - master_col = f"[{master}](https://reporter.comma.life/{master})" - else: - master_col = "N/A (new model)" - pr = get_checkpoint(BASEDIR + MODEL_PATH + fn) - print("|", fn, "|", master_col, "|", f"[{pr}](https://reporter.comma.life/{pr})", "|") diff --git a/site_scons/site_tools/cython.py b/site_scons/site_tools/cython.py deleted file mode 100644 index f11db1d71b..0000000000 --- a/site_scons/site_tools/cython.py +++ /dev/null @@ -1,75 +0,0 @@ -import re -import SCons -from SCons.Action import Action -from SCons.Scanner import Scanner -import numpy as np - -pyx_from_import_re = re.compile(r'^from\s+(\S+)\s+cimport', re.M) -pyx_import_re = re.compile(r'^cimport\s+(\S+)', re.M) -cdef_import_re = re.compile(r'^cdef extern from\s+.(\S+).:', re.M) - -np_version = SCons.Script.Value(np.__version__) - -def pyx_scan(node, env, path, arg=None): - contents = node.get_text_contents() - env.Depends(str(node).split('.')[0] + env['CYTHONCFILESUFFIX'], np_version) - - # from cimport ... - matches = pyx_from_import_re.findall(contents) - # cimport - matches += pyx_import_re.findall(contents) - - # Modules can be either .pxd or .pyx files - files = [m.replace('.', '/') + '.pxd' for m in matches] - files += [m.replace('.', '/') + '.pyx' for m in matches] - - # cdef extern from - files += cdef_import_re.findall(contents) - - # Handle relative imports - cur_dir = str(node.get_dir()) - files = [cur_dir + f if f.startswith('/') else f for f in files] - - # Filter out non-existing files (probably system imports) - files = [f for f in files if env.File(f).exists()] - return env.File(files) - - -pyxscanner = Scanner(function=pyx_scan, skeys=['.pyx', '.pxd'], recursive=True) -cythonAction = Action("$CYTHONCOM") - - -def create_builder(env): - try: - cython = env['BUILDERS']['Cython'] - except KeyError: - cython = SCons.Builder.Builder( - action=cythonAction, - emitter={}, - suffix=cython_suffix_emitter, - single_source=1 - ) - env.Append(SCANNERS=pyxscanner) - env['BUILDERS']['Cython'] = cython - return cython - -def cython_suffix_emitter(env, source): - return "$CYTHONCFILESUFFIX" - -def generate(env): - env["CYTHON"] = "cythonize" - env["CYTHONCOM"] = "$CYTHON $CYTHONFLAGS $SOURCE" - env["CYTHONCFILESUFFIX"] = ".cpp" - - c_file, _ = SCons.Tool.createCFileBuilders(env) - - c_file.suffix['.pyx'] = cython_suffix_emitter - c_file.add_action('.pyx', cythonAction) - - c_file.suffix['.py'] = cython_suffix_emitter - c_file.add_action('.py', cythonAction) - - create_builder(env) - -def exists(env): - return True diff --git a/system/hardware/tici/agnos.json b/system/hardware/tici/agnos.json new file mode 120000 index 0000000000..7bc8df1b8c --- /dev/null +++ b/system/hardware/tici/agnos.json @@ -0,0 +1 @@ +../../../openpilot/system/hardware/comma/agnos.json \ No newline at end of file diff --git a/teleoprtc_repo b/teleoprtc_repo index 22df577821..1aa8fc433b 160000 --- a/teleoprtc_repo +++ b/teleoprtc_repo @@ -1 +1 @@ -Subproject commit 22df577821862e32a011fb0cf42577599f3a79c4 +Subproject commit 1aa8fc433bef1519a95c0700c96258c3be6dfb34 diff --git a/tinygrad_repo b/tinygrad_repo index ac1632ab96..66ee3cfb4f 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit ac1632ab966c77ba96a7048b893a30f1a714dc87 +Subproject commit 66ee3cfb4f3a3908a6a20ddfbec7774ba7c09b4e diff --git a/tools/CTF.md b/tools/CTF.md index 2b877a0e25..ae7bea09c2 100644 --- a/tools/CTF.md +++ b/tools/CTF.md @@ -5,7 +5,7 @@ Welcome to the first part of the comma CTF! * there's 2 flags in each segment, with roughly increasing difficulty * everything you'll need to find the flags is in the openpilot repo * grep is also your friend - * first, [setup](https://github.com/commaai/openpilot/tree/master/openpilot/tools#setup-your-pc) your PC + * first, [setup](https://github.com/commaai/openpilot/tree/master/tools) your PC * read the docs & checkout out the tools in openpilot/tools/ * tip: once you get the replay and UI up, start by familiarizing yourself with seeking in replay diff --git a/tools/README.md b/tools/README.md index 1ea42bbe1d..ae36282828 100644 --- a/tools/README.md +++ b/tools/README.md @@ -45,6 +45,14 @@ Learn about the openpilot ecosystem and tools by playing our [CTF](/tools/CTF.md ## Directory Structure +``` +├── car_porting/ # Tools for porting new cars +├── release/ # Scripts for building openpilot releases +└── scripts/ # Miscellaneous scripts +``` + +Development tools such as cabana, plotjuggler, and replay live in [openpilot/tools/](/openpilot/tools/): + ``` ├── cabana/ # View and plot CAN messages from drives or in realtime ├── camerastream/ # Cameras stream over the network @@ -52,8 +60,5 @@ Learn about the openpilot ecosystem and tools by playing our [CTF](/tools/CTF.md ├── lib/ # Libraries to support the tools and reading openpilot logs ├── plotjuggler/ # A tool to plot openpilot logs ├── replay/ # Replay drives and mock openpilot services -├── scripts/ # Miscellaneous scripts -├── serial/ # Tools for using the comma serial -├── sim/ # Run openpilot in a simulator -└── webcam/ # Run openpilot on a PC with webcams +└── sim/ # Run openpilot in a simulator ``` diff --git a/tools/car_porting/README.md b/tools/car_porting/README.md index 07ca2d0eba..ef4c6ef80b 100644 --- a/tools/car_porting/README.md +++ b/tools/car_porting/README.md @@ -32,7 +32,7 @@ Finds common bugs for car interfaces, without even requiring a route. #### Example: Typo in signal name ```bash -> pytest openpilot/selfdrive/car/tests/test_car_interfaces.py -k subaru # replace with the brand you are working on +> tools/test_runner.py openpilot/selfdrive/car/tests/test_car_interfaces.py -k subaru # replace with the brand you are working on ===================================================================== FAILED openpilot/selfdrive/car/tests/test_car_interfaces.py::TestCarInterfaces::test_car_interfaces_165_SUBARU_LEGACY_7TH_GEN - KeyError: 'CruiseControlOOPS' @@ -52,9 +52,9 @@ FAIL: test_panda_safety_carstate (__main__.CarModelTestCase.test_panda_safety_ca Assert that panda safety matches openpilot's carState ---------------------------------------------------------------------- Traceback (most recent call last): - File "/home/batman/xx/openpilot/openpilot/selfdrive/car/tests/test_models.py", line 380, in test_panda_safety_carstate - self.assertFalse(len(failed_checks), f"panda safety doesn't agree with openpilot: {failed_checks}") -AssertionError: 1 is not false : panda safety doesn't agree with openpilot: {'gasPressed': 116} + File "/home/batman/openpilot/opendbc_repo/opendbc/car/tests/test_models.py", line 440, in test_panda_safety_carstate + self.assertFalse(failed_checks, f"panda safety doesn't agree with CarState: {failed_checks}") +AssertionError: {'gasPressed': 116} is not false : panda safety doesn't agree with CarState: {'gasPressed': 116} ``` ## Jupyter notebooks diff --git a/tools/car_porting/examples/find_segments_with_message.ipynb b/tools/car_porting/examples/find_segments_with_message.ipynb index af17bde52b..f91827f7e5 100644 --- a/tools/car_porting/examples/find_segments_with_message.ipynb +++ b/tools/car_porting/examples/find_segments_with_message.ipynb @@ -9,7 +9,6 @@ "source": [ "# Import all cars from opendbc\n", "\n", - "from opendbc.car import structs\n", "from opendbc.car.values import PLATFORMS as TEST_PLATFORMS\n", "\n", "# Example: add additional platforms/segments to test outside of commaCarSegments\n", @@ -147,8 +146,8 @@ } ], "source": [ - "from openpilot.tools.lib.logreader import LogReader, comma_car_segments_source\n", - "from tqdm.notebook import tqdm, tnrange\n", + "from openpilot.tools.lib.logreader import comma_car_segments_source\n", + "from tqdm.notebook import tnrange\n", "\n", "# Example search for CAN ignition messages\n", "# Be careful when filtering by bus, account for odd harness arrangements on Honda/HKG\n", diff --git a/tools/car_porting/examples/ford_vin_fingerprint.ipynb b/tools/car_porting/examples/ford_vin_fingerprint.ipynb index 6b806d22d2..cb5fd8f820 100644 --- a/tools/car_porting/examples/ford_vin_fingerprint.ipynb +++ b/tools/car_porting/examples/ford_vin_fingerprint.ipynb @@ -53,12 +53,12 @@ " if vin.startswith('1FT'):\n", " if vin_positions_567 in F150_CODES:\n", " if vin[7] in LIGHTNING_CODES:\n", - " return f\"FORD F-150 LIGHTNING 1ST GEN\"\n", + " return \"FORD F-150 LIGHTNING 1ST GEN\"\n", " else:\n", - " return f\"FORD F-150 14TH GEN\"\n", + " return \"FORD F-150 14TH GEN\"\n", " elif vin.startswith('3FM'):\n", " if vin_positions_567 in MACHE_CODES:\n", - " return f\"FORD MUSTANG MACH-E 1ST GEN\"\n", + " return \"FORD MUSTANG MACH-E 1ST GEN\"\n", " elif vin.startswith('5LM'):\n", " pass\n", "\n", @@ -147,7 +147,8 @@ "source": [ "for vin, real_fingerprint in VINS_TO_CHECK:\n", " determined_fingerprint = ford_vin_fingerprint(vin)\n", - " print(f\"vin: {vin} real platform: {real_fingerprint: <30} determined platform: {determined_fingerprint: <30} correct: {real_fingerprint == determined_fingerprint}\")" + " print(f\"vin: {vin} real platform: {real_fingerprint: <30} \" +\n", + " f\"determined platform: {determined_fingerprint: <30} correct: {real_fingerprint == determined_fingerprint}\")" ] } ], diff --git a/tools/car_porting/examples/hkg_canfd_gear_message.ipynb b/tools/car_porting/examples/hkg_canfd_gear_message.ipynb index f0bca8decc..ec902b7d10 100644 --- a/tools/car_porting/examples/hkg_canfd_gear_message.ipynb +++ b/tools/car_porting/examples/hkg_canfd_gear_message.ipynb @@ -21,9 +21,7 @@ } ], "source": [ - "from opendbc.car import structs\n", "from opendbc.car.hyundai.values import CAR, HyundaiFlags\n", - "from opendbc.car.hyundai.fingerprints import FW_VERSIONS\n", "\n", "TEST_PLATFORMS = set(CAR.with_flags(HyundaiFlags.CANFD)) & set(CAR.with_flags(HyundaiFlags.EV)) # CAN-FD electric vehicles only\n", "#TEST_PLATFORMS = set(CAR.with_flags(HyundaiFlags.CANFD)) - set(CAR.with_flags(HyundaiFlags.EV)) # CAN-FD hybrid and ICE vehicles only\n", @@ -190,15 +188,13 @@ ], "source": [ "import copy\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", "\n", "from opendbc.can.parser import CANParser\n", "from opendbc.car.hyundai.values import DBC\n", "from opendbc.car.hyundai.hyundaicanfd import CanBus\n", "\n", "from openpilot.selfdrive.pandad import can_capnp_to_list\n", - "from openpilot.tools.lib.logreader import LogReader, comma_car_segments_source\n", + "from openpilot.tools.lib.logreader import comma_car_segments_source\n", "\n", "message_names = [\"GEAR_SHIFTER\", \"ACCELERATOR\", \"GEAR\", \"GEAR_ALT\", \"GEAR_ALT_2\"]\n", "\n", @@ -229,11 +225,11 @@ " for i, parsed_messages in enumerate(parsed_message_history):\n", " gear = parsed_messages[name][\"GEAR\"]\n", " if gear != gear_prev:\n", - " print(f\" *** Signal transition found! ***\")\n", + " print(\" *** Signal transition found! ***\")\n", " examples.append(i)\n", " gear_prev = gear\n", "\n", - "print(f\"Analysis finished\")\n" + "print(\"Analysis finished\")\n" ] }, { diff --git a/tools/car_porting/test_car_model.py b/tools/car_porting/test_car_model.py index 20e7d136ea..3fb7ec9ae6 100755 --- a/tools/car_porting/test_car_model.py +++ b/tools/car_porting/test_car_model.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 import argparse import sys -import unittest # noqa: TID251 +import unittest from opendbc.car.tests.routes import CarTestRoute -from openpilot.selfdrive.car.tests.test_models import TestCarModel +from opendbc.car.tests.test_models import TestCarModelBase from openpilot.tools.lib.route import SegmentRange @@ -12,14 +12,14 @@ def create_test_models_suite(routes: list[CarTestRoute]) -> unittest.TestSuite: for test_route in routes: # create new test case and discover tests test_case_args = {"platform": test_route.car_model, "test_route": test_route} - CarModelTestCase = type("CarModelTestCase", (TestCarModel,), test_case_args) + CarModelTestCase = type("CarModelTestCase", (TestCarModelBase,), test_case_args) test_suite.addTest(unittest.TestLoader().loadTestsFromTestCase(CarModelTestCase)) return test_suite if __name__ == "__main__": parser = argparse.ArgumentParser(description="Test any route against common issues with a new car port. " + - "Uses openpilot/selfdrive/car/tests/test_models.py") + "Uses opendbc_repo/opendbc/car/tests/test_models.py") parser.add_argument("route_or_segment_name", help="Specify route to run tests on") parser.add_argument("--car", help="Specify car model for test route") args = parser.parse_args() diff --git a/tools/op.sh b/tools/op.sh index 1ee7b232b0..3d7d17a76b 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -19,18 +19,6 @@ RC_FILE="${HOME}/.$(basename ${SHELL})rc" if [ "$(uname)" == "Darwin" ] && [ $SHELL == "/bin/bash" ]; then RC_FILE="$HOME/.bash_profile" fi -function op_install() { - echo "Installing op system-wide..." - OP_SH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )/op.sh" - CMD=$(cat </dev/null || printf '\n%s\n' "$CMD" >> "$RC_FILE" - echo -e " ↳ [${GREEN}✔${NC}] op installed successfully. Open a new shell to use it." -} function retry() { local attempts=$1 @@ -99,7 +87,6 @@ function op_check_openpilot_dir() { echo -e " ↳ [${GREEN}✔${NC}] openpilot found." return 0 fi - echo -e " ↳ [${RED}✗${NC}] openpilot directory not found! Make sure that you are" echo " inside the openpilot directory or specify one with the" echo " --dir option!" @@ -193,6 +180,17 @@ function op_before_cmd() { } function op_setup() { + echo "Installing op system-wide..." + OP_SH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )/op.sh" + CMD=$(cat </dev/null || printf '\n%s\n' "$CMD" >> "$RC_FILE" + echo -e " ↳ [${GREEN}✔${NC}] op installed successfully. Open a new shell to use it." + op_get_openpilot_dir cd $OPENPILOT_ROOT @@ -327,7 +325,7 @@ function op_lint() { function op_test() { op_before_cmd - op_run_command pytest "$@" + op_run_command tools/test_runner.py "$@" } function op_replay() { @@ -351,6 +349,32 @@ function op_clip() { op_run_command openpilot/tools/clip/run.py "$@" } +function op_check_agnos_update() { + if [[ ! -f "/AGNOS" ]]; then + return 0 + fi + + local choice current_version target_version + current_version="$(< /VERSION)" + target_version="$(unset AGNOS_VERSION; source "$OPENPILOT_ROOT/launch_env.sh"; echo "$AGNOS_VERSION")" + + if [[ "$current_version" == "$target_version" ]]; then + return 0 + fi + + echo -e "${BOLD}AGNOS update available:${NC} $current_version → $target_version" + if read -r -p "Install it now? [y/N] " choice && [[ "$choice" =~ ^[Yy]$ ]]; then + op_run_command "$OPENPILOT_ROOT/openpilot/common/hardware/comma/agnos.py" --swap \ + "$OPENPILOT_ROOT/openpilot/common/hardware/comma/agnos.json" + + if read -r -p "Reboot now to apply the update? [y/N] " choice && [[ "$choice" =~ ^[Yy]$ ]]; then + op_run_command sudo reboot + else + echo "Reboot before starting openpilot to apply the AGNOS update." + fi + fi +} + function op_switch() { REMOTE="origin" if [ "$#" -gt 1 ]; then @@ -378,11 +402,14 @@ function op_switch() { # remove openpilot update flag if present rm -f .overlay_init + + op_check_agnos_update } function op_start() { if [[ -f "/AGNOS" ]]; then op_before_cmd + op_check_agnos_update op_run_command sudo systemctl restart comma $@ fi } @@ -409,9 +436,8 @@ function op_default() { echo -e " ${BOLD}check${NC} Check the development environment (git, os) to start using openpilot" echo -e " ${BOLD}esim${NC} Manage eSIM profiles on your comma device" echo -e " ${BOLD}venv${NC} Activate the python virtual environment" - echo -e " ${BOLD}setup${NC} Install openpilot dependencies" + echo -e " ${BOLD}setup${NC} Install the 'op' tool and openpilot dependencies" echo -e " ${BOLD}build${NC} Run the openpilot build system in the current working directory" - echo -e " ${BOLD}install${NC} Install the 'op' tool system wide" echo -e " ${BOLD}switch${NC} Switch to a different git branch with a clean slate (nukes any changes)" echo -e " ${BOLD}start${NC} Starts (or restarts) openpilot" echo -e " ${BOLD}stop${NC} Stops openpilot" @@ -431,7 +457,7 @@ function op_default() { echo -e " ${BOLD}sim${NC} Run openpilot in a simulator" echo -e " ${BOLD}lint${NC} Run the linter" echo -e " ${BOLD}post-commit${NC} Install the linter as a post-commit hook" - echo -e " ${BOLD}test${NC} Run all unit tests from pytest" + echo -e " ${BOLD}test${NC} Run all unit tests" echo "" echo -e "${BOLD}${UNDERLINE}Options:${NC}" echo -e " ${BOLD}-d, --dir${NC}" @@ -477,7 +503,6 @@ function _op() { replay ) shift 1; op_replay "$@" ;; clip ) shift 1; op_clip "$@" ;; sim ) shift 1; op_sim "$@" ;; - install ) shift 1; op_install "$@" ;; switch ) shift 1; op_switch "$@" ;; start ) shift 1; op_start "$@" ;; stop ) shift 1; op_stop "$@" ;; diff --git a/tools/release/build_release.sh b/tools/release/build_release.sh index 6a019a80d0..21d0bba449 100755 --- a/tools/release/build_release.sh +++ b/tools/release/build_release.sh @@ -2,15 +2,14 @@ set -e set -x -# git diff --name-status origin/release3-staging | grep "^A" | less - DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" - cd $DIR BUILD_DIR=/data/openpilot SOURCE_DIR="$(git rev-parse --show-toplevel)" +export PYTHONPATH="$BUILD_DIR:$BUILD_DIR/msgq_repo:$BUILD_DIR/opendbc_repo:$BUILD_DIR/rednose_repo:$BUILD_DIR/teleoprtc_repo:$BUILD_DIR/tinygrad_repo" + if [ -z "$RELEASE_BRANCH" ]; then echo "RELEASE_BRANCH is not set" exit 1 @@ -23,32 +22,36 @@ BUILD_BRANCH=release-mici-staging source $DIR/identity.sh echo "[-] Setting up repo T=$SECONDS" -rm -rf $BUILD_DIR -mkdir -p $BUILD_DIR +if ! git -C "$SOURCE_DIR" worktree remove --force "$BUILD_DIR" 2>/dev/null; then + rm -rf $BUILD_DIR +fi +git -C "$SOURCE_DIR" worktree prune +git -C "$SOURCE_DIR" worktree add --detach --no-checkout "$BUILD_DIR" cd $BUILD_DIR -git init -git remote add origin git@github.com:commaai/openpilot.git -git checkout --orphan $BUILD_BRANCH +git update-ref -d "refs/heads/$BUILD_BRANCH" +git symbolic-ref HEAD "refs/heads/$BUILD_BRANCH" +git read-tree --empty # do the files copy echo "[-] copying files T=$SECONDS" cd $SOURCE_DIR -cp -pR --parents $(./tools/release/release_files.py) $BUILD_DIR/ +./tools/release/release_files.py | xargs -0 cp -pR --parents -t "$BUILD_DIR" -- # in the directory cd $BUILD_DIR -rm -f panda/board/obj/panda.bin.signed -rm -f panda/board/obj/panda_h7.bin.signed +# use the full CPU available for speeding up the build. +# openpilot resets the CPU frequencies when test_onroad.py runs below. +for policy in /sys/devices/system/cpu/cpufreq/policy*; do + [ -d "$policy" ] || continue + hardware_max="$(cat "$policy/cpuinfo_max_freq")" + echo "$hardware_max" | sudo tee "$policy/scaling_max_freq" >/dev/null +done -VERSION=$(cat openpilot/sunnypilot/common/version.h | awk -F[\"-] '{print $2}') -echo "[-] committing version $VERSION T=$SECONDS" -git add -f . -git commit -a -m "openpilot v$VERSION release" - -# Build -export PYTHONPATH="$BUILD_DIR" scons +if [ -n "$INCLUDE_BIG_MODEL" ]; then + test -f openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest +fi if [ -z "$PANDA_DEBUG_BUILD" ]; then # release panda fw @@ -71,7 +74,6 @@ find . -name '*.a' -delete find . -name '*.o' -delete find . -name '*.os' -delete find . -name '*.pyc' -delete -find . -name 'moc_*' -delete find . -name '__pycache__' -delete rm -rf .sconsign.dblite Jenkinsfile tools/release/ rm -f openpilot/selfdrive/modeld/models/*.onnx* @@ -87,20 +89,23 @@ git checkout openpilot/third_party/ # Mark as prebuilt release touch prebuilt +VERSION=$(cat openpilot/sunnypilot/common/version.h | awk -F[\"-] '{print $2}') # Add built files to git -git add -f . -git commit --amend -m "openpilot v$VERSION" +# writing larger objects is faster than compressing them on-device +git -c core.compression=0 add -f . +git -c core.compression=0 -c gc.auto=0 commit -m "openpilot v$VERSION" # Run tests cd $BUILD_DIR -RELEASE=1 pytest -n0 -s openpilot/selfdrive/test/test_onroad.py -#pytest openpilot/selfdrive/car/tests/test_car_interfaces.py +RELEASE=1 ./openpilot/selfdrive/test/test_onroad.py +#tools/test_runner.py openpilot/selfdrive/car/tests/test_car_interfaces.py echo "[-] pushing release T=$SECONDS" REFS=() for branch in ${RELEASE_BRANCH//,/ }; do REFS+=("$BUILD_BRANCH:$branch") done -git push -f origin "${REFS[@]}" +# uploading the larger pack is faster than spending CPU to optimize it +git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin "${REFS[@]}" echo "[-] done T=$SECONDS" diff --git a/tools/release/build_stripped.sh b/tools/release/build_stripped.sh index b5092b15a7..6015fdc5dd 100755 --- a/tools/release/build_stripped.sh +++ b/tools/release/build_stripped.sh @@ -30,20 +30,14 @@ git submodule deinit -f --all git rm -rf --cached . find . -maxdepth 1 -not -path './.git' -not -name '.' -not -name '..' -exec rm -rf '{}' \; -# cleanup before the copy -cd $SOURCE_DIR -git clean -xdff -git submodule foreach --recursive git clean -xdff - # do the files copy echo "[-] copying files T=$SECONDS" cd $SOURCE_DIR -./tools/release/release_files.py | xargs -d '\n' cp -pR --parents -t "$TARGET_DIR" +./tools/release/release_files.py | xargs -0 cp -pR --parents -t "$TARGET_DIR" -- # in the directory cd $TARGET_DIR rm -rf .git/modules/ -rm -f panda/board/obj/panda.bin.signed find openpilot/selfdrive/modeld/models -name '*.onnx' -size +95M -exec ./openpilot/common/file_chunker.py {} \; @@ -57,9 +51,10 @@ echo -n "$GIT_HASH" > git_src_commit echo -n "$GIT_COMMIT_DATE" > git_src_commit_date echo "[-] committing version $VERSION T=$SECONDS" -git add -f . +# writing larger objects is faster than compressing them on-device +git -c core.compression=0 add -f . git status -git commit -a -m "sunnypilot v$VERSION release +git -c core.compression=0 commit -a -m "sunnypilot v$VERSION release date: $DATETIME master commit: $GIT_HASH @@ -83,7 +78,8 @@ fi if [ ! -z "$BRANCH" ]; then echo "[-] Pushing to $BRANCH T=$SECONDS" - git push -f origin tmp:$BRANCH + # uploading the larger pack is faster than spending CPU to optimize it + git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin tmp:$BRANCH fi echo "[-] done T=$SECONDS, ready at $TARGET_DIR" diff --git a/tools/release/release_files.py b/tools/release/release_files.py index 7f08719ea8..641dd7ff89 100755 --- a/tools/release/release_files.py +++ b/tools/release/release_files.py @@ -1,13 +1,15 @@ #!/usr/bin/env python3 import os import re -from pathlib import Path +import subprocess +import sys HERE = os.path.abspath(os.path.dirname(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "../..")) blacklist = [ ".git/", + ".venv/", ".github/workflows/", "matlab.*.md", @@ -27,14 +29,17 @@ whitelist: list[str] = [ if __name__ == "__main__": - for f in Path(ROOT).rglob("**/*"): - if not (f.is_file() or f.is_symlink()): + tracked_files = subprocess.check_output(["git", "ls-files", "-z", "--recurse-submodules"], cwd=ROOT).split(b"\0") + for tracked_file in tracked_files: + if not tracked_file: continue - rf = str(f.relative_to(ROOT)) + rf = os.fsdecode(tracked_file) + if not os.getenv("INCLUDE_BIG_MODEL") and rf.startswith("openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"): + continue blacklisted = any(re.search(p, rf) for p in blacklist) whitelisted = any(re.search(p, rf) for p in whitelist) if blacklisted and not whitelisted: continue - print(rf) + sys.stdout.buffer.write(tracked_file + b"\0") diff --git a/tools/scripts/car/max_lat_accel.py b/tools/scripts/car/max_lat_accel.py index dc44e8ac40..b76b3302cc 100755 --- a/tools/scripts/car/max_lat_accel.py +++ b/tools/scripts/car/max_lat_accel.py @@ -62,8 +62,8 @@ def find_events(lr: LogReader, extrapolate: bool = False, qlog: bool = False) -> elif msg.which() == 'controlsState': curvature = msg.controlsState.curvature - elif msg.which() == 'liveParameters': - roll = msg.liveParameters.roll + elif msg.which() == 'vehicleParameters': + roll = msg.vehicleParameters.roll if lat_active > min_lat_active and steering_unpressed > min_steering_unpressed and requesting_max > min_requesting_max: # TODO: record max lat accel at the end of the event, need to use the past lat accel as overriding can happen before we detect it diff --git a/tools/scripts/cycle_alerts.py b/tools/scripts/cycle_alerts.py index 0bd42bcd3f..f78dfe1090 100755 --- a/tools/scripts/cycle_alerts.py +++ b/tools/scripts/cycle_alerts.py @@ -50,12 +50,12 @@ def cycle_alerts(duration=200, is_metric=False): (EventName.cameraFrameRate, ET.PERMANENT), ] - cameras = ['roadCameraState', 'wideRoadCameraState', 'driverCameraState'] + cameras = ['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState'] CS = car.CarState.new_message() CP = CarInterface.get_non_essential_params("HONDA_CIVIC") - sm = messaging.SubMaster(['deviceState', 'pandaStates', 'roadCameraState', 'modelV2', 'liveCalibration', - 'driverMonitoringState', 'longitudinalPlan', 'livePose', + sm = messaging.SubMaster(['deviceState', 'pandaStates', 'narrowRoadCameraState', 'modelV2', 'extrinsicsCalibration', + 'driverMonitoringState', 'longitudinalPlan', 'deviceMotion', 'managerState'] + cameras) pm = messaging.PubMaster(['selfdriveState', 'pandaStates', 'deviceState']) @@ -87,7 +87,7 @@ def cycle_alerts(duration=200, is_metric=False): procs[i].shouldBeRunning = True sm['managerState'].processes = procs - sm['liveCalibration'].rpyCalib = [-1 * random.random() for _ in range(random.randint(0, 3))] + sm['extrinsicsCalibration'].rpyCalib = [-1 * random.random() for _ in range(random.randint(0, 3))] for s in sm.data.keys(): prob = 0.3 if s in cameras else 0.08 diff --git a/tools/scripts/profiling/clpeak/no_print.patch b/tools/scripts/profiling/clpeak/no_print.patch deleted file mode 100644 index 44a5efd10f..0000000000 --- a/tools/scripts/profiling/clpeak/no_print.patch +++ /dev/null @@ -1,39 +0,0 @@ -diff --git a/src/logger.cpp b/src/logger.cpp -index a63c6dd..a1d9860 100644 ---- a/src/logger.cpp -+++ b/src/logger.cpp -@@ -24,34 +24,22 @@ logger::~logger() - - void logger::print(string str) - { -- cout << str; -- cout.flush(); - } - - void logger::print(double val) - { -- cout << setprecision(2) << fixed; -- cout << val; -- cout.flush(); - } - - void logger::print(float val) - { -- cout << setprecision(2) << fixed; -- cout << val; -- cout.flush(); - } - - void logger::print(int val) - { -- cout << val; -- cout.flush(); - } - - void logger::print(unsigned int val) - { -- cout << val; -- cout.flush(); - } - - void logger::xmlOpenTag(string tag) diff --git a/tools/scripts/ssh.py b/tools/scripts/ssh.py index 86e86c7eed..2d8ff46604 100755 --- a/tools/scripts/ssh.py +++ b/tools/scripts/ssh.py @@ -14,7 +14,7 @@ if __name__ == "__main__": parser.add_argument("device", help="device name or dongle id") parser.add_argument("--host", help="ssh jump server host", default="ssh.comma.ai") parser.add_argument("--port", help="ssh jump server port", default=22, type=int) - parser.add_argument("--key", help="ssh key", default=os.path.join(BASEDIR, "openpilot/common/hardware/tici/id_rsa")) + parser.add_argument("--key", help="ssh key", default=os.path.join(BASEDIR, "openpilot/common/hardware/comma/id_rsa")) parser.add_argument("--debug", help="enable debug output", action="store_true") args = parser.parse_args() diff --git a/tools/scripts/test_fw_query_on_routes.py b/tools/scripts/test_fw_query_on_routes.py index ab539e4feb..33e1aa39ba 100755 --- a/tools/scripts/test_fw_query_on_routes.py +++ b/tools/scripts/test_fw_query_on_routes.py @@ -12,7 +12,6 @@ from openpilot.tools.lib.logreader import LogReader, ReadMode from openpilot.tools.lib.route import SegmentRange -NO_API = "NO_API" in os.environ SUPPORTED_BRANDS = VERSIONS.keys() SUPPORTED_CARS = [brand for brand in SUPPORTED_BRANDS for brand in interface_names[brand]] UNKNOWN_BRAND = "unknown" @@ -178,4 +177,3 @@ if __name__ == "__main__": print(f"Correct fuzzy matches: {good_fuzzy}") print(f"Wrong fuzzy matches: {wrong_fuzzy}") print() - diff --git a/tools/setup.sh b/tools/setup.sh index dafd466ef9..ced451ab11 100755 --- a/tools/setup.sh +++ b/tools/setup.sh @@ -33,6 +33,13 @@ cat << 'EOF' EOF } +function check_platform() { + if [[ -f /AGNOS ]]; then + echo -e "[${RED}✗${NC}] This installer is for PCs only. The environment is pre-configured in AGNOS." + return 1 + fi +} + function check_stdin() { if [ -t 0 ]; then INTERACTIVE=1 @@ -121,7 +128,6 @@ function git_clone() { function install_with_op() { cd $OPENPILOT_ROOT - $OPENPILOT_ROOT/tools/op.sh install $OPENPILOT_ROOT/tools/op.sh post-commit if ! $OPENPILOT_ROOT/tools/op.sh setup; then @@ -136,6 +142,7 @@ function install_with_op() { } show_motd +check_platform check_stdin ask_dir check_dir diff --git a/tools/setup_dependencies.sh b/tools/setup_dependencies.sh index 80f3ac4e45..5ad833a5ca 100755 --- a/tools/setup_dependencies.sh +++ b/tools/setup_dependencies.sh @@ -38,13 +38,17 @@ function install_linux_deps() { fi done - # normal stuff, this mostly for bare docker images + # ------------------------------------------------ + # dependencies should never be added to this list. + # these are only for inflating bare docker images + # to their desktop equivalents. + # ------------------------------------------------ if [[ "$missing_linux_deps" -eq 0 ]]; then # the native package managers are slow, so skip if we can echo "[ ] system packages already installed t=$SECONDS" elif command -v apt-get > /dev/null 2>&1; then $SUDO apt-get update - $SUDO apt-get install -y --no-install-recommends ca-certificates build-essential curl libcurl4-openssl-dev locales git + $SUDO apt-get install -y --no-install-recommends ca-certificates build-essential curl libcurl4-openssl-dev locales git xclip wl-clipboard elif command -v dnf > /dev/null 2>&1; then $SUDO dnf install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git elif command -v yum > /dev/null 2>&1; then diff --git a/tools/test_runner.py b/tools/test_runner.py new file mode 100755 index 0000000000..1979ce01e4 --- /dev/null +++ b/tools/test_runner.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +import argparse +from collections import Counter +from concurrent.futures import as_completed, ProcessPoolExecutor +from itertools import batched +import math +import os +from pathlib import Path +import sys +import tempfile +import time +import traceback +import unittest +import warnings + +ROOT = Path(__file__).resolve().parents[1] +IGNORED = ( + ROOT / "openpilot/selfdrive/test/process_replay/test_processes.py", + ROOT / "openpilot/tools/sim", +) +FAILURES = {"failed", "error", "xpassed"} +STATUS_MARKS = { + "passed": (".", 32), + "skipped": ("s", 33), + "xfailed": ("x", 36), + "failed": ("F", 31), + "error": ("E", 31), + "xpassed": ("X", 31), +} + + +def paint(text, code): + if sys.stdout.isatty() and "NO_COLOR" not in os.environ: + return f"\033[{code}m{text}\033[0m" + return text + + +class Capture: + def __init__(self, enabled): + self.enabled = enabled + + def start(self): + if not self.enabled: + return + for stream in (sys.stdout, sys.stderr): + stream.flush() + self.files = [tempfile.TemporaryFile() for _ in range(2)] + self.saved = [os.dup(fd) for fd in (1, 2)] + for fd, file in enumerate(self.files, 1): + os.dup2(file.fileno(), fd) + + def stop(self, keep=True): + if not self.enabled: + return "", "" + for stream in (sys.stdout, sys.stderr): + stream.flush() + for fd, saved in enumerate(self.saved, 1): + os.dup2(saved, fd) + os.close(saved) + output = [] + for file in self.files: + if keep: + file.seek(0) + output.append(file.read().decode(errors="replace")) + file.close() + return output if keep else ("", "") + + +def make_record(test_id, status="passed", detail=""): + return {"id": test_id, "status": status, "detail": detail, "time": 0.0, "stdout": "", "stderr": ""} + + +class Result(unittest.TestResult): + def __init__(self, capture_output): + super().__init__() + self.records = [] + self.current = None + self.capture = Capture(capture_output) + + def startTest(self, test): + self.current = make_record(test.id()) + self.started = time.monotonic() + self.capture.start() + + def stopTest(self, test): + self.current["time"] = time.monotonic() - self.started + keep = self.current["status"] in FAILURES + stdout, stderr = self.capture.stop(keep) + if keep: + self.current["stdout"] = stdout + self.current["stderr"] = stderr + self.records.append(self.current) + self.current = None + + def mark(self, test, status, detail=""): + if self.current is None: # setUpClass/setUpModule can fail before a test starts + self.records.append(make_record(test.id(), status, detail)) + return + if self.current["status"] not in FAILURES or status == "error": + self.current["status"] = status + if detail: + self.current["detail"] += ("\n\n" if self.current["detail"] else "") + detail + + def addFailure(self, test, err): + self.mark(test, "failed", self._exc_info_to_string(err, test)) + + def addError(self, test, err): + self.mark(test, "error", self._exc_info_to_string(err, test)) + + def addSkip(self, test, reason): + self.mark(test, "skipped") + + def addExpectedFailure(self, test, err): + self.mark(test, "xfailed") + + def addUnexpectedSuccess(self, test): + self.mark(test, "xpassed", "Test was expected to fail, but passed.") + + def addSubTest(self, test, subtest, err): + if err: + status = "failed" if issubclass(err[0], test.failureException) else "error" + self.mark(test, status, f"{subtest}\n{self._exc_info_to_string(err, test)}") + + +def flatten(suite): + for test in suite: + if isinstance(test, unittest.TestSuite): + yield from flatten(test) + else: + yield test + + +def module_name(path): + return ".".join(path.resolve().relative_to(ROOT).with_suffix("").parts) + + +def collect(targets, keyword): + use_ignores = not targets + targets = targets or ["openpilot"] + loader = unittest.TestLoader() + tests = [] + errors = [] + names = [] + for target in targets: + path_text, *nodes = target.split("::") + path = Path(path_text) + try: + if path.is_dir(): + files = sorted(path.rglob("test_*.py")) + if use_ignores: + files = [f for f in files if not any(f.resolve().is_relative_to(i) for i in IGNORED)] + names.extend(module_name(file) for file in files) + elif path.is_file(): + names.append(".".join((module_name(path), *nodes))) + elif "/" in path_text or path_text.endswith(".py"): + errors.append(f"{target}: file or directory not found") + else: + names.append(target.replace("::", ".")) + except (OSError, ValueError) as e: + errors.append(str(e)) + for name in dict.fromkeys(names): + before = len(loader.errors) + try: + suite = loader.loadTestsFromName(name) + except Exception: + errors.append(f"Failed to collect {name}\n{traceback.format_exc()}") + continue + errors.extend(loader.errors[before:]) + for test in flatten(suite): + cls = type(test) + if cls.__name__ == "_FailedTest": + continue + if getattr(cls, "__unittest_skip_why__", "") == "parameterized base class": + continue + if not keyword or keyword.lower() in test.id().lower(): + tests.append(test) + return list({test.id(): test for test in tests}.values()), errors + + +def make_batches(tests, workers): + fixture_groups = {} + parallel = [] + for test in tests: + cls = type(test) + module = sys.modules[cls.__module__] + if hasattr(module, "setUpModule") or hasattr(module, "tearDownModule"): + key = cls.__module__ + elif "setUpClass" in cls.__dict__ or "tearDownClass" in cls.__dict__: + key = f"{cls.__module__}.{cls.__qualname__}" + else: + parallel.append(test.id()) + continue + fixture_groups.setdefault(key, []).append(test.id()) + size = max(1, math.ceil(len(tests) / (workers * 4))) + batches = list(fixture_groups.values()) + batches.extend(list(batch) for batch in batched(parallel, size)) + return sorted(batches, key=len, reverse=True) + + +def run_batch(test_ids, capture_output): + result = Result(capture_output) + outside = Capture(capture_output) + os.chdir(ROOT) + outside.start() + try: + unittest.TestLoader().loadTestsFromNames(test_ids).run(result) + finally: + stdout, stderr = outside.stop() + failures = [item for item in result.records if item["status"] in FAILURES] + if failures: # attach class/module fixture output to the first related failure + failures[0]["stdout"] = stdout + failures[0]["stdout"] + failures[0]["stderr"] = stderr + failures[0]["stderr"] + return result.records + + +def run_parallel(batches, workers, warning_action, capture_output): + with ProcessPoolExecutor(max_workers=workers, initializer=warnings.simplefilter, initargs=(warning_action,)) as pool: + futures = {pool.submit(run_batch, batch, capture_output): batch for batch in batches} + for future in as_completed(futures): + try: + yield future.result() + except Exception: + yield [make_record(futures[future][0], "error", traceback.format_exc())] + + +def report(records, errors, duration_count, elapsed): + width = min(100, os.get_terminal_size().columns if sys.stdout.isatty() else 80) + for index, error in enumerate(errors, 1): + print(paint(f"\n{'=' * 8} COLLECTION ERROR {index} {'=' * 8}", 31)) + print(error.rstrip()) + for item in sorted((r for r in records if r["status"] in FAILURES), key=lambda r: r["id"]): + heading = f" {item['status'].upper()} {item['id']} " + print(paint(f"\n{heading:=^{width}}", 31)) + if item["detail"]: + print(item["detail"].rstrip()) + for stream in ("stdout", "stderr"): + if item[stream]: + print(paint(f"\n--- captured {stream} ---", 33)) + print(item[stream].rstrip()) + timed = sorted((r for r in records if r["time"]), key=lambda r: r["time"], reverse=True) + if duration_count: + timed = timed[:duration_count] + if timed: + print(paint("\nslowest tests", 36)) + for item in timed: + print(f"{item['time']:8.2f}s {item['id']}") + counts = Counter(item["status"] for item in records) + parts = [f"{counts[name]} {name}" for name in STATUS_MARKS if counts[name]] + if errors: + parts.append(f"{len(errors)} collection error{'s' if len(errors) != 1 else ''}") + failed = bool(errors) or any(counts[name] for name in FAILURES) + print(paint(f"\n{', '.join(parts) or 'no tests ran'} in {elapsed:.2f}s", 31 if failed else 32)) + if failed: + return 1 + if records: + return 0 + return 5 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("targets", nargs="*", help="files, directories, dotted IDs, or path.py::Class::test") + parser.add_argument("-j", "--jobs", type=int, default=os.cpu_count() or 1, help="workers (default: available CPUs)") + parser.add_argument("-k", metavar="TEXT", help="only run test IDs containing TEXT") + parser.add_argument("-s", "--no-capture", action="store_true", help="show test output live") + parser.add_argument("-v", "--verbose", action="store_true", help="show every test") + parser.add_argument("--durations", type=int, default=10, metavar="N", help="show N slowest tests; 0 shows all") + parser.add_argument("-W", "--warnings", choices=("error", "default", "always", "ignore"), default="error") + args = parser.parse_args() + + capture_output = not args.no_capture + os.chdir(ROOT) + warnings.simplefilter(args.warnings) + started = time.monotonic() + tests, errors = collect(args.targets, args.k) + batches = make_batches(tests, args.jobs) + workers = min(args.jobs, len(batches)) + summary = f"collected {len(tests)} test{'s' if len(tests) != 1 else ''} in {time.monotonic() - started:.2f}s " + summary += f"• {workers} worker{'s' if workers != 1 else ''}" + print(summary) + records = [] + column = 0 + try: + if workers < 2: + streams = (run_batch(batch, capture_output) for batch in batches) + else: + streams = run_parallel(batches, workers, args.warnings, capture_output) + for batch in streams: + records.extend(batch) + for item in batch: + mark, code = STATUS_MARKS[item["status"]] + if args.verbose: + print(f"{paint(mark, code)} {item['id']} {item['time']:.2f}s") + else: + print(paint(mark, code), end="", flush=True) + column += 1 + if column == 80: + print() + column = 0 + except KeyboardInterrupt: + print(paint("\ninterrupted", 31)) + return 2 + if column: + print() + return report(records, errors, args.durations, time.monotonic() - started) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index d9adb09f0b..b12a3283a1 100644 --- a/uv.lock +++ b/uv.lock @@ -3,179 +3,92 @@ revision = 3 requires-python = ">=3.12.3, <3.13" [manifest] -overrides = [ - { name = "av" }, - { name = "opendbc", editable = "opendbc_repo" }, -] +overrides = [{ name = "opendbc", editable = "opendbc_repo" }] [[package]] -name = "aiohappyeyeballs" -version = "2.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.14.1" +name = "anyio" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "typing-extensions" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, - { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, - { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, - { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, - { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, - { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, -] - -[[package]] -name = "aioice" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dnspython" }, - { name = "ifaddr" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/67/04/df7286233f468e19e9bedff023b6b246182f0b2ccb04ceeb69b2994021c6/aioice-0.10.2.tar.gz", hash = "sha256:bf236c6829ee33c8e540535d31cd5a066b531cb56de2be94c46be76d68b1a806", size = 44307, upload-time = "2025-11-28T15:56:48.836Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/e3/0d23b1f930c17d371ce1ec36ee529f22fd19ebc2a07fe3418e3d1d884ce2/aioice-0.10.2-py3-none-any.whl", hash = "sha256:14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf", size = 24875, upload-time = "2025-11-28T15:56:47.847Z" }, -] - -[[package]] -name = "aiortc" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aioice" }, - { name = "av" }, - { name = "cryptography" }, - { name = "google-crc32c" }, - { name = "pyee" }, - { name = "pylibsrtp" }, - { name = "pyopenssl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/51/9c/4e027bfe0195de0442da301e2389329496745d40ae44d2d7c4571c4290ce/aiortc-1.14.0.tar.gz", hash = "sha256:adc8a67ace10a085721e588e06a00358ed8eaf5f6b62f0a95358ff45628dd762", size = 1180864, upload-time = "2025-10-13T21:40:37.905Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/ab/31646a49209568cde3b97eeade0d28bb78b400e6645c56422c101df68932/aiortc-1.14.0-py3-none-any.whl", hash = "sha256:4b244d7e482f4e1f67e685b3468269628eca1ec91fa5b329ab517738cfca086e", size = 93183, upload-time = "2025-10-13T21:40:36.59Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, + { name = "idna" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, -] - -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - -[[package]] -name = "av" -version = "16.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/cd/3a83ffbc3cc25b39721d174487fb0d51a76582f4a1703f98e46170ce83d4/av-16.1.0.tar.gz", hash = "sha256:a094b4fd87a3721dacf02794d3d2c82b8d712c85b9534437e82a8a978c175ffd", size = 4285203, upload-time = "2026-01-11T07:31:33.772Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/84/2535f55edcd426cebec02eb37b811b1b0c163f26b8d3f53b059e2ec32665/av-16.1.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:640f57b93f927fba8689f6966c956737ee95388a91bd0b8c8b5e0481f73513d6", size = 26945785, upload-time = "2026-01-09T20:18:34.486Z" }, - { url = "https://files.pythonhosted.org/packages/b6/17/ffb940c9e490bf42e86db4db1ff426ee1559cd355a69609ec1efe4d3a9eb/av-16.1.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ae3fb658eec00852ebd7412fdc141f17f3ddce8afee2d2e1cf366263ad2a3b35", size = 21481147, upload-time = "2026-01-09T20:18:36.716Z" }, - { url = "https://files.pythonhosted.org/packages/15/c1/e0d58003d2d83c3921887d5c8c9b8f5f7de9b58dc2194356a2656a45cfdc/av-16.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:27ee558d9c02a142eebcbe55578a6d817fedfde42ff5676275504e16d07a7f86", size = 39517197, upload-time = "2026-01-11T09:57:31.937Z" }, - { url = "https://files.pythonhosted.org/packages/32/77/787797b43475d1b90626af76f80bfb0c12cfec5e11eafcfc4151b8c80218/av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2", size = 41174337, upload-time = "2026-01-11T09:57:35.792Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/d90df7f1e3b97fc5554cf45076df5045f1e0a6adf13899e10121229b826c/av-16.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8cf065f9d438e1921dc31fc7aa045790b58aee71736897866420d80b5450f62a", size = 40817720, upload-time = "2026-01-11T09:57:39.039Z" }, - { url = "https://files.pythonhosted.org/packages/80/6f/13c3a35f9dbcebafd03fe0c4cbd075d71ac8968ec849a3cfce406c35a9d2/av-16.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a345877a9d3cc0f08e2bc4ec163ee83176864b92587afb9d08dff50f37a9a829", size = 42267396, upload-time = "2026-01-11T09:57:42.115Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b9/275df9607f7fb44317ccb1d4be74827185c0d410f52b6e2cd770fe209118/av-16.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:f49243b1d27c91cd8c66fdba90a674e344eb8eb917264f36117bf2b6879118fd", size = 31752045, upload-time = "2026-01-11T09:57:45.106Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] name = "certifi" -version = "2026.6.17" +version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.7" +version = "3.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, ] [[package]] @@ -192,11 +105,11 @@ wheels = [ [[package]] name = "codespell" -version = "2.4.2" +version = "2.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/19/45e941380f69c042b43423513d201e6592346f992394347f5e7174c31407/codespell-2.4.3.tar.gz", hash = "sha256:cbe085e331227b37bb86ef8bddd08dc768c704ee9a07ca869852c093fa2793e2", size = 352773, upload-time = "2026-07-15T11:51:54.159Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" }, + { url = "https://files.pythonhosted.org/packages/8b/bf/bdb951d34eb169140b546f44be9ec4525d1acefb9eb5572071f5492b19fc/codespell-2.4.3-py3-none-any.whl", hash = "sha256:af2505b335e8573dbd2d384d1c4ef498f4006f4ba2d6fceca01e55b91f52628a", size = 340736, upload-time = "2026-07-15T11:51:52.925Z" }, ] [[package]] @@ -210,164 +123,146 @@ wheels = [ [[package]] name = "comma-deps-acados" -version = "0.2.2.post95" +version = "0.2.2.post98" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/58/83/e76d3f89de07a672c5fc452d81b6f00f972721342eb75de2171d2c3a8b19/comma_deps_acados-0.2.2.post95-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd7c583ac2a33b414540c0601173e3a9c28c5d8f825cb24736c64e3c07271f56", size = 10631724, upload-time = "2026-06-24T23:58:31.726Z" }, - { url = "https://files.pythonhosted.org/packages/64/96/4b8e50a153dcb5f34628f854dc58774588b8dbfb26b6bedd0b99acb4aa71/comma_deps_acados-0.2.2.post95-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1f47c3a665193937c993d3ee15989be5ba75eb427c9733fe1b9e800bf33c56aa", size = 11663713, upload-time = "2026-06-24T23:58:33.725Z" }, - { url = "https://files.pythonhosted.org/packages/1b/2f/bf57b9656e86950ba19c7a88992fccc0abb89878f9c30fb1b2252d57f0a7/comma_deps_acados-0.2.2.post95-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:00b49d5b691fa97d07fa5e5b842e0d2e1a6faa6856d2a081eefcee649f93329b", size = 13167209, upload-time = "2026-06-24T23:58:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/3d/13/1190aed06e91a9f9024b16fb44a4184842e56ac39dbaec8e6aea83cb1d7e/comma_deps_acados-0.2.2.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64c002e0d6170c7bdec300159bbbe07cf3e05fa54ae5890fe736190fc3543fe7", size = 10635996, upload-time = "2026-07-23T17:01:04.136Z" }, + { url = "https://files.pythonhosted.org/packages/d6/24/a16888f692e2a2759b656847b7e6ca2dbbca666e64e1372e3e158d25e29f/comma_deps_acados-0.2.2.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:356ce6020430eb5b53d637bf870c27b10ad8599c9ade0abd2c520c3e00143410", size = 11657744, upload-time = "2026-07-23T17:01:08.618Z" }, + { url = "https://files.pythonhosted.org/packages/73/9d/24377b731093e015a44fff043dd7ea5b77b0de62acf48b5a0e7d5a662a15/comma_deps_acados-0.2.2.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:e55ac429d848415930a0b82ab100a310e1848b48cdbaa8dda574b561b43c50d0", size = 13124767, upload-time = "2026-07-23T17:01:13.091Z" }, ] [[package]] name = "comma-deps-bootstrap-icons" -version = "1.10.5.0.post95" +version = "1.10.5.0.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/0b/bb713dd4bed94b0b2b21657b7337e264953bd785a2b2f5c1b0706cdcde29/comma_deps_bootstrap_icons-1.10.5.0.post95-py3-none-any.whl", hash = "sha256:d59fc8d3e642e00f83d7a4854164f1dee21c3d17c726d5537b1b72b149f5788b", size = 386001, upload-time = "2026-06-24T23:58:37.84Z" }, -] - -[[package]] -name = "comma-deps-bzip2" -version = "1.0.8.post95" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/ac/539d76e9bcebbc53d62274a19c47522b118dce3261fc6baea045f93c98b6/comma_deps_bzip2-1.0.8.post95-py3-none-macosx_11_0_arm64.whl", hash = "sha256:be466743b7fc56fa18c2c389a46bdeb0fc885cf3cf39b779d748ce540609ebd5", size = 42832, upload-time = "2026-06-24T23:58:39.592Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fe/5206bd6d716022cea259c4878c6a1927ab3555f4caa48a74efaa83d8f418/comma_deps_bzip2-1.0.8.post95-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7c015d720cc5462f87062b4482c54684de6a237b4edb60612c2f58721413682f", size = 38629, upload-time = "2026-06-24T23:58:40.496Z" }, - { url = "https://files.pythonhosted.org/packages/39/0e/78218f9a645ad9d27000153795d6819b3b52ca62d882aa46a413e40887be/comma_deps_bzip2-1.0.8.post95-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3188a197aaf3efbfac975193887c59600b91a3b4eb7f62cc975377133cd48834", size = 36271, upload-time = "2026-06-24T23:58:41.2Z" }, + { url = "https://files.pythonhosted.org/packages/aa/69/da1a72b8b7783b0caf9a54b27c7124bad11768b8bce2c656ef3b700ab831/comma_deps_bootstrap_icons-1.10.5.0.post98-py3-none-any.whl", hash = "sha256:cabaeecea398eb867b96a6c653c6078691a437c0eff2364530194a218d94cb99", size = 385998, upload-time = "2026-07-23T17:01:17.476Z" }, ] [[package]] name = "comma-deps-capnproto" -version = "1.0.1.post93" +version = "1.0.1.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/a6/97c22a112e23f530db28f8ecf7b191ec16685d282fdbe892dff7437bdf6d/comma_deps_capnproto-1.0.1.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6717e8e34ef12116502f244b23f092984c1d15afa4e40b956450b74a0d4d2520", size = 2407330, upload-time = "2026-07-08T19:30:41.395Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f2/79a15ff5f2c97a741923a97357f6a4235e4ced928ba1d4079d01c66df4b0/comma_deps_capnproto-1.0.1.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a70349446e2ec17b281ebaaae02ca4c35495f4210043c716c55139a05a826090", size = 2506341, upload-time = "2026-07-08T19:30:45.607Z" }, - { url = "https://files.pythonhosted.org/packages/80/a9/f61fe62045c4ea867f51f2d74b4edfabd6c272c62a3281a9f1f33825a725/comma_deps_capnproto-1.0.1.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f98cdba8f8c7f08a7a0c0a4b7cc0bfcf515e29ad8f1b5cb8eda4e253bef5e6e3", size = 2590769, upload-time = "2026-07-08T19:30:49.728Z" }, -] - -[[package]] -name = "comma-deps-catch2" -version = "2.13.10.post93" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ee/b4ef7758d04a024775d49558ca930fec19aa37cd691ad9182b7141f4d4d7/comma_deps_catch2-2.13.10.post93-py3-none-any.whl", hash = "sha256:8f23293251b5db48c08885d8816ca252b3fb11b0c1c26775bae36e8b217e865e", size = 137085, upload-time = "2026-07-08T19:30:53.44Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/d3e6346a31491be1d378e4585f37a7979eb772018616abfa74fb27750f1e/comma_deps_capnproto-1.0.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9f4d08682df92411b360bec855cb6475990313cd0ecd8ed5c6ee02befb9db913", size = 2407343, upload-time = "2026-07-23T17:01:21.247Z" }, + { url = "https://files.pythonhosted.org/packages/b0/8b/6f2a29d50ed4c8741dbf0a34ab109899268d09753518cd693e881bbf1a9d/comma_deps_capnproto-1.0.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6cdf838a8d415ac71e1f52306624ab3ab6f27777f6ce89c0059c4129ea7b7f62", size = 2506355, upload-time = "2026-07-23T17:01:25.254Z" }, + { url = "https://files.pythonhosted.org/packages/08/24/e91f2203d62e4db9de7dae06dd0cdefb1e000d8b3ba0bde48367be7e5b63/comma_deps_capnproto-1.0.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d95993c9aff0c89e39ca965e995021dd3dccdfc3d5d85152916cf4bf651b7ec", size = 2590764, upload-time = "2026-07-23T17:01:29.062Z" }, ] [[package]] name = "comma-deps-eigen" -version = "3.4.0.post93" +version = "3.4.0.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/3c/b262d6103a3b7a5cb0296b821eb4f1385b349e527bba75d7efe057cea802/comma_deps_eigen-3.4.0.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f90433cea3f59f1b9ed58bd92d0f1b55721eff8b473d579ed40fa2fcdc2c1787", size = 2275895, upload-time = "2026-07-08T19:31:08.983Z" }, - { url = "https://files.pythonhosted.org/packages/00/a1/b9bf332a096267ff26ebd546b104d9a92b905857c59bae899794897c790b/comma_deps_eigen-3.4.0.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3169d82b825ca8accdae5f3e1a9cddd9f28f8bd5f66e4353fedae68b23f13cdb", size = 2275897, upload-time = "2026-07-08T19:31:12.66Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c6/1132467acd1257ea159cf4608c6f00ef0b3ca353fd86aaced0d76dcce62c/comma_deps_eigen-3.4.0.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:d0b042c817070a0124b8c0e78b87ff5e8b44db21a1abea740afe7115f5bd8447", size = 2275900, upload-time = "2026-07-08T19:31:16.352Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2f/89011c71976da6e1c3d7be315afa3d86ff25deeada1ad2319ac6be0e18ea/comma_deps_eigen-3.4.0.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd182634cf4fa537e7815238d3135e6e89be5826421a495be92258c6c388b527", size = 2275893, upload-time = "2026-07-23T17:01:44.622Z" }, + { url = "https://files.pythonhosted.org/packages/2c/61/fcd4ad536c51437ee73ac255f3b8a23fb5f21bb1f96e834d8036c3bbcf08/comma_deps_eigen-3.4.0.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:250c15f6217c37736a2f54298d01f6e32f6e977faa87cc358de67ea3d121725e", size = 2275896, upload-time = "2026-07-23T17:01:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a2/2b7633fe5a5a2914900933393c315e9bd86e8fb7bbbe328d3a220eaf2027/comma_deps_eigen-3.4.0.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:dee9eb6c6c7e58201d7a36611857b7b3f3ba70f888ee07493fef2ea41d0d2cae", size = 2275898, upload-time = "2026-07-23T17:01:52.179Z" }, ] [[package]] name = "comma-deps-ffmpeg" -version = "7.1.0.post94" +version = "7.1.0.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/5c/47e10049fd96b581e8ddc9fba31c3397562732fcc99db6c062690e862300/comma_deps_ffmpeg-7.1.0.post94-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e87b4dd0fb0fc25d6992167ee266b5c9e92305aefa3d343ddd22a17219363b8", size = 7329953, upload-time = "2026-07-09T03:07:11.516Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d5/c4edfbbe488983ff45fb6cacab78342fc56cc0f28010a946e8697613ffe2/comma_deps_ffmpeg-7.1.0.post94-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7ea34368c5b564cecab0fd77d6423bc15d8f100051648f64ad79a3499f43dafb", size = 4441314, upload-time = "2026-07-09T03:07:15.679Z" }, - { url = "https://files.pythonhosted.org/packages/80/8f/76a876a26572c52d52dbcf8ed39227f5023d343201a32af4b992c4b9f13e/comma_deps_ffmpeg-7.1.0.post94-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:5b2d6cfbba59ecd673dd0c8bd8eb5a5b3472bdfa025e1063c011e6234877fe84", size = 4685134, upload-time = "2026-07-09T03:07:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/20/59/4899ac0fa54905f43e237fff122008d6c591918b41e36880eeb18cd6279c/comma_deps_ffmpeg-7.1.0.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7816c5adc9c6a7462209ccf1d023c42d7e38a4eee47aa2f43d45dfc8320063f8", size = 7326312, upload-time = "2026-07-23T17:01:55.975Z" }, + { url = "https://files.pythonhosted.org/packages/29/cb/6e047c19c39977c5ae322ad698b91d8d9fce43314cb86563de91bb161982/comma_deps_ffmpeg-7.1.0.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:45ad401b4058e3f7efb8d6841e1f187e8c265e942e56e7fb01db1ba9096e6b78", size = 4437675, upload-time = "2026-07-23T17:01:59.971Z" }, + { url = "https://files.pythonhosted.org/packages/76/3d/cda4b19fa5a7b26921a518143c94fd3632030a34b157cab6d6f10f2c86bc/comma_deps_ffmpeg-7.1.0.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9e7034739d45a45254c4200a555d343b22ce117429bebc2de2c1b05f050bfc8c", size = 4681499, upload-time = "2026-07-23T17:02:03.963Z" }, ] [[package]] name = "comma-deps-gcc-arm-none-eabi" -version = "13.2.1.post93" +version = "13.2.1.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/9f/9a6c7f04faf18fdc4ab3e5b55ecfdbcb3998333a450fd5a1c986af571d15/comma_deps_gcc_arm_none_eabi-13.2.1.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:00de06a94ecf4379c79b150d36b3116d0f37bc341bbf1ef2ad850c8a5985eeb1", size = 15238809, upload-time = "2026-07-08T19:31:33.509Z" }, - { url = "https://files.pythonhosted.org/packages/83/94/1cdb11c170b96f2677dfd1be7cfb9a2994d3518555c154f2a131929057da/comma_deps_gcc_arm_none_eabi-13.2.1.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:de1cf3ff89ea84fac5df62c65d843af877eaa2634439d793f527f0cfeea66e59", size = 17367238, upload-time = "2026-07-08T19:31:38.137Z" }, - { url = "https://files.pythonhosted.org/packages/23/3c/4b2f60274f080b9583da2fdaad54f692f4f8719bd360558d4460d79f8fb4/comma_deps_gcc_arm_none_eabi-13.2.1.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3cf7f022ffa1b3d3e20eb32610c7e59f84932e516d9ed11236df787546d82d67", size = 16941134, upload-time = "2026-07-08T19:31:43.102Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e5/a4cd9faa80bf419c6a7052c99dfe565c283a5c966e90ce35b1b4040b24b8/comma_deps_gcc_arm_none_eabi-13.2.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d0e6991b845636ab19e46199bc5cb9dd056611bc6b93c6bca4f2bb5002783533", size = 15238810, upload-time = "2026-07-23T17:02:08.588Z" }, + { url = "https://files.pythonhosted.org/packages/5b/81/690ce48945aecf58e475cb728a8d2f6c034493afd87b5b0381a85dd324b4/comma_deps_gcc_arm_none_eabi-13.2.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:41fef00d033e0f6c12e1748829d95e53942826e0085f57d918351b2de69530d7", size = 17367240, upload-time = "2026-07-23T17:02:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/41/a9/6af914145bd5c9ce3468a95500558bdc0a438f69700daedd37535945294e/comma_deps_gcc_arm_none_eabi-13.2.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:ed3630aac06b3a1db78ba5a29a33b900a51dd1c0b37f86cbfe3c1591993178f8", size = 16941137, upload-time = "2026-07-23T17:02:18.976Z" }, ] [[package]] name = "comma-deps-git-lfs" -version = "3.6.1.post93" +version = "3.6.1.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/62/e06ea6fb98b8cee2686fb409fb28426d902f3318b077cbd78d9bbec0354b/comma_deps_git_lfs-3.6.1.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89cbffd3d6800fa8e366301b98c09c4ccbc973fb59721aa2546b36158b9b64c0", size = 4685107, upload-time = "2026-07-08T19:31:47.273Z" }, - { url = "https://files.pythonhosted.org/packages/ce/cf/f08dc359e8bc7e1b21b7f4a93167a14ac61dc78f1bfb27a0e7554c78cb09/comma_deps_git_lfs-3.6.1.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:39844960733a3885d99bbfdf4f7b414e7d0bf4b4d56f9d77a88ea10d72f549c5", size = 4485276, upload-time = "2026-07-08T19:31:51.253Z" }, - { url = "https://files.pythonhosted.org/packages/2f/af/3dfae2a56320165b45ceab0d7a2c6dea69cc5766928f408d17997ff2a525/comma_deps_git_lfs-3.6.1.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:e445cbadec41856997de775d5d415d1e707db0ba563c705df1b55ce198179bbe", size = 4889583, upload-time = "2026-07-08T19:31:55.153Z" }, + { url = "https://files.pythonhosted.org/packages/79/27/ecfda511eb334822d9bc464ec2d9b74d3c553784811a885baba34a27eaf6/comma_deps_git_lfs-3.6.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:259b1f4859bb3ab20fdcc012be0a9868a3649e1087d5cec07eaade7adea44c78", size = 4685104, upload-time = "2026-07-23T17:02:23.67Z" }, + { url = "https://files.pythonhosted.org/packages/00/a5/9631b4a676279b353f82d4e2da62eb567c70fff628133a62d7f70fbf5924/comma_deps_git_lfs-3.6.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:60d39254138b2c7c3f15cc512c14504c11881e79885492321e1ffc3ecb840f93", size = 4485276, upload-time = "2026-07-23T17:02:27.751Z" }, + { url = "https://files.pythonhosted.org/packages/09/5a/7ef6bc209d8ec15c40b1f988345e2e59c535a215284366916422ba0d0c30/comma_deps_git_lfs-3.6.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9586057ca9c6e77e9068128f3e96f0ca03db29a757414b1b251bab4ca31ee6b8", size = 4889582, upload-time = "2026-07-23T17:02:31.454Z" }, ] [[package]] name = "comma-deps-imgui" -version = "1.92.7.post93" +version = "1.92.7.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/e8/a5f99714d4b2ef85744bd6af9ee46b016a70cea8383c9cca3097847c6940/comma_deps_imgui-1.92.7.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ae7cbb655cd61157b785e7f388cc1bfe17bf6a2bf8c40ff9484702bd9be71074", size = 1688022, upload-time = "2026-07-08T19:31:58.849Z" }, - { url = "https://files.pythonhosted.org/packages/86/f1/0ebcbb7d55ef1ad0f516831c52f37cafdf0ed94b73e0396c531ecd89dbc1/comma_deps_imgui-1.92.7.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:efc689e8279900c10e3922e9bf188fe34e16ab28d8660878e82b7192d7d86c68", size = 2522798, upload-time = "2026-07-08T19:32:02.435Z" }, - { url = "https://files.pythonhosted.org/packages/c3/94/6f0bd31d599f5748736b57e0b71a42d15ec0782906155f268bc7056fd0ac/comma_deps_imgui-1.92.7.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:b08a763c00a1f037dc5e2e2e0322c46960fe8a2cc972ce17781d547ccce3eaa6", size = 2655457, upload-time = "2026-07-08T19:32:06.273Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/b9f4b68973bfd529314c28fcd87cb2f52b5dc7d9fdeb3be2d3d15b7cea25/comma_deps_imgui-1.92.7.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6fddf76138b1e54fe33e9f5ea3cbcb650f92fef8fbcf7e5080800ea851d68b98", size = 1688011, upload-time = "2026-07-23T17:02:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/7f/46/92030abf6e42e9813f144d10bcf541b39a246c5ca2d63d049478deac650b/comma_deps_imgui-1.92.7.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9f7eed0f759e59afcf289967edb3d94c48a38fe97527f2909dbbc70a850dc2cc", size = 2522785, upload-time = "2026-07-23T17:02:39.092Z" }, + { url = "https://files.pythonhosted.org/packages/7d/57/d41e76559a553565413976695fb63a768d3446d12eccc9e736a12b53e662/comma_deps_imgui-1.92.7.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:07eeb105cce73ec3b27789501c78dcd059d75e736a99f1953358ef5097e7036f", size = 2655476, upload-time = "2026-07-23T17:02:42.925Z" }, ] [[package]] name = "comma-deps-json11" -version = "20170411.0.post93" +version = "20170411.0.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/3e/f2bb5d0e0d9e63535007c64859b2ab5e17164ca1f4cfa279fd228dbcef80/comma_deps_json11-20170411.0.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0088d66fd76ea27712d09f2ffa783596f7d70b5b152a039c36d65befcebe3863", size = 34033, upload-time = "2026-07-08T19:32:10.175Z" }, - { url = "https://files.pythonhosted.org/packages/b8/6e/9576bb1ba5d8371f32664268401fa61231ca1092aa5da45141c37984d868/comma_deps_json11-20170411.0.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:659c18d4b2634bf7d2388c6e855c6f083f384d2a70052945c1c9ddabbb0dcb21", size = 41846, upload-time = "2026-07-08T19:32:13.457Z" }, - { url = "https://files.pythonhosted.org/packages/cc/34/6d673c311c9a23e65a0db56352878b53b575aca0539e57c71121d2fe544b/comma_deps_json11-20170411.0.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4c24a9c9db71b38192ec9c515f13fd14b4495b8080bb1f687b84b193613d95be", size = 42603, upload-time = "2026-07-08T19:32:16.601Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f4/50411c9134a8347831a72f90318b7b7d91ce566e63575b8a4a821be50ca4/comma_deps_json11-20170411.0.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:20d666897062487e4cd93b8e4eb9c53ecabf706864be8d8cbb60a56f0113c452", size = 34034, upload-time = "2026-07-23T17:02:46.595Z" }, + { url = "https://files.pythonhosted.org/packages/1f/54/0c87fae682ee52e6aec371336ac980921ad34cedabb69e576cc9f83c40a7/comma_deps_json11-20170411.0.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9b4a03909609b832dc99b06503fb8c77419399a99e0db982d4c3f51f1563aa73", size = 41848, upload-time = "2026-07-23T17:02:50.039Z" }, + { url = "https://files.pythonhosted.org/packages/7b/71/dd100992e13f2c7a01f68eebcd1e3cf43f1d169e4b80b0577f330e5f5c12/comma_deps_json11-20170411.0.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:1da9030908f3a6631a0a254f493c382ba061c2e8ddb280a30d64430af29f4638", size = 42602, upload-time = "2026-07-23T17:02:53.233Z" }, ] [[package]] name = "comma-deps-libusb" -version = "1.0.29.post93" +version = "1.0.29.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/a3/4fb912d8af7af134a0a54eeb893beecc9e1cb3ed8371e055319faa74a580/comma_deps_libusb-1.0.29.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1dba5fe7832877994ea9ff3daf056851f1b5c9a47552525af08b35074c5ff65e", size = 102339, upload-time = "2026-07-08T19:32:19.785Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ad/6d36327352fa1030997409ee650de3f307cc26e3fec4f2a8ea6254430e6a/comma_deps_libusb-1.0.29.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c2a29c888668cb4c0ab549ac71206d6b53da8d2db5e581a55a113574aaabe950", size = 94439, upload-time = "2026-07-08T19:32:23.104Z" }, - { url = "https://files.pythonhosted.org/packages/84/47/f4f2da67db202bacc3fa578fdebdb916f45c97bee0c3d2c5fbe845a6f129/comma_deps_libusb-1.0.29.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f136dbbfc461d228378731361cc66a90a483e17d55f8ba43f23eeab6bcf41963", size = 93461, upload-time = "2026-07-08T19:32:26.434Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/f7d342a8f785fc1c0fd5d6883e1a5a7d424a1899b888f78f9091f4b98049/comma_deps_libusb-1.0.29.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a10b82a946c33c23152cee3e330ce76d398ddce1f38fea62e33773bef8f56164", size = 102339, upload-time = "2026-07-23T17:02:56.567Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/1b21692cc03078219a3946aae56086a109168a4b4dcfba3a22ce1cd01064/comma_deps_libusb-1.0.29.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:39567eeef6170ece389526780f90c3b75cbcfdf427f648f6b1539c203f7387a8", size = 94431, upload-time = "2026-07-23T17:03:00.01Z" }, + { url = "https://files.pythonhosted.org/packages/49/d2/d93aac76b94ae87f7a37ce88f2e7e1184e19e67d10c068c1aac209075450/comma_deps_libusb-1.0.29.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:5e5b86be94b4a355c6be933ed21586d01b1d7ba597298dafd33b871a1ae66416", size = 93462, upload-time = "2026-07-23T17:03:03.218Z" }, ] [[package]] name = "comma-deps-ncurses" -version = "6.5.post93" +version = "6.5.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/3d/f85c036037e9d72c8cc444a1613ae8ca6967c0997148fbc8c03b8cdeb21c/comma_deps_ncurses-6.5.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0fbc94bdb42577389ec9e475deb7425f027b4e6a097b7b326ee0ee3ecb4c26ff", size = 264892, upload-time = "2026-07-08T22:08:58.325Z" }, - { url = "https://files.pythonhosted.org/packages/f4/da/13881cefe9ca963665a57f066683fd9995c15349839f0268686741942cd7/comma_deps_ncurses-6.5.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a8786c9e7693fbe235044ed068292ddae1e8c25e9cc3ffba9364c233b6df9f9c", size = 260837, upload-time = "2026-07-08T22:09:02.514Z" }, - { url = "https://files.pythonhosted.org/packages/11/98/6a278e4436ca901c084ae111dc23f064e3eb5f64481dc970111aff8a91fd/comma_deps_ncurses-6.5.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:1de1011e71833d46d545a0d64150316f980385d13731eb138f9fc53642942c55", size = 248341, upload-time = "2026-07-08T22:09:06.591Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d4/03e62b2a0be92ad420653ff1cf4396de9840c4c59cdc6e000ea5614f7744/comma_deps_ncurses-6.5.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:22ade1596deb18538d4bf59708aa2747c28c2e0e7048f13f5bfce3e5588f7417", size = 264921, upload-time = "2026-07-23T17:03:06.576Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/295b428ef473dbe0d7088ff02daf5ba17b924f3b915ac69a8a9c45a6eb2b/comma_deps_ncurses-6.5.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6724d3e8c1f2e59d2475f7588d86494bcc4001e993fd7dff4c91ecb046edc97f", size = 260844, upload-time = "2026-07-23T17:03:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/cd/db/75afb33eaa86425d9bee68153f6141be2645cf5699b2cab5a7cbf7a36099/comma_deps_ncurses-6.5.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:d85e70e98f4b0a969d63a4a61a1a5b85db3c648ea58422401b55f386813f7d12", size = 248352, upload-time = "2026-07-23T17:03:13.786Z" }, ] [[package]] name = "comma-deps-raylib" -version = "6.0.0.1.post93" +version = "6.0.0.1.post98" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/5a/1abdc781fc242e0db517e22c53fa676fc81b76344c5d6c86677bdd5d6a9a/comma_deps_raylib-6.0.0.1.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ef4dbc7036e4449f9ee029cec077c23f64975b55b60cce0be818b70640c1732a", size = 2004199, upload-time = "2026-07-08T22:09:10.731Z" }, - { url = "https://files.pythonhosted.org/packages/79/90/919fd98aa72c8c1d31e657abc0bc28b3933d7408f4dc8ccbb7272ef638a9/comma_deps_raylib-6.0.0.1.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a64f2a77ad55b05350ed4301f3a1a814176810d1d408c77bdbaa1bd5a3ab5135", size = 7203010, upload-time = "2026-07-08T22:09:15.323Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b6/419b387b132efe43ace20dced9852448f36195d71d1305fccfd5e0617083/comma_deps_raylib-6.0.0.1.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:11bf076441b636434bc76a00b2e349c22a825e0b83416a4fb3dd4b8217638f8a", size = 5055450, upload-time = "2026-07-08T22:09:19.844Z" }, + { 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" }, ] [[package]] name = "comma-deps-zeromq" -version = "4.3.5.post93" +version = "4.3.5.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/6f/d50752a3a6f6ecd4b4289bf76090fd35c11ac8d4b9353534e91e9a640512/comma_deps_zeromq-4.3.5.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b5951b302ec3db7ad3638161af00dff6ad1d8e0272aa242a0b57d692a093bacc", size = 815166, upload-time = "2026-07-08T22:09:24.48Z" }, - { url = "https://files.pythonhosted.org/packages/57/24/0fa08db746438533a67e44886bffb1cd7e2a587c68d57fba745e84036965/comma_deps_zeromq-4.3.5.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a7d4813155ea13548158204a60e6a37e2dedfcf7ae34cbb78491474ac784ac24", size = 833412, upload-time = "2026-07-08T22:09:28.872Z" }, - { url = "https://files.pythonhosted.org/packages/c5/83/9083466d8269bf273a7ff4ff154fbcd300a8d9c79ca7754fb98215caf602/comma_deps_zeromq-4.3.5.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:378f121eea1084c7cd45ee73c3dbc437e3d07547e8333d230174711e963f958d", size = 798729, upload-time = "2026-07-08T22:09:32.859Z" }, + { url = "https://files.pythonhosted.org/packages/12/b7/b0070e091dae4be2cecccfb2921167b568d0e7bb9ea600b5814603e0590f/comma_deps_zeromq-4.3.5.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0ecde97133d657024bf99ac7302130905a131dabedbc8425b6666b2058ce6acb", size = 815150, upload-time = "2026-07-23T17:03:29.517Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9a/d6a381b079516eca1b8a86aa3e972e550a48ff2f069ccc722b118ab53d60/comma_deps_zeromq-4.3.5.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:110a628ea440ea75707ad29fd90341d300414b0092137b1d43e8f19d100cf2fa", size = 833389, upload-time = "2026-07-23T17:03:33.25Z" }, + { url = "https://files.pythonhosted.org/packages/af/d7/504649efc8dbe8ce4c0cbec085178d1c3298f6950bd4bbca1683a90c49ed/comma_deps_zeromq-4.3.5.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cd16a515f00f5fb679c2883970c2e7f446ad84e65d7fcf045d325952f9cc3607", size = 798894, upload-time = "2026-07-23T17:03:36.788Z" }, ] [[package]] name = "comma-deps-zstd" -version = "1.5.6.post93" +version = "1.5.6.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/32/e62e39d77d356675b07fa0c03286d3f5620bb39fe8619285bfaf3c085f6b/comma_deps_zstd-1.5.6.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9c40c10fa541c5ed975a06327f4e5cc5fe380d49c50fd2899f367c52e0d535a1", size = 1065092, upload-time = "2026-07-08T22:09:37.023Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f9/80e530ffed74b767591e50367dd51413c99e8aa10a975745086f2f10eac8/comma_deps_zstd-1.5.6.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3a1da859191a9b4c778498afdaa80a7000e5967d4851d827e422c467aa159f9a", size = 1006181, upload-time = "2026-07-08T22:09:41.332Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b1/5b544af0a50235b1efe9a9de469180ded30814feb8e6a2cd89d4064f995e/comma_deps_zstd-1.5.6.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:fe85e5fd24ac81964cac30ad04327ae157b5976a69725604ec960e773f06a9a8", size = 1030331, upload-time = "2026-07-08T22:09:45.535Z" }, + { url = "https://files.pythonhosted.org/packages/ef/66/fd1098b4514e759d85e19d604d446ecec1f67e2f452df9e280d01a2449f7/comma_deps_zstd-1.5.6.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2b6acdd50e71ec67a1426423cda5116a7cb43900dd2e4fca2cbcbb7d588be171", size = 1065140, upload-time = "2026-07-23T17:03:40.465Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/1d61aae97577bbf13c9c02e7e69d4c9391947d8d0d09ca4ad78f2e3d0faa/comma_deps_zstd-1.5.6.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:04825faf902754a0945ebac374d01d676b7e5f98a68e460452319de509b369f3", size = 1006145, upload-time = "2026-07-23T17:03:44.144Z" }, + { url = "https://files.pythonhosted.org/packages/a9/22/94d164407b579090eb3aceeeb63fbd8c540f6972a11e5b72fe3ca3139333/comma_deps_zstd-1.5.6.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:316200a52c9ac1aeb6b22030480ffebaa1d91756c18a3f3cf216368a5fb35bfd", size = 1030359, upload-time = "2026-07-23T17:03:47.999Z" }, ] [[package]] @@ -394,63 +289,63 @@ wheels = [ [[package]] name = "coverage" -version = "7.15.0" +version = "7.15.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, - { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, - { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, - { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, - { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, - { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, - { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, - { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, - { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, - { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, - { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, - { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, - { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, - { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, - { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, - { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, ] [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, ] [[package]] @@ -464,51 +359,42 @@ wheels = [ [[package]] name = "cython" -version = "3.2.8" +version = "3.2.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/6b/80101e02ebacaf9232ecf32bf6a788d36b27d820ee02434746252569ef98/cython-3.2.8.tar.gz", hash = "sha256:f4f23a56b25221a06f91817fe8f3114ab8b48a4fac73187dbb64bc2c4a87961f", size = 3290300, upload-time = "2026-06-30T07:41:57.874Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/de/db48b8870e766cfea809986cc50c1e986c663a9ab7bafd0ac1a2512c4a26/cython-3.2.9.tar.gz", hash = "sha256:d249c9022ab13286b17bd66f30609e800c5f95efeecb06168990c7a66cecde6c", size = 3293493, upload-time = "2026-07-24T06:21:21.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/c4/47e0bcfc15b36b1c5cbde5235c60bf88df552ab216ddb836d7f816386ae6/cython-3.2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f2547a31fbd3b1610a8859a16edee2a141f7781691cb98a2c6fd54870c5f7541", size = 2995546, upload-time = "2026-06-30T07:42:23.618Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f4/bc5830abeb57a7c7498cd9a0f2df953fd9fc7f33e3f5352c9824802b83bb/cython-3.2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab1fe11ebd61e497a848622cdd157a4324ac06e7935b1219715408844e15dd13", size = 3179546, upload-time = "2026-06-30T07:42:25.566Z" }, - { url = "https://files.pythonhosted.org/packages/89/38/a70e879ea52debac11d2810e066a5a2cb16e71229edae303f024506bc142/cython-3.2.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3bce1f079734753649f8a3d3a95832297207feb120d0ef4fd5db4c813cc6c04", size = 3351714, upload-time = "2026-06-30T07:42:27.513Z" }, - { url = "https://files.pythonhosted.org/packages/45/f1/f071c5e7050a7924ffad9822558c74d489afe4764f7486cb68555a509219/cython-3.2.8-cp312-cp312-win_amd64.whl", hash = "sha256:8297efe129e6421c34ddbeb09ed5627ef6c0fc4868bf7f9bdf6c147f595ccaed", size = 2774196, upload-time = "2026-06-30T07:42:29.47Z" }, - { url = "https://files.pythonhosted.org/packages/92/a2/0f2eaa5076bcaef52567471a54ce02ffd70007bf8688cd054f7aab9bc3b8/cython-3.2.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:127bc4039be48c6eebe7f1d68c33d23eb3a0c5ae95e1d730bdd048837751438b", size = 2892527, upload-time = "2026-06-30T07:42:55.45Z" }, - { url = "https://files.pythonhosted.org/packages/a4/08/b5488aef44662e48ac09b42d4cb398207f591c770797036fb1d6fbeb7a52/cython-3.2.8-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e480ae9f195cd29e5ce334e3d434c83dbac0783c0cc88f2407e31ba997724192", size = 3220335, upload-time = "2026-06-30T07:42:57.403Z" }, - { url = "https://files.pythonhosted.org/packages/7c/96/d04a3621045e9fe9c7c5e406a688ee3d6e04a65f545ea7c622ead4b4afd8/cython-3.2.8-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3142407b9d63f233c766e17000e2aac782411bf0409b9adc97cb7c320aecd199", size = 2876481, upload-time = "2026-06-30T07:42:59.406Z" }, - { url = "https://files.pythonhosted.org/packages/71/9a/daa259b638c5eabb8a8c36f203b85b01b5362101ff8fc4ec6ad592d34bb3/cython-3.2.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41c118fd91d320cd72af26e29232ff3f1a0a170c47d477c9a176d766067a4718", size = 2999974, upload-time = "2026-06-30T07:43:01.29Z" }, - { url = "https://files.pythonhosted.org/packages/21/de/71cc5b4be5ee4d34c3302b6e7272189106a4072e9890d284d05239d2b645/cython-3.2.8-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6335c6e8737a39734e20d25f89f425bf3274c104fc7efc05aacc7ed1a4858c9d", size = 2897932, upload-time = "2026-06-30T07:43:03.606Z" }, - { url = "https://files.pythonhosted.org/packages/5d/0c/da68b9d3056e90b2060970b50d575cd7fcd1c778e8f23cc467f346e1d471/cython-3.2.8-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1034facc082cb882e1e5beb61e136ae8e282df2eafa11e9771e9b0a15c860801", size = 3235980, upload-time = "2026-06-30T07:43:05.486Z" }, - { url = "https://files.pythonhosted.org/packages/36/0b/d88bc50e66fd1f1160dd2677d9af18273a2fb2f102c086d21f64a5a9c78b/cython-3.2.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8896ff6b133f346ebcf22aa23706c4031e8d9d5ae184433dfc67dc1053318b69", size = 3118504, upload-time = "2026-06-30T07:43:07.485Z" }, - { url = "https://files.pythonhosted.org/packages/db/de/511c4364808b3b4036d051a0301b0b142a3ddf8a319bfdbbd474fcfdc879/cython-3.2.8-cp39-abi3-win32.whl", hash = "sha256:3fd6464433d925cba66ae31bf5780c8a469a06da1d109180cffb39ee3c88ae20", size = 2435866, upload-time = "2026-06-30T07:43:09.367Z" }, - { url = "https://files.pythonhosted.org/packages/18/4f/911b2b2a0a02be15829ccbf0c906029a318efbf53d9f8e021e438261c206/cython-3.2.8-cp39-abi3-win_arm64.whl", hash = "sha256:4e9447d9b652396a285cdfbd4f9f0721842c63c6df281720e87dcc4b9ea65af5", size = 2457829, upload-time = "2026-06-30T07:43:11.645Z" }, - { url = "https://files.pythonhosted.org/packages/c4/19/31aa63ab719b2e1eea5f200a8a54e2591dc1966a6b75e3de30ef8be9bc2c/cython-3.2.8-py3-none-any.whl", hash = "sha256:f635e113677666de13a2ec2979e9b1d5b90617cdfd1a691d3559be81e2dd6cb9", size = 1258688, upload-time = "2026-06-30T07:41:55.624Z" }, + { url = "https://files.pythonhosted.org/packages/fd/37/c74d842306c8fe381c415b37460d5e3086a820fac72b8ff5cb48513ccfcd/cython-3.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:114b2dee0fa1daa48a59574d848da0ff1b6bdb725a755e9b92fad14962e1ff8d", size = 3009571, upload-time = "2026-07-24T06:21:52.534Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/f7c42b161edd585e3ae556fd62a2c72cd80a6ed527a9907f0c5c6fb060de/cython-3.2.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5cd9c5f138cb052130b40ad3b6976d2180c35348410995812678f4636bd8f94", size = 3183562, upload-time = "2026-07-24T06:21:54.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/1b/c04520ac7f3157aa12a69b632c16261170dac9fab6c48608cc004b8f1b17/cython-3.2.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23e80bc885c599e72072e18d0746df82d394b73100c1e153cda7359e6e59fe09", size = 3354811, upload-time = "2026-07-24T06:21:56.72Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/4ce33235a25b19fcd51dc639f0f403b783a3b7f9b1934eade0d993fbe029/cython-3.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b1fd5a9c03f72a18618668a8e90d569442ed742f910e3ad003dcc9348e9598b", size = 2778077, upload-time = "2026-07-24T06:21:58.7Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/342312c5fe021c8e0c386e1915d138e0902c48ae179b0374ab04773a8831/cython-3.2.9-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:944dc8747f640b3527649c566a5fc75ee0c15e80642ea2fdae4fe6378e1a9d4a", size = 2899729, upload-time = "2026-07-24T06:22:24.877Z" }, + { url = "https://files.pythonhosted.org/packages/f0/62/ea919ee426cb4d435ec8155e1ee6bcbb46b20d8f070527191b59769d4e7f/cython-3.2.9-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4b871ad97dd7fb1cbf56f6238c54423febd310afc1d9d9bc70c69c89b7ce57fc", size = 3226650, upload-time = "2026-07-24T06:22:26.947Z" }, + { url = "https://files.pythonhosted.org/packages/18/02/057b4f63e2ced8c3cf217c4e9fb544bfe48145f493347c7ca3f51607526c/cython-3.2.9-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9b6ebc6c74b4318eaa4e51e520dc8b95ebc7b262953c3ecb24131104681f14e", size = 2881919, upload-time = "2026-07-24T06:22:29.318Z" }, + { url = "https://files.pythonhosted.org/packages/64/e4/e158793ee3de7e4417ba17e7ff1015d6e2cf557cb485ad270b2446c9d1c7/cython-3.2.9-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:92989da161a7d18a7ad4baebc49289b2b77556d5a94916f90140ba26aecf6892", size = 3004702, upload-time = "2026-07-24T06:22:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/d5bbbd743ab4feddb24a7e823b34c3ec4ebab91ff503d16743a4e7ce106b/cython-3.2.9-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e75ec625d8f8781ced690b7a2f5c2d138067711cf24bb8fb68c872c30c2fefe5", size = 2902695, upload-time = "2026-07-24T06:22:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/80850817395985259f135baa510d9186d3a325df81cd1862060bba977029/cython-3.2.9-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:2b1756ddc3bc0cd4341a515fc420c3e25e13c249f5537159b3fb0bff8d19e55c", size = 3241554, upload-time = "2026-07-24T06:22:35.667Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ce/6be776814f6cb81751f3da737ed537385738148e1ea99f89fb4637799198/cython-3.2.9-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7d41baea51ea00f9237f75af498577827493bca5e9b45bbd4e351543727e589a", size = 3124337, upload-time = "2026-07-24T06:22:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/e2/48/27c948cbbfe6050994e67a497ae530955dcda7e79084319321c49969e0fd/cython-3.2.9-cp39-abi3-win32.whl", hash = "sha256:61d4abbf84f77c8d19361d05d9f51d65d8d95e74f736eae55fa1aed8a1430469", size = 2435609, upload-time = "2026-07-24T06:22:40.005Z" }, + { url = "https://files.pythonhosted.org/packages/17/ef/cf0e1bd7542296f1752be63b027f90271448d8c8062eac66d8e44a79b883/cython-3.2.9-cp39-abi3-win_arm64.whl", hash = "sha256:57a6a78d14f7dd7d6062d9bca694e2a8c1c14113b6ceceea076abcd1161fdc5a", size = 2458025, upload-time = "2026-07-24T06:22:41.973Z" }, + { url = "https://files.pythonhosted.org/packages/00/ec/e61deec9bcfbb0e1b36f8b5ba75cb44644419b4bfd0fdd666bffd21d9579/cython-3.2.9-py3-none-any.whl", hash = "sha256:a2b0e87f6b80790c929308ca0831d686f7a180feab684fe8cd4a4380bd96aaca", size = 1259272, upload-time = "2026-07-24T06:21:18.95Z" }, ] [[package]] name = "deepmerge" -version = "2.1.0" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2a/78/6e9e20106224083cfb817d2d3c26e80e72258d617b616721a169b87081e0/deepmerge-2.1.0.tar.gz", hash = "sha256:07ca7a7b8935df596c512fa8161877c0487ac61f691c07766e7d71d2b23bdd2f", size = 21449, upload-time = "2026-06-22T05:46:07.669Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/6c/9f4577a36d5f463a3a3f8322bd65d33e1a1a6b6ba1d692a5ebc3cba19015/deepmerge-3.0.tar.gz", hash = "sha256:14ed69f063de64b7743985c732ccff5d6c34ff4560946e7fbfd99086b853b9ce", size = 22279, upload-time = "2026-08-17T05:50:53.161Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/25/2a75b47cb057b1e164c604fb81ab690a6cdb5e2260ce651194eae90f64a3/deepmerge-2.1.0-py3-none-any.whl", hash = "sha256:8f148339a91d680a75ecb74ade235d9e759a93df373a0b04e9d31c8666cfeb75", size = 14345, upload-time = "2026-06-22T05:46:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d7/7f19bedd30b90b72865aeec3a29127bed6dee6c9ef0324bb5b4d424bb0e3/deepmerge-3.0-py3-none-any.whl", hash = "sha256:c8541c3e186dc88d19a5513ad3a0b2d0b22beaa780969fc0c13b995a64265365", size = 14855, upload-time = "2026-08-17T05:50:52.218Z" }, ] [[package]] -name = "dnspython" -version = "2.8.0" +name = "filelock" +version = "3.32.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, -] - -[[package]] -name = "execnet" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, ] [[package]] @@ -529,72 +415,94 @@ wheels = [ ] [[package]] -name = "frozenlist" -version = "1.8.0" +name = "fsspec" +version = "2026.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, ] [[package]] -name = "google-crc32c" -version = "1.8.0" +name = "h11" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, - { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, - { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, - { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] -name = "hypothesis" -version = "6.47.5" +name = "hf-xet" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "sortedcontainers" }, + { name = "certifi" }, + { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/f2/f77da8271b1abb630cb2090ead2f5aa4acc9639d632e8e68187f52527e4b/hypothesis-6.47.5.tar.gz", hash = "sha256:e0c1e253fc97e7ecdb9e2bbff2cf815d8739e0d1d3d093d67c3af5bb6a7211b0", size = 326641, upload-time = "2022-06-25T20:58:48.926Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/a7/389bbaade2cbbb2534cb2715986041ed01c6d792152c527e71f7f68e93b5/hypothesis-6.47.5-py3-none-any.whl", hash = "sha256:87049b781ee11ec1c7948565b889ab02e428a1e32d427ab4de8fdb3649242d06", size = 387311, upload-time = "2022-06-25T20:58:45.281Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/ae/222a91937ebee7f62c0ca8f5ee0afd97577caf24c0abb927d1f5c7e9f6d2/huggingface_hub-1.28.0.tar.gz", hash = "sha256:46a2e950c09234de54093d587d1675382f0d08dbd600d9fb599b5932f5b2c6cb", size = 959609, upload-time = "2026-08-18T12:27:15.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/0e/eafef18f1a75e125e68395db21131db0cf868a128ecd2fce69b4df6c584b/huggingface_hub-1.28.0-py3-none-any.whl", hash = "sha256:58a8bacb03072edfc38067065e9dc24bbb34805410fcd36a1632de0b329660bb", size = 793202, upload-time = "2026-08-18T12:27:12.719Z" }, ] [[package]] name = "idna" -version = "3.18" +version = "3.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, -] - -[[package]] -name = "ifaddr" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] @@ -606,15 +514,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, ] -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - [[package]] name = "inputs" version = "0.5" @@ -672,6 +571,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, ] +[[package]] +name = "libdatachannel-py" +version = "2026.1.0.dev2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2f/68e8306327ddef4b2133d2efb163cb05b319759ce8bd50b8b32dcd03dd95/libdatachannel_py-2026.1.0.dev2-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6607fa1439e1b5bfceecd387c433470c9d45e439c3c06fa064f5c4669ad7e582", size = 1213155, upload-time = "2026-05-19T03:37:12.796Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e3/10aed36ffaf1744795322aae612db777991575b72a9f04e2c677c2c022bf/libdatachannel_py-2026.1.0.dev2-cp312-cp312-macosx_26_0_arm64.whl", hash = "sha256:a060b1250f57d1fccb36e3a6b36ac8f4fd34926a6b51c564e787e8b7206458aa", size = 1224706, upload-time = "2026-05-19T03:37:12.679Z" }, + { url = "https://files.pythonhosted.org/packages/09/a9/103fc647a8f9c721ab140fe8d2f8dbd90817e917ca763c4eb0f843fe247e/libdatachannel_py-2026.1.0.dev2-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:0b8aa3be2fa3654ea24f882756d6599e847de866a44f7a291c60994548a2debb", size = 1638879, upload-time = "2026-05-19T03:37:18.138Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/0dc7d3fe80fc247ec165dbd455bc2a1a307ef65702a43e24473202c2bf42/libdatachannel_py-2026.1.0.dev2-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:339a79fcbc8c6caf91c620f6e4a0f1b8ccb6a941a966d10a3135c980ae4651a6", size = 1718006, upload-time = "2026-05-19T03:37:11.891Z" }, + { url = "https://files.pythonhosted.org/packages/6d/86/30904a8753e9db60d8c3cf8efda09585fc68f2004d3d7aa2910c93a8eed5/libdatachannel_py-2026.1.0.dev2-cp312-cp312-manylinux_2_38_aarch64.whl", hash = "sha256:b9f476cb065b50856ab2e53bf774ccca9c6a66454b7ce903aaf6dfcdef2a4482", size = 1643757, upload-time = "2026-05-19T03:37:12.207Z" }, + { url = "https://files.pythonhosted.org/packages/d7/9d/1e10131396d28e84a8088a63c14978cc215f6677dc85acdd96b6068f0664/libdatachannel_py-2026.1.0.dev2-cp312-cp312-manylinux_2_38_x86_64.whl", hash = "sha256:1f31db7347549edcd69fcc1ecb8b31e7183894808ccf9387afc49a4d68debaae", size = 1751748, upload-time = "2026-05-19T03:37:06.98Z" }, +] + [[package]] name = "libusb-package" version = "1.0.30.0" @@ -703,11 +615,11 @@ wheels = [ [[package]] name = "markdown" -version = "3.10.2" +version = "3.10.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, ] [[package]] @@ -731,7 +643,7 @@ wheels = [ [[package]] name = "matplotlib" -version = "3.11.0" +version = "3.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, @@ -744,15 +656,15 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/17/f5276b496c61477a6c4fc5e7401f4bfe1c2e5ef7c6cd67896f2ade3809cb/matplotlib-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b5872e9cf11adc8f589ded3ce11bc3e1061ad498259664fabc1f6615beb918", size = 9449976, upload-time = "2026-06-12T02:27:50.989Z" }, - { url = "https://files.pythonhosted.org/packages/82/34/bdd77418adb2178a1d59f044bd67bfebb115896e91b840b8a197eb3f4f4e/matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0515d495124be3124340e59f164d901ed4484e2246a5b74cfa483cac3b80bd97", size = 9279307, upload-time = "2026-06-12T02:27:53.247Z" }, - { url = "https://files.pythonhosted.org/packages/94/95/7f522393c88313336b20d70fc849555757b2e5febc22b83b3a3f0fd4bce9/matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be5f93a1d21981bfb802ded0d77a0caa92d4342a47d45754fac77e314a506344", size = 10031353, upload-time = "2026-06-12T02:27:55.215Z" }, - { url = "https://files.pythonhosted.org/packages/87/ce/8f25a0e3186aefd61913e7467d1b999465bcd0d0c03ac695c1b26ca559b7/matplotlib-3.11.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41635d7909d19e52e924a521dde6d8f670b0f53ab1d0e8c331fa831554f681d1", size = 10839232, upload-time = "2026-06-12T02:27:57.746Z" }, - { url = "https://files.pythonhosted.org/packages/85/c2/db15da2bbdf9e3ca66df7db8e2c33a1dfed67be24a24d2c878efaaff01d6/matplotlib-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94f5000f67ca9faa300863ea17f8bce9175cb67b88bec4bc7780502d53dd7c9e", size = 10923899, upload-time = "2026-06-12T02:28:00.223Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e", size = 9329528, upload-time = "2026-06-12T02:28:02.27Z" }, - { url = "https://files.pythonhosted.org/packages/61/0f/4b669589d47733b97ab9df4b58d6fc1e68acb5ea42a928dc7cbdd6bf5871/matplotlib-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9dd11fb612ce7bc60b1de5b4fc87ff959d22317b5de42aabf392f66f97af22eb", size = 9003413, upload-time = "2026-06-12T02:28:04.49Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6c/7ef7ebcb2bd9739b2b66b18b076e077f44bb46fdbe28ca0506edb3c62c79/matplotlib-3.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e15ef41507f3d525f46154ac9e3ae785dacde9f20e593a25de8986267892ef74", size = 9453849, upload-time = "2026-07-18T03:38:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/6d0c312c8d9738e7d9677f09fe5c986b3239e651a7b73a2deb38b65e4a71/matplotlib-3.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b", size = 9283113, upload-time = "2026-07-18T03:38:21.95Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/b4ad2cc81b6672ea29ea04e64e350a9f9b493b0908ccd884c67eeff8f7b2/matplotlib-3.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea", size = 10035615, upload-time = "2026-07-18T03:38:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/4e10e033d9b66589d8ed98b84c95cdbb57033d57c1f41339d7393dbd2f2e/matplotlib-3.11.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c52f7ad20ef476806ed212380b1d54d20310c8b86bdc2c9a68b51f0024a44472", size = 10842559, upload-time = "2026-07-18T03:38:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/88/eb/799612d0f8cd3e816a10fec59329fca52cd2353264df80378dfc541ae855/matplotlib-3.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b14eb22961fe865efb0e4ff167e333e428908b00115a8d800ccb65ee108e481", size = 10927532, upload-time = "2026-07-18T03:38:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/88/89/56649bbaa2fd12e20f3be03dbcc135b0c8676d88bac17977599e3eb442a0/matplotlib-3.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f", size = 9333886, upload-time = "2026-07-18T03:38:30.477Z" }, + { url = "https://files.pythonhosted.org/packages/c1/11/4d124efbbad677b7b7552f6f85a3bd432d4232f95400cea98fcd2ae36ef3/matplotlib-3.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:480194afceca4df2f137c2721227d3cba67121fbf4397b69cee7f83714b0a58a", size = 9007545, upload-time = "2026-07-18T03:38:32.833Z" }, ] [[package]] @@ -771,13 +683,11 @@ source = { editable = "msgq_repo" } [package.metadata] requires-dist = [ - { name = "catch2", marker = "extra == 'dev'", git = "https://github.com/commaai/dependencies.git?subdirectory=catch2&rev=release-catch2" }, { name = "codespell", marker = "extra == 'dev'" }, { name = "cppcheck", marker = "extra == 'dev'" }, { name = "cpplint", marker = "extra == 'dev'" }, { name = "cython", marker = "extra == 'dev'" }, { name = "lefthook", marker = "extra == 'dev'" }, - { name = "parameterized", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "scons", marker = "extra == 'dev'" }, { name = "setuptools", marker = "extra == 'dev'" }, @@ -785,50 +695,23 @@ requires-dist = [ ] provides-extras = ["dev"] -[[package]] -name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, -] - [[package]] name = "numpy" -version = "2.5.0" +version = "2.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" }, - { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" }, - { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" }, - { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" }, - { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" }, - { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" }, - { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" }, - { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" }, - { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" }, - { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" }, + { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, + { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, + { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, ] [[package]] @@ -848,9 +731,7 @@ requires-dist = [ { name = "codespell", marker = "extra == 'testing'" }, { name = "cpplint", marker = "extra == 'testing'" }, { name = "gcovr", marker = "extra == 'testing'" }, - { name = "hypothesis", marker = "extra == 'testing'", specifier = "==6.47.*" }, { name = "inputs", marker = "extra == 'examples'" }, - { name = "jinja2", marker = "extra == 'docs'" }, { name = "lefthook", marker = "extra == 'testing'" }, { name = "numpy" }, { name = "pycapnp" }, @@ -863,7 +744,7 @@ requires-dist = [ { name = "unittest-parallel", marker = "extra == 'testing'" }, { name = "zstandard", marker = "extra == 'testing'" }, ] -provides-extras = ["testing", "docs", "examples"] +provides-extras = ["testing", "examples"] [package.metadata.requires-dev] testing = [ @@ -876,70 +757,38 @@ name = "openpilot" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "aiortc" }, - { name = "av" }, - { name = "cffi" }, { name = "comma-deps-acados" }, - { name = "comma-deps-bootstrap-icons" }, - { name = "comma-deps-bzip2" }, { name = "comma-deps-capnproto" }, - { name = "comma-deps-catch2" }, { name = "comma-deps-ffmpeg" }, { name = "comma-deps-gcc-arm-none-eabi" }, { name = "comma-deps-git-lfs" }, { name = "comma-deps-json11" }, - { name = "comma-deps-libusb" }, - { name = "comma-deps-ncurses" }, { name = "comma-deps-raylib" }, { name = "comma-deps-zeromq" }, { name = "comma-deps-zstd" }, - { name = "cython" }, { name = "inputs" }, { name = "jeepney" }, { name = "numpy" }, - { name = "pillow" }, { name = "pycapnp" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pyzmq" }, - { name = "qrcode" }, { name = "requests" }, { name = "scons" }, { name = "sentry-sdk" }, { name = "setproctitle" }, - { name = "setuptools" }, { name = "sounddevice" }, { name = "tqdm" }, { name = "websocket-client" }, - { name = "xattr" }, { name = "zstandard" }, ] [package.optional-dependencies] dev = [ - { name = "matplotlib" }, + { name = "huggingface-hub" }, ] docs = [ - { name = "jinja2" }, { name = "zensical" }, ] -testing = [ - { name = "codespell" }, - { name = "coverage" }, - { name = "hypothesis" }, - { name = "pre-commit-hooks" }, - { name = "pytest" }, - { name = "pytest-cpp" }, - { name = "pytest-mock" }, - { name = "pytest-subtests" }, - { name = "pytest-xdist" }, - { name = "ruff" }, - { name = "ty" }, -] -tools = [ - { name = "comma-deps-imgui" }, -] - -[package.dev-dependencies] submodules = [ { name = "msgq" }, { name = "opendbc" }, @@ -948,80 +797,80 @@ submodules = [ { name = "teleoprtc" }, { name = "tinygrad" }, ] +testing = [ + { name = "codespell" }, + { name = "coverage" }, + { name = "ruff" }, + { name = "ty" }, +] +tools = [ + { name = "comma-deps-bootstrap-icons" }, + { name = "comma-deps-imgui" }, + { name = "comma-deps-libusb" }, + { name = "comma-deps-ncurses" }, + { name = "matplotlib" }, +] + +[package.dev-dependencies] +standalone = [ + { name = "openpilot", extra = ["submodules"] }, +] [package.metadata] requires-dist = [ - { name = "aiortc" }, - { name = "av" }, - { name = "cffi" }, { name = "codespell", marker = "extra == 'testing'" }, { name = "comma-deps-acados" }, - { name = "comma-deps-bootstrap-icons" }, - { name = "comma-deps-bzip2" }, + { name = "comma-deps-bootstrap-icons", marker = "extra == 'tools'" }, { name = "comma-deps-capnproto" }, - { name = "comma-deps-catch2" }, { name = "comma-deps-ffmpeg" }, { name = "comma-deps-gcc-arm-none-eabi" }, { name = "comma-deps-git-lfs" }, { name = "comma-deps-imgui", marker = "extra == 'tools'" }, { name = "comma-deps-json11" }, - { name = "comma-deps-libusb" }, - { name = "comma-deps-ncurses" }, + { name = "comma-deps-libusb", marker = "extra == 'tools'" }, + { name = "comma-deps-ncurses", marker = "extra == 'tools'" }, { name = "comma-deps-raylib" }, { name = "comma-deps-zeromq" }, { name = "comma-deps-zstd" }, { name = "coverage", marker = "extra == 'testing'" }, - { name = "cython" }, - { name = "hypothesis", marker = "extra == 'testing'", specifier = "==6.47.*" }, + { name = "huggingface-hub", marker = "extra == 'dev'" }, { name = "inputs" }, { name = "jeepney" }, - { name = "jinja2", marker = "extra == 'docs'" }, - { name = "matplotlib", marker = "extra == 'dev'" }, + { name = "matplotlib", marker = "extra == 'tools'" }, + { name = "msgq", marker = "extra == 'submodules'", editable = "msgq_repo" }, { name = "numpy", specifier = ">=2.0" }, - { name = "pillow" }, - { name = "pre-commit-hooks", marker = "extra == 'testing'" }, + { name = "opendbc", marker = "extra == 'submodules'", editable = "opendbc_repo" }, + { name = "pandacan", marker = "extra == 'submodules'", editable = "panda" }, { name = "pycapnp", specifier = "==2.1.0" }, - { name = "pyjwt" }, - { name = "pytest", marker = "extra == 'testing'" }, - { name = "pytest-cpp", marker = "extra == 'testing'" }, - { name = "pytest-mock", marker = "extra == 'testing'" }, - { name = "pytest-subtests", marker = "extra == 'testing'" }, - { name = "pytest-xdist", marker = "extra == 'testing'", git = "https://github.com/sshane/pytest-xdist?rev=2b4372bd62699fb412c4fe2f95bf9f01bd2018da" }, + { name = "pyjwt", extras = ["crypto"] }, { name = "pyzmq" }, - { name = "qrcode" }, + { name = "rednose", marker = "extra == 'submodules'", editable = "rednose_repo" }, { name = "requests" }, { name = "ruff", marker = "extra == 'testing'" }, - { name = "scons" }, + { name = "scons", specifier = "==4.10.1" }, { name = "sentry-sdk" }, { name = "setproctitle" }, - { name = "setuptools" }, { name = "sounddevice" }, + { name = "teleoprtc", marker = "extra == 'submodules'", editable = "teleoprtc_repo" }, + { name = "tinygrad", marker = "extra == 'submodules'", editable = "tinygrad_repo" }, { name = "tqdm" }, { name = "ty", marker = "extra == 'testing'" }, { name = "websocket-client" }, - { name = "xattr" }, { name = "zensical", marker = "extra == 'docs'" }, { name = "zstandard" }, ] -provides-extras = ["docs", "testing", "dev", "tools"] +provides-extras = ["docs", "dev", "testing", "tools", "submodules"] [package.metadata.requires-dev] -submodules = [ - { name = "msgq", editable = "msgq_repo" }, - { name = "opendbc", editable = "opendbc_repo" }, - { name = "pandacan", editable = "panda" }, - { name = "rednose", editable = "rednose_repo" }, - { name = "teleoprtc", editable = "teleoprtc_repo" }, - { name = "tinygrad", editable = "tinygrad_repo" }, -] +standalone = [{ name = "openpilot", extras = ["submodules"] }] [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -1032,6 +881,7 @@ dependencies = [ { name = "libusb-package" }, { name = "libusb1" }, { name = "opendbc" }, + { name = "spidev", marker = "sys_platform == 'linux'" }, ] [package.metadata] @@ -1043,7 +893,6 @@ requires-dist = [ { name = "libusb-package" }, { name = "libusb1" }, { name = "opendbc", git = "https://github.com/sunnypilot/opendbc.git?rev=master" }, - { name = "pycryptodome", marker = "extra == 'dev'", specifier = ">=3.9.8" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest-mock", marker = "extra == 'dev'" }, { name = "pytest-timeout", marker = "extra == 'dev'" }, @@ -1051,6 +900,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'" }, { name = "scons", marker = "extra == 'dev'" }, { name = "setuptools", marker = "extra == 'dev'" }, + { name = "spidev", marker = "sys_platform == 'linux'" }, ] provides-extras = ["dev"] @@ -1071,53 +921,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, ] -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pre-commit-hooks" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ruamel-yaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/36/4d/93e63e48f8fd16d6c1e4cef5dabadcade4d1325c7fd6f29f075a4d2284f3/pre_commit_hooks-6.0.0.tar.gz", hash = "sha256:76d8370c006f5026cdd638a397a678d26dda735a3c88137e05885a020f824034", size = 28293, upload-time = "2025-08-09T19:25:04.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/46/eba9be9daa403fa94854ce16a458c29df9a01c6c047931c3d8be6016cd9a/pre_commit_hooks-6.0.0-py2.py3-none-any.whl", hash = "sha256:76161b76d321d2f8ee2a8e0b84c30ee8443e01376121fd1c90851e33e3bd7ee2", size = 41338, upload-time = "2025-08-09T19:25:03.513Z" }, -] - -[[package]] -name = "propcache" -version = "0.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, - { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, - { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, - { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, - { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, - { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, - { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, - { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, - { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, - { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, - { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, - { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, - { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, - { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, - { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, -] - [[package]] name = "pycapnp" version = "2.1.0" @@ -1166,25 +969,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, ] -[[package]] -name = "pyee" -version = "13.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, -] - [[package]] name = "pygments" -version = "2.20.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] @@ -1196,26 +987,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] -[[package]] -name = "pylibsrtp" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/a6/6e532bec974aaecbf9fe4e12538489fb1c28456e65088a50f305aeab9f89/pylibsrtp-1.0.0.tar.gz", hash = "sha256:b39dff075b263a8ded5377f2490c60d2af452c9f06c4d061c7a2b640612b34d4", size = 10858, upload-time = "2025-10-13T16:12:31.552Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/af/89e61a62fa3567f1b7883feb4d19e19564066c2fcd41c37e08d317b51881/pylibsrtp-1.0.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:822c30ea9e759b333dc1f56ceac778707c51546e97eb874de98d7d378c000122", size = 1865017, upload-time = "2025-10-13T16:12:15.62Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0e/8d215484a9877adcf2459a8b28165fc89668b034565277fd55d666edd247/pylibsrtp-1.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:aaad74e5c8cbc1c32056c3767fea494c1e62b3aea2c908eda2a1051389fdad76", size = 2182739, upload-time = "2025-10-13T16:12:17.121Z" }, - { url = "https://files.pythonhosted.org/packages/57/3f/76a841978877ae13eac0d4af412c13bbd5d83b3df2c1f5f2175f2e0f68e5/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9209b86e662ebbd17c8a9e8549ba57eca92a3e87fb5ba8c0e27b8c43cd08a767", size = 2732922, upload-time = "2025-10-13T16:12:18.348Z" }, - { url = "https://files.pythonhosted.org/packages/0e/14/cf5d2a98a66fdfe258f6b036cda570f704a644fa861d7883a34bc359501e/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc", size = 2434534, upload-time = "2025-10-13T16:12:20.074Z" }, - { url = "https://files.pythonhosted.org/packages/bd/08/a3f6e86c04562f7dce6717cd2206a0f84ca85c5e38121d998e0e330194c3/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:81fb8879c2e522021a7cbd3f4bda1b37c192e1af939dfda3ff95b4723b329663", size = 2345818, upload-time = "2025-10-13T16:12:21.439Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d5/130c2b5b4b51df5631684069c6f0a6761c59d096a33d21503ac207cf0e47/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4ddb562e443cf2e557ea2dfaeef0d7e6b90e96dd38eb079b4ab2c8e34a79f50b", size = 2774490, upload-time = "2025-10-13T16:12:22.659Z" }, - { url = "https://files.pythonhosted.org/packages/91/e3/715a453bfee3bea92a243888ad359094a7727cc6d393f21281320fe7798c/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f02e616c9dfab2b03b32d8cc7b748f9d91814c0211086f987629a60f05f6e2cc", size = 2372603, upload-time = "2025-10-13T16:12:24.036Z" }, - { url = "https://files.pythonhosted.org/packages/e3/56/52fa74294254e1f53a4ff170ee2006e57886cf4bb3db46a02b4f09e1d99f/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c134fa09e7b80a5b7fed626230c5bc257fd771bd6978e754343e7a61d96bc7e6", size = 2451269, upload-time = "2025-10-13T16:12:25.475Z" }, - { url = "https://files.pythonhosted.org/packages/1e/51/2e9b34f484cbdd3bac999bf1f48b696d7389433e900639089e8fc4e0da0d/pylibsrtp-1.0.0-cp310-abi3-win32.whl", hash = "sha256:bae377c3b402b17b9bbfbfe2534c2edba17aa13bea4c64ce440caacbe0858b55", size = 1247503, upload-time = "2025-10-13T16:12:27.39Z" }, - { url = "https://files.pythonhosted.org/packages/c3/70/43db21af194580aba2d9a6d4c7bd8c1a6e887fa52cd810b88f89096ecad2/pylibsrtp-1.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:8d6527c4a78a39a8d397f8862a8b7cdad4701ee866faf9de4ab8c70be61fd34d", size = 1601659, upload-time = "2025-10-13T16:12:29.037Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ec/6e02b2561d056ea5b33046e3cad21238e6a9097b97d6ccc0fbe52b50c858/pylibsrtp-1.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:2696bdb2180d53ac55d0eb7b58048a2aa30cd4836dd2ca683669889137a94d2a", size = 1159246, upload-time = "2025-10-13T16:12:30.285Z" }, +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, ] [[package]] @@ -1231,19 +1005,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, ] -[[package]] -name = "pyopenssl" -version = "26.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/b7/da07bae88f5a9506b4def6f2f4903cf4c3b8831e560dba8fa18ca08f758f/pyopenssl-26.3.0.tar.gz", hash = "sha256:589de7fae1c9ea670d18422ed00fc04da787bbde8e1454aea872aa57b49ad341", size = 182024, upload-time = "2026-06-12T20:28:07.458Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/18/1dd71c9b43192ab83f1d531ad6002dc81108ac36c475f79fb7a295abe2f4/pyopenssl-26.3.0-py3-none-any.whl", hash = "sha256:46367f8f66b92271e6d218da9c87607e1ef5a0bc5c8dea5bb3db82f395c385a3", size = 56008, upload-time = "2026-06-12T20:28:05.999Z" }, -] - [[package]] name = "pyparsing" version = "3.3.2" @@ -1253,68 +1014,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] -[[package]] -name = "pytest" -version = "9.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, -] - -[[package]] -name = "pytest-cpp" -version = "2.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cf/a1/c2679d7ff2da20a0f89c7820ae2739cde739eac9b43c192531117b31b5f4/pytest_cpp-2.6.0.tar.gz", hash = "sha256:c2f49d3c038539ac84786a94d852e4f4619c34c95979c2bc69c20b3bdf051d85", size = 465490, upload-time = "2024-09-18T00:08:08.251Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/44/dc2f5d53165264ae5831f361fe7723c45da05718a97015b2eddc452cf503/pytest_cpp-2.6.0-py3-none-any.whl", hash = "sha256:b33de94609450feea2fba9efff3558b8ac8f1fdf40a99e263b395d4798b911bb", size = 15074, upload-time = "2024-09-18T00:08:06.415Z" }, -] - -[[package]] -name = "pytest-mock" -version = "3.15.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, -] - -[[package]] -name = "pytest-subtests" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/d9/20097971a8d315e011e055d512fa120fd6be3bdb8f4b3aa3e3c6bf77bebc/pytest_subtests-0.15.0.tar.gz", hash = "sha256:cb495bde05551b784b8f0b8adfaa27edb4131469a27c339b80fd8d6ba33f887c", size = 18525, upload-time = "2025-10-20T16:26:18.358Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/64/bba465299b37448b4c1b84c7a04178399ac22d47b3dc5db1874fe55a2bd3/pytest_subtests-0.15.0-py3-none-any.whl", hash = "sha256:da2d0ce348e1f8d831d5a40d81e3aeac439fec50bd5251cbb7791402696a9493", size = 9185, upload-time = "2025-10-20T16:26:17.239Z" }, -] - -[[package]] -name = "pytest-xdist" -version = "3.7.1.dev24+g2b4372b" -source = { git = "https://github.com/sshane/pytest-xdist?rev=2b4372bd62699fb412c4fe2f95bf9f01bd2018da#2b4372bd62699fb412c4fe2f95bf9f01bd2018da" } -dependencies = [ - { name = "execnet" }, - { name = "pytest" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1366,18 +1065,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, ] -[[package]] -name = "qrcode" -version = "8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, -] - [[package]] name = "rednose" version = "0.0.1" @@ -1422,38 +1109,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] -[[package]] -name = "ruamel-yaml" -version = "0.19.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, -] - [[package]] name = "ruff" -version = "0.15.20" +version = "0.16.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, - { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, - { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, - { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, - { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, - { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, - { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, - { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, - { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, - { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, ] [[package]] @@ -1467,15 +1145,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.64.0" +version = "2.68.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/31/b7341f156a5f6f36f0b4845d6f1c28a2ae4799171dba7007f3a1e9b234b4/sentry_sdk-2.64.0.tar.gz", hash = "sha256:68be2c29e14ae310f8a39e1a79916b6d85c6cb41dcce789d14ff05fe293e4c55", size = 921020, upload-time = "2026-06-30T08:13:47.682Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/94/23b7dd072acb9628907bd3f4fbf61794a7b12a9db8f33c1276f70ae5ac92/sentry_sdk-2.68.0.tar.gz", hash = "sha256:648c58e9887311a03470a41539e24bdbbf64a30ca4f5336f7e3dcc87276400b3", size = 1008854, upload-time = "2026-08-13T09:06:21.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/a8/3fb9a4319efa3b26f5be0e90e6d8918df43fa7c7e977d26390f589501d82/sentry_sdk-2.64.0-py3-none-any.whl", hash = "sha256:715ea91ca860a819e8d8a50a7bde3a80d0df3b4ed7b6660a20fb9a2d084188f1", size = 498901, upload-time = "2026-06-30T08:13:45.566Z" }, + { url = "https://files.pythonhosted.org/packages/7d/9b/e2421d08956d0bc4691d995393d835e563886bff499d8fb10fdefae85a8d/sentry_sdk-2.68.0-py3-none-any.whl", hash = "sha256:538e56c2d03679d42f7c0cb5f1af73a7a510b00abc7e296c13ac49b107b713a4", size = 518670, upload-time = "2026-08-13T09:06:19.735Z" }, ] [[package]] @@ -1498,11 +1176,11 @@ wheels = [ [[package]] name = "setuptools" -version = "82.0.1" +version = "84.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, ] [[package]] @@ -1514,31 +1192,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - [[package]] name = "sounddevice" -version = "0.5.5" +version = "0.5.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/db/0c890e2d9aab9ba284021efc02e1d3aebfecab1b611762d7434602209bcf/sounddevice-0.5.6.tar.gz", hash = "sha256:8ec9fbfde2e32f020b167e348f3ab3bac6625a5f15af524d790108ac7147a410", size = 1120094, upload-time = "2026-08-17T07:55:05.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" }, - { url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" }, - { url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" }, - { url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" }, - { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, + { url = "https://files.pythonhosted.org/packages/72/1f/62eef605172bddc1017508469a12f75bc7c4194ece35c734f822795f53b1/sounddevice-0.5.6-py3-none-any.whl", hash = "sha256:de099612311ad81e55d31ccbd83f43ea6bf4d87b48f9b6ea55a1fbcde0eee4e0", size = 32793, upload-time = "2026-08-17T07:54:57.507Z" }, + { url = "https://files.pythonhosted.org/packages/b6/84/85e719d49cf98b2f406d9ac9c338892286c4448eb42ef0b2625ccf159616/sounddevice-0.5.6-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:e3aef00ad8b1d1740eb66d9a7671eab88a4d2b8fa4ab33498d742e63b65c309c", size = 1009647, upload-time = "2026-08-17T07:54:58.814Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6f/6292145099f72a153a710245f46ae43e5fb6c77bec1b6086cb76c12dc280/sounddevice-0.5.6-py3-none-win32.whl", hash = "sha256:b36b807eb02abd257198bf84b2af05e4fea199a9d2f0019014169c7136d45e9c", size = 1009627, upload-time = "2026-08-17T07:55:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3e/cbc593c31a5f0d817b3fe97e64aa8461bd0f55cb07b67ce1b776296ae336/sounddevice-0.5.6-py3-none-win_amd64.whl", hash = "sha256:7f4162f514f007b0bf25a3ccfed3f1705bc2ec311888a90232729eec4f57a4f4", size = 1009630, upload-time = "2026-08-17T07:55:02.088Z" }, + { url = "https://files.pythonhosted.org/packages/60/a4/b0c21c9f215a6fd9606b8f8748c21212dc098e5d5a2d93068c50edcf19b4/sounddevice-0.5.6-py3-none-win_arm64.whl", hash = "sha256:c8ae19173e5f27f8c12d4b5eee2dbfe542cee125d591e663e0fb4dfb75246d45", size = 1009630, upload-time = "2026-08-17T07:55:03.689Z" }, ] +[[package]] +name = "spidev" +version = "3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/87/039b6eeea781598015b538691bc174cc0bf77df9d4d2d3b8bf9245c0de8c/spidev-3.8.tar.gz", hash = "sha256:2bc02fb8c6312d519ebf1f4331067427c0921d3f77b8bcaf05189a2e8b8382c0", size = 13893, upload-time = "2025-09-15T18:56:20.672Z" } + [[package]] name = "sympy" version = "1.14.0" @@ -1556,18 +1231,12 @@ name = "teleoprtc" version = "1.0.1" source = { editable = "teleoprtc_repo" } dependencies = [ - { name = "aiohttp" }, - { name = "aiortc" }, - { name = "av" }, - { name = "numpy" }, + { name = "libdatachannel-py" }, ] [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.7.0" }, - { name = "aiortc", specifier = ">=1.6.0" }, - { name = "av", specifier = ">=11.0.0,<13.0.0" }, - { name = "numpy", specifier = ">=1.19.0" }, + { name = "libdatachannel-py", specifier = ">=2026.1.0.dev2" }, { name = "parameterized", marker = "extra == 'dev'", specifier = ">=0.8" }, { name = "pre-commit", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'" }, @@ -1578,7 +1247,7 @@ provides-extras = ["dev"] [[package]] name = "tinygrad" -version = "0.12.0" +version = "0.13.0" source = { editable = "tinygrad_repo" } [package.metadata] @@ -1616,7 +1285,6 @@ requires-dist = [ { name = "pylint", marker = "extra == 'linting'" }, { name = "pytest", marker = "extra == 'testing-minimal'" }, { name = "pytest-split", marker = "extra == 'testing-minimal'" }, - { name = "pytest-timeout", marker = "extra == 'testing-minimal'" }, { name = "pytest-xdist", marker = "extra == 'testing-minimal'" }, { name = "ruff", marker = "extra == 'linting'", specifier = "==0.14.10" }, { name = "safetensors", marker = "extra == 'testing-unit'" }, @@ -1625,6 +1293,7 @@ requires-dist = [ { name = "tiktoken", marker = "extra == 'testing'" }, { name = "tinygrad", extras = ["testing-minimal"], marker = "extra == 'testing-unit'" }, { name = "tinygrad", extras = ["testing-unit"], marker = "extra == 'testing'" }, + { name = "tinymesa", marker = "extra == 'mesa'", specifier = "==25.2.7.2" }, { name = "torch", marker = "extra == 'testing-minimal'", specifier = "==2.9.1" }, { name = "tqdm", marker = "extra == 'testing-unit'" }, { name = "transformers", marker = "extra == 'testing'" }, @@ -1632,7 +1301,7 @@ requires-dist = [ { name = "typing-extensions", marker = "extra == 'linting'" }, { name = "z3-solver", marker = "extra == 'testing-minimal'", specifier = "<4.15.4" }, ] -provides-extras = ["linting", "testing-minimal", "testing-unit", "testing", "docs"] +provides-extras = ["linting", "testing-minimal", "testing-unit", "testing", "docs", "mesa"] [[package]] name = "tomli" @@ -1654,39 +1323,39 @@ wheels = [ [[package]] name = "tqdm" -version = "4.68.3" +version = "4.70.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, ] [[package]] name = "ty" -version = "0.0.56" +version = "0.0.73" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/55/07/fb29aea5235b0aa8ecfc4d1cc6ddf9fba8b863d67d96c6d345694d644c43/ty-0.0.56.tar.gz", hash = "sha256:84d114dc3796361c0fc72945016eabd74d46b9ee64f198cb0e485719704681e5", size = 6050123, upload-time = "2026-07-01T16:44:56.036Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/90/c4e1bb4cead3b644c3e258a27f9b05c7dc5eb0ec96a4f5282194edae9e0d/ty-0.0.73.tar.gz", hash = "sha256:823d4ce0d237bfc7eb6bcee70842f2c0706113813a16951077840743712f4b74", size = 6712739, upload-time = "2026-08-19T03:12:43.381Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/48/bce79e7ca5c1cc529d3e0d37ddd1121aea4b68a4f749974ad1cc77161871/ty-0.0.56-py3-none-linux_armv6l.whl", hash = "sha256:186d4a53e15747c947e1ec3d7eec8e345d8e40a1ca10e634c585db52497e87dd", size = 11643066, upload-time = "2026-07-01T16:44:18.374Z" }, - { url = "https://files.pythonhosted.org/packages/80/d1/22555d8a1d719661f10050f3865d877bbf497da908961c75fe22217dd18a/ty-0.0.56-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aae1a980fd9535da0469b7ba2b2e1b54a907743a5e0f442dd57eee9f5bfd034c", size = 11407487, upload-time = "2026-07-01T16:44:20.956Z" }, - { url = "https://files.pythonhosted.org/packages/cf/2d/b3b7a74ce8bc59ef48843ad80179bb0d9598bbd6cfc0d11d519bdf6b1352/ty-0.0.56-py3-none-macosx_11_0_arm64.whl", hash = "sha256:afd3058c0a6c5f241e814734f133008c93ee805f61c9cf4ce7412b8822b5d9ad", size = 10962270, upload-time = "2026-07-01T16:44:22.959Z" }, - { url = "https://files.pythonhosted.org/packages/64/ac/6c2fd7de0304a8a7218a756af74f7e62a5e8540fdb175e0a869e51042345/ty-0.0.56-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:058b52f7a823ac13aae3cae30809dd6b5145794b64d8478f9ef38c75d79b4483", size = 11471406, upload-time = "2026-07-01T16:44:25.327Z" }, - { url = "https://files.pythonhosted.org/packages/50/b6/11d861156861c03c7726b74558f9a0e0092661aff83a4fda1279df28c425/ty-0.0.56-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c66e00c1522add1f2bbdd2e45828c953b35c306b7bef03ec9169c75a63699a0", size = 11445612, upload-time = "2026-07-01T16:44:27.531Z" }, - { url = "https://files.pythonhosted.org/packages/fb/ba/09df108582090f3c0770ec4bc8675affed60248f6793a78d909be16211d9/ty-0.0.56-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40903d71c669a30691b5a5d5728056c7877a1bd6be4f233a38883a8b28cf34d7", size = 12093889, upload-time = "2026-07-01T16:44:29.548Z" }, - { url = "https://files.pythonhosted.org/packages/d7/f7/dbb4b4ccb69cd64c209ae55b1ab788ace8222c2bc1f6845be9e7cbedbf25/ty-0.0.56-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63fe3947fe0c46c69a7d950e6832ee70a9ec17321fefbff3d2e3c20baf9e5bd0", size = 12666337, upload-time = "2026-07-01T16:44:31.586Z" }, - { url = "https://files.pythonhosted.org/packages/86/e9/73f903fe4a3d9ea02f26f57c1eb07e3b1029ec92b0e8c2364718893440e3/ty-0.0.56-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71a0c1a72f9854532e710e119b6871ffe4542c8a65146f1f65dcd78fecd885b4", size = 12280247, upload-time = "2026-07-01T16:44:33.637Z" }, - { url = "https://files.pythonhosted.org/packages/d6/90/cebd222495832f1a00dcd321ba25f3cab804221a4991b992c2178bec68ee/ty-0.0.56-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70d1665596494e24d8ebd198438872b5a56ec3cae5f2bcf6c673be797acc4e3c", size = 11991107, upload-time = "2026-07-01T16:44:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/b7/07/8f7337a07250f42d975cdb6decf47fc5b421e6c7da5e3e7be1e85f63a7e5/ty-0.0.56-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:778f99e51558afc1dbbe48ee38ab6aae7b31390ed8c1a1ef1499b295e9f1e82f", size = 12298970, upload-time = "2026-07-01T16:44:38.243Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b9/a52cd59034a48f5f18c6b155cc2cc36861d874b6d0af204b12c898024c3d/ty-0.0.56-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:867bc5708e0066bb4ff6c7db524bd5deea2676c62bfe71d3303138b3be850af0", size = 11425683, upload-time = "2026-07-01T16:44:40.473Z" }, - { url = "https://files.pythonhosted.org/packages/1d/2e/48e42d33357d52eefb695c0c3fcfc96879b73668a7447d1d1e0ad774fedc/ty-0.0.56-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6012f4189c928edb330a37deb9930f982380bd4aa7c4b8e0428eec9651c7551", size = 11469258, upload-time = "2026-07-01T16:44:42.513Z" }, - { url = "https://files.pythonhosted.org/packages/d5/01/ad1b4138be1e3fa97863af3925aa2134f17a593240c35dc38c3429fb5ad1/ty-0.0.56-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee83de1a7ff4cc32837ec06134ce391d441bc5b35ecd8d3cfe053f120f3e4c1", size = 11758736, upload-time = "2026-07-01T16:44:44.567Z" }, - { url = "https://files.pythonhosted.org/packages/09/34/9d81967ff240eaa57e9249728ef7b7790747cf6d3c9a98ec86b2cfdcc8ee/ty-0.0.56-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:62619b3b0e2c6248ef30d3f0e2f2217ae9893040585be07f32324242f197cd6f", size = 12100242, upload-time = "2026-07-01T16:44:46.584Z" }, - { url = "https://files.pythonhosted.org/packages/c3/36/f51d4666d2de6cf33c1f3a1fcc4bb6b70b197dd6ceaa491eef71d78fe8e8/ty-0.0.56-py3-none-win32.whl", hash = "sha256:b30687bb5cd9729d34c889a289edf32770388d9bb05243e534e723fb45e0381b", size = 11093759, upload-time = "2026-07-01T16:44:49.171Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b4/8fb5d4acfa4afb152245b20fa263069a7547bd1f8e4bfca4eda280c897d7/ty-0.0.56-py3-none-win_amd64.whl", hash = "sha256:ad4c8c47b6f4e3f9ed3fc0b1a5d650088d229e17dd8f63c1826d6bbe94cc3235", size = 12100327, upload-time = "2026-07-01T16:44:51.26Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fc/6a183e71edde90d0c35c2303f23f7a45b6891d1a2c45daf7b8f869831e19/ty-0.0.56-py3-none-win_arm64.whl", hash = "sha256:57538f273d444a5f1293fa7860e967178afe3917611fc5eff16b64e1204fe0d6", size = 11538780, upload-time = "2026-07-01T16:44:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f5e1801e55cc631f2db193276675b30561b963a2403da832bffb5d100267/ty-0.0.73-py3-none-linux_armv6l.whl", hash = "sha256:90a946082bf9bc446b5e72973d9f4ff1222a240b2ca4c9e6eed61eb913e30810", size = 12715452, upload-time = "2026-08-19T03:12:06.673Z" }, + { url = "https://files.pythonhosted.org/packages/54/32/515dd05074c213b433524ab97eb003b0132ae7e358e0d75633ba7a314ed8/ty-0.0.73-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b7d6b5c6a6db7ea95fbbc16af514ef44a27a29a2fe1dc798900790364d170209", size = 12301870, upload-time = "2026-08-19T03:12:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/50/4d/085b4889f0d4bbe4af8b96242d4a1cb209fff95967cfa239ea141983719b/ty-0.0.73-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6f657f463e01372d8688f235be164750c8db722c97da27fa4903aa8d40b203", size = 12111741, upload-time = "2026-08-19T03:12:11.067Z" }, + { url = "https://files.pythonhosted.org/packages/95/f6/d6ec277cadfecf03ad4c18551b67c4c6eb7807a0560d801db14be99d7a89/ty-0.0.73-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2de468e33fd44c9ff1c43473a7316f4289480f5cba8995a67b6d22aee39ca9", size = 12196124, upload-time = "2026-08-19T03:12:13.14Z" }, + { url = "https://files.pythonhosted.org/packages/75/b7/ce78d8707563af9cae9bbd25328bfbc4931035085bd20089adf0c418f70e/ty-0.0.73-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2942fa0ef795a66034cdc8d75a72f453442f3b58ff2f69b4da05b7b954765b55", size = 12488557, upload-time = "2026-08-19T03:12:15.252Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e8/329b9851b23502758c5c98e8cc875ea2a1b4c9674b4ca3a86da56a5063d3/ty-0.0.73-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e0f1ef14f642e18ac4e7a616a2796dcf7a5d82e28cd17f9796494acc7c4aabb", size = 13215606, upload-time = "2026-08-19T03:12:17.225Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/67fedfd2cb77516ef0066b1642f487dba0eb3006493cf3475b15f5b8b228/ty-0.0.73-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16981e15fdceedb37d0aff76c5ac25914595dfee2675af95335550064251ad22", size = 13665497, upload-time = "2026-08-19T03:12:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b3/154f4dd48ec5eebc186ab4b822c6e62f982fc5ddfd262d6e3903c2acba44/ty-0.0.73-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644b2bec8a2e2e4957a942ae81d6cff5571c489bb5a8675e4d3886de537a694d", size = 13351231, upload-time = "2026-08-19T03:12:21.353Z" }, + { url = "https://files.pythonhosted.org/packages/35/5f/d462496903fbe453fb76363f8478be929c8e6ff21e6928c57dcd7e5fa21f/ty-0.0.73-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338d565be3186f50ff8e9d10483685549c2d23f0754485d5ede3b54f4319188a", size = 12782586, upload-time = "2026-08-19T03:12:23.667Z" }, + { url = "https://files.pythonhosted.org/packages/87/52/ec6d24b74abe3ec324204c1c71e6d0c6c76a17ffc15fd51d603b0a302abe/ty-0.0.73-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:11c7b6d839309d2c102cb3a4c03d817176bbfab5b2fccc95a75ec5c9597421c9", size = 13247134, upload-time = "2026-08-19T03:12:25.956Z" }, + { url = "https://files.pythonhosted.org/packages/26/20/cc74650fec56a54786c6d7c89e09576fcad3092be34cf21715d39a406a9b/ty-0.0.73-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:488572db7ff97fb50ea36a76250f2d617c9727d143da6c7bf0623276eb0fc507", size = 12309344, upload-time = "2026-08-19T03:12:28.122Z" }, + { url = "https://files.pythonhosted.org/packages/89/bd/4b0a9087f4315d7fbadf77a3ce44c816cc9ffabed1ced06cc5be81fbc414/ty-0.0.73-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1b958ebceefbbf594e59eb8d3d55bbd033ce634026fcba3e4bc3179e78e45bb7", size = 12502319, upload-time = "2026-08-19T03:12:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/11/80/0a925074911fe111912ea29d9eed309bcc183f43d2fb3eef07db056a0beb/ty-0.0.73-py3-none-musllinux_1_2_i686.whl", hash = "sha256:91a32993b3c34e42c3f323ad6c0399cb596bd1c27e9b7f20db7cd64c1067b68e", size = 12753688, upload-time = "2026-08-19T03:12:32.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/6b/aeccaf89efbc2e112bd415340a22e2669ec998aa397242503e747b712ca4/ty-0.0.73-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bab8a19fbf51f479bddb2a12c5fabfe52f918a5590362321ed5d89b44eb62c15", size = 13069050, upload-time = "2026-08-19T03:12:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3e/eae485fd86c1585943fd4e1746b0757b2da01e2c43136ebe8c686fe1c7f1/ty-0.0.73-py3-none-win32.whl", hash = "sha256:03347a612f0fa020b19bfd8dbd521db6ecc75d377a3e4d4f6e6c2e62871da4cc", size = 12053187, upload-time = "2026-08-19T03:12:37.565Z" }, + { url = "https://files.pythonhosted.org/packages/a7/01/9b8b983786e3ce34924e372e8b76b92b508273ab65c589fc7e88cc03ee17/ty-0.0.73-py3-none-win_amd64.whl", hash = "sha256:cedd05122ded0b5dcc55431a370e974b747f99c41c290a3d2ab8c1867f197519", size = 12693838, upload-time = "2026-08-19T03:12:39.483Z" }, + { url = "https://files.pythonhosted.org/packages/ea/88/25333bbfea6a5dc064371d2002d3d4807db90b84d5448f9106b2712b0fbc/ty-0.0.73-py3-none-win_arm64.whl", hash = "sha256:e47068f8369dea5d641a26a2ad0a947a320b02ff87099b07e95de0323245a4dc", size = 12443573, upload-time = "2026-08-19T03:12:41.449Z" }, ] [[package]] @@ -1716,58 +1385,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, ] -[[package]] -name = "xattr" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/08/d5/25f7b19af3a2cb4000cac4f9e5525a40bec79f4f5d0ac9b517c0544586a0/xattr-1.3.0.tar.gz", hash = "sha256:30439fabd7de0787b27e9a6e1d569c5959854cb322f64ce7380fedbfa5035036", size = 17148, upload-time = "2025-10-13T22:16:47.353Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/78/00bdc9290066173e53e1e734d8d8e1a84a6faa9c66aee9df81e4d9aeec1c/xattr-1.3.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dd4e63614722d183e81842cb237fd1cc978d43384166f9fe22368bfcb187ebe5", size = 23476, upload-time = "2025-10-13T22:16:06.942Z" }, - { url = "https://files.pythonhosted.org/packages/53/16/5243722294eb982514fa7b6b87a29dfb7b29b8e5e1486500c5babaf6e4b3/xattr-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:995843ef374af73e3370b0c107319611f3cdcdb6d151d629449efecad36be4c4", size = 18556, upload-time = "2025-10-13T22:16:08.209Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5c/d7ab0e547bea885b55f097206459bd612cefb652c5fc1f747130cbc0d42c/xattr-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fa23a25220e29d956cedf75746e3df6cc824cc1553326d6516479967c540e386", size = 18869, upload-time = "2025-10-13T22:16:10.319Z" }, - { url = "https://files.pythonhosted.org/packages/98/25/25cc7d64f07de644b7e9057842227adf61017e5bcfe59a79df79f768874c/xattr-1.3.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b4345387087fffcd28f709eb45aae113d911e1a1f4f0f70d46b43ba81e69ccdd", size = 38797, upload-time = "2025-10-13T22:16:11.624Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/cc350bcdbed006dfcc6ade0ac817693b8b3d4b2787f20e427fd0697042e4/xattr-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe92bb05eb849ab468fe13e942be0f8d7123f15d074f3aba5223fad0c4b484de", size = 38956, upload-time = "2025-10-13T22:16:13.121Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b2/9416317ac89e2ed759a861857cda0d5e284c3691e6f460d36cc2bd5ce4d1/xattr-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c42ef5bdac3febbe28d3db14d3a8a159d84ba5daca2b13deae6f9f1fc0d4092", size = 38214, upload-time = "2025-10-13T22:16:14.389Z" }, - { url = "https://files.pythonhosted.org/packages/38/63/188f7cb41ab35d795558325d5cc8ab552171d5498cfb178fd14409651e18/xattr-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2aaa5d66af6523332189108f34e966ca120ff816dfa077ca34b31e6263f8a236", size = 37754, upload-time = "2025-10-13T22:16:15.306Z" }, -] - -[[package]] -name = "yarl" -version = "1.24.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, - { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, - { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, - { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, - { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, - { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, - { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, - { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, - { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, - { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, - { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, - { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, - { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, - { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, -] - [[package]] name = "zensical" -version = "0.0.46" +version = "0.0.56" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1779,20 +1399,20 @@ dependencies = [ { name = "pyyaml" }, { name = "tomli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/57/c7bbb71f943e1e0ba5ce460f4930ec836ead7286969e7fd742f7a6c049ab/zensical-0.0.46.tar.gz", hash = "sha256:3ec21f4fb1e78cd7c0d6b07ae336b04770e27ba020dabc457b2790e5d34f1978", size = 3973968, upload-time = "2026-06-21T18:52:40.368Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/7d/18bb725a659352e9af0940a3d879c5edcff5d86fec3ac15ce500d484d9d3/zensical-0.0.56.tar.gz", hash = "sha256:c359163800d1c3a8c39af48f4e2869fcfc2b4fc00d28652bd2a5b0330c36530c", size = 3997416, upload-time = "2026-08-18T15:46:47.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/bd/bbc499ee35ac9ec5459dbfec7bb7231556689e97eaa13a5eddbe1f0443b5/zensical-0.0.46-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d91af81ab058c8693dfd75f2f77b4c73bcba4125681d1d276f38624291820bd2", size = 12796482, upload-time = "2026-06-21T18:52:07.369Z" }, - { url = "https://files.pythonhosted.org/packages/88/1b/7acc273184d59b8e894d15ebe3cf1c5e81b3a822fde1792ea3e33be37a2e/zensical-0.0.46-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d9221264a9a87409900a47e29985607b0c9245dacb89077e87c8e16e31edc167", size = 12660030, upload-time = "2026-06-21T18:52:10.186Z" }, - { url = "https://files.pythonhosted.org/packages/80/df/bd0a68de98a19fc6050c58be11f36d05ea72a213b6a7ff7395d33c793747/zensical-0.0.46-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec43018d5343ca2e1d71aa352eeddd560fef504effd03025840a5a783abefa4f", size = 13057130, upload-time = "2026-06-21T18:52:12.911Z" }, - { url = "https://files.pythonhosted.org/packages/f4/db/e27635f5787a42245f900e658340698a6654e165d466f9a3b640efced2cd/zensical-0.0.46-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26e98fb8ab7ab50cdd20a73e2c7d4d9aae0b46cf2d8691e6bb22f9c261b8a60a", size = 13022345, upload-time = "2026-06-21T18:52:15.84Z" }, - { url = "https://files.pythonhosted.org/packages/e7/9d/6ce2ba11c97154870b458a8dae4637ade93b7097912f0102f5ea7fe8cf5b/zensical-0.0.46-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46fe578f26963f8ee89567983e62737b6fadc9197d4742e1020b522e092d7baa", size = 13377445, upload-time = "2026-06-21T18:52:18.538Z" }, - { url = "https://files.pythonhosted.org/packages/68/06/9930d43cd9d2f899b648d63491007c1b4f9716cf118b0c98e867b933069c/zensical-0.0.46-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aef03fa186a5589148e10b62610500989c6b075a2c08e1554233adbf91b2a3dc", size = 13086749, upload-time = "2026-06-21T18:52:21.452Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ed/2342cf860fbb02314938b0d1f1b02344935801b04d185ff3151ef1812898/zensical-0.0.46-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bc7446cdf97a8dea390f20ed2bd6b030cddc1bd36a8ce113ea3efef6fa61c573", size = 13231120, upload-time = "2026-06-21T18:52:24.171Z" }, - { url = "https://files.pythonhosted.org/packages/de/b0/d2ece02f63cd767fcf10fd7608dc8e0a995f87dc5261209b1dbc296fd57b/zensical-0.0.46-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:bbee37801f1ed500f158dc0992c569282950f780ae353c37fe6969f99983d701", size = 13295035, upload-time = "2026-06-21T18:52:26.942Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b2/cb0048a612e63e615399fc507472a557d1c5b7c2f74065c5bf11998fd597/zensical-0.0.46-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:9487c147c9cceb50c04d0ad70b024821a6eab1629dafd70ab6d1e86ec841e623", size = 13437191, upload-time = "2026-06-21T18:52:29.69Z" }, - { url = "https://files.pythonhosted.org/packages/91/16/515f81db8055b109a510063be481e60a657c4fad1a883680b2ee4aa9a424/zensical-0.0.46-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f42a4683c762f026878d19ede4bcf7bfbb84dbecb5ad923949abb77806ed88a5", size = 13369382, upload-time = "2026-06-21T18:52:32.521Z" }, - { url = "https://files.pythonhosted.org/packages/b9/5c/da54ee65b642eb7d88dd4a3db35845d0765915638e05d5d434a10b42f1c3/zensical-0.0.46-cp310-abi3-win32.whl", hash = "sha256:85f018f2a7ee76a83915c87ddb12b58cf343fd6154081d33ac95b6751b011dd7", size = 12354298, upload-time = "2026-06-21T18:52:34.976Z" }, - { url = "https://files.pythonhosted.org/packages/73/26/fc7ef081acbdada8436825221cb728ee84a81d4d78a7bb79aa58bd150d31/zensical-0.0.46-cp310-abi3-win_amd64.whl", hash = "sha256:1543a693a160de60e86ca589592401b584670e7e12c5ae30e3c2ba76786f7ec3", size = 12599687, upload-time = "2026-06-21T18:52:37.913Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/6810aec6e670451f39639039b570a43d90bc1d4a3b93cf13316ccf6bad11/zensical-0.0.56-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:5135ea3aa5d1358503fc1903e866c191873b138028bc1ab170abac3a4b537ffe", size = 12874712, upload-time = "2026-08-18T15:46:18.378Z" }, + { url = "https://files.pythonhosted.org/packages/97/98/23445d8ed708088dd6d9d51674f8836b77c53aab280360d7aa7a206bea8e/zensical-0.0.56-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a22ae2329ba755c6e58e1fe5967ca429d580d346a73cf467ad5185377e2cf809", size = 12764552, upload-time = "2026-08-18T15:46:20.746Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/ab89450728b0a55e6a52a86060b38a362a52a1b9f02eda7fef68b729eb2e/zensical-0.0.56-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03c70ef328e31cce0e31739acdd6679e9272bc7a3016ca5eafaa16dcfda460c9", size = 13212920, upload-time = "2026-08-18T15:46:22.977Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5a/969fd9a461204392a9266a544c185fbb223714b306534661dcbb7d290be7/zensical-0.0.56-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ffc50153a50292078357a5052e7a6b3e20a818523792ffcbeace70418b761eb", size = 13146652, upload-time = "2026-08-18T15:46:25.773Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/28a7c8dea8fe2edbba75a664efbde797b5922651ea92ffc480947ab22197/zensical-0.0.56-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88bba0e339d36647ce638a42b4ba96f2521b59ea54be74c314340be7e401f94b", size = 13530946, upload-time = "2026-08-18T15:46:28.19Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/d55a18c6e041b788af1d19e4d8c61ee9b8a7de5b9cc79ffa7bc9565e3a28/zensical-0.0.56-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b7a37f6ac38ac218e3e0dd311b58c468603963e38ee23f2e25672dcd6e9c17b", size = 13178741, upload-time = "2026-08-18T15:46:30.378Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/a07da2f761cfd6a27faf9e8d5a9475c396a9a0ca37424a0b67cab7ae4d2c/zensical-0.0.56-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f6d850bce3184422b37b9d98ef4d123047a47446844793251cabc04bd55f8f0", size = 13389836, upload-time = "2026-08-18T15:46:32.847Z" }, + { url = "https://files.pythonhosted.org/packages/34/aa/697ef9846b0e2071de4d03b0472e2658380ea9271bd01235f4ffaeef1978/zensical-0.0.56-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:d18944a4a111050b8f571e73d4e2475d7ce62ce78b64f09bcf33bb11cc346e1c", size = 13419551, upload-time = "2026-08-18T15:46:35.112Z" }, + { url = "https://files.pythonhosted.org/packages/97/6b/20e1b2443951d5182b3fc2ee54c200ea00c09bd8901a479be4147c94361b/zensical-0.0.56-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3937029ec5091d577c2a05ebccd38fde97fab28662d165ead66d566689b2a6df", size = 13579878, upload-time = "2026-08-18T15:46:37.381Z" }, + { url = "https://files.pythonhosted.org/packages/53/c7/9c3b400b8a7d78cc169b7a78d4f74a90f9114209034a604f04051f48037c/zensical-0.0.56-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:346a1cdbad157633d185f79039a97c8b4f8c2b40be7d7efd56d72622f40247b0", size = 13526142, upload-time = "2026-08-18T15:46:39.949Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f6/7a6f2a513054071e44a504d7157684f00ac5e5e5f741eadc78ab6c80642c/zensical-0.0.56-cp310-abi3-win32.whl", hash = "sha256:a06681046f74b5bdc506d22af8fcef505b2450e45538c7f01a5e028f4e50c6f7", size = 12433218, upload-time = "2026-08-18T15:46:42.329Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/5b497c75fb1fd5845f3ec2f0b11b5e01f18f14c297041cd2f20d30d8b6a4/zensical-0.0.56-cp310-abi3-win_amd64.whl", hash = "sha256:c557985f12d042c15dcb7d577543d81ceee9281f96d591d265310b673437a325", size = 12702823, upload-time = "2026-08-18T15:46:44.836Z" }, ] [[package]] diff --git a/zensical.toml b/zensical.toml deleted file mode 100644 index 7e5ca2c5db..0000000000 --- a/zensical.toml +++ /dev/null @@ -1,81 +0,0 @@ -[project] -site_name = "openpilot docs" -site_url = "https://docs.comma.ai" -repo_url = "https://github.com/commaai/openpilot/" - -docs_dir = "docs" -site_dir = "docs_site/" - -extra_css = ["stylesheets/extra.css"] - -nav = [ - { "What is openpilot?" = "index.md" }, - { "How-to" = [ - { "Turn the speed blue" = "how-to/turn-the-speed-blue.md" }, - { "Connect to a comma 3X or four" = "how-to/connect-to-comma.md" }, - { "Add support for a car" = "how-to/car-port.md" }, - ] }, - { "Concepts" = [ - { "Logs" = "concepts/logs.md" }, - { "Safety" = "concepts/safety.md" }, - { "Glossary" = "concepts/glossary.md" }, - ] }, - { "Contributing" = [ - { "Feedback" = "contributing/feedback.md" }, - { "Roadmap" = "contributing/roadmap.md" }, - { "Contributing Guide →" = "https://github.com/commaai/openpilot/blob/master/docs/CONTRIBUTING.md" }, - ] }, - { "Links" = [ - { "Blog →" = "https://blog.comma.ai" }, - { "Bounties →" = "https://comma.ai/bounties" }, - { "GitHub →" = "https://github.com/commaai" }, - { "Discord →" = "https://discord.comma.ai" }, - { "X →" = "https://x.com/comma_ai" }, - ] }, -] - -[project.theme] -logo = "assets/comma-logo.png" -features = [ - "navigation.expand", - "navigation.sections", - "navigation.instant", - "navigation.instant.prefetch", - "content.code.copy", - "content.action.edit", - "content.action.view", -] - -[[project.extra.social]] -icon = "fontawesome/brands/github" -link = "https://github.com/commaai" - -[[project.extra.social]] -icon = "fontawesome/brands/discord" -link = "https://discord.comma.ai" - -[[project.extra.social]] -icon = "fontawesome/brands/x-twitter" -link = "https://x.com/comma_ai" - -[project.markdown_extensions.attr_list] - -[project.markdown_extensions.admonition] - -[project.markdown_extensions.md_in_html] - -[project.markdown_extensions.pymdownx.highlight] -anchor_linenums = true -line_spans = "__span" -pygments_lang_class = true - -[project.markdown_extensions.pymdownx.inlinehilite] - -[project.markdown_extensions.pymdownx.magiclink] - -[project.markdown_extensions.pymdownx.superfences] -custom_fences = [{ name = "mermaid", class = "mermaid" }] - -[project.markdown_extensions.pymdownx.details] - -[project.markdown_extensions."ext.glossary"]