diff --git a/.gitattributes b/.gitattributes index 5cb404146d..300ca01979 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,10 @@ -* text=auto +* text=auto eol=lf # to move existing files into LFS: # git add --renormalize . *.onnx filter=lfs diff=lfs merge=lfs -text +openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text +openpilot/sunnypilot/modeld_v2/models/*.pkl filter=lfs diff=lfs merge=lfs -text *.svg filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text *.gif filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/build-all-tinygrad-models.yaml b/.github/workflows/build-all-tinygrad-models.yaml index acf907e0c6..8bad4c0069 100644 --- a/.github/workflows/build-all-tinygrad-models.yaml +++ b/.github/workflows/build-all-tinygrad-models.yaml @@ -41,6 +41,7 @@ jobs: with: path: sunnypilot submodules: recursive + fetch-depth: 1 - name: Get tinygrad_repo ref id: get-tinygrad-ref @@ -58,6 +59,7 @@ jobs: ref: gh-pages path: docs ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }} + fetch-depth: 1 - name: Get next JSON version to use (from GitHub docs repo) id: get-json diff --git a/.github/workflows/build-default-models.yaml b/.github/workflows/build-default-models.yaml index 4f22002ae2..01ebf0c977 100644 --- a/.github/workflows/build-default-models.yaml +++ b/.github/workflows/build-default-models.yaml @@ -46,8 +46,8 @@ jobs: 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" + ONNX_PATH="openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl" elif [ "${{ inputs.target }}" = "dm" ]; then ONNX_PATH="openpilot/selfdrive/modeld/models/dmonitoring_model.onnx" HF_DEFAULTS_PATH="models/defaults/dm" @@ -57,8 +57,9 @@ jobs: ONNX_PATH="openpilot/selfdrive/modeld/models/driving_supercombo.onnx" HF_DEFAULTS_PATH="models/defaults/small" fi + ONNX_REF="" + [ -n "$ONNX_PATH" ] && ONNX_REF=$(git log -1 --format='%H' -- "$ONNX_PATH") - 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" @@ -78,6 +79,8 @@ jobs: if: ${{ inputs.target == 'small' }} runs-on: [self-hosted, tici] env: + MODELS_DIR: openpilot/selfdrive/modeld/models + COMPILER: tinygrad_repo/examples/openpilot SMALL_ONNX: openpilot/selfdrive/modeld/models/driving_supercombo.onnx SMALL_PKL: openpilot/selfdrive/modeld/models/driving_tinygrad.pkl steps: @@ -85,9 +88,10 @@ jobs: with: submodules: recursive - - name: Pull ONNX via LFS + - name: Pull small ONNX via LFS run: git lfs pull -I "${{ env.SMALL_ONNX }}" + - name: Set environment variables run: | source /etc/profile @@ -103,38 +107,15 @@ jobs: source ${UV_PROJECT_ENVIRONMENT}/bin/activate PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable - - name: Compile small model with stock compiler + - name: Compile small model from ONNX 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') - " + env ${TG_FLAGS} python3 ${{ github.workspace }}/${{ env.COMPILER }}/compile_onnx.py \ + ${{ github.workspace }}/${{ env.SMALL_ONNX }} \ + ${{ github.workspace }}/${{ env.SMALL_PKL }} \ + --out-of-band --benchmark-runs 1 - name: Prepare output env: @@ -147,14 +128,16 @@ jobs: 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/" + cp "$MODELS_DIR/${PKL_BASE}" "$OUTPUT_DIR/" + + ONNX_SHA256=$(git cat-file blob "$(git ls-files -s "${{ github.workspace }}/${{ env.SMALL_ONNX }}" | awk '{print $2}')" | grep '^oid sha256:' | cut -d: -f2) 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 }}" + --upstream-branch "${{ needs.resolve.outputs.onnx_ref }}" \ + --onnx-sha256 "$ONNX_SHA256" echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt" @@ -181,15 +164,16 @@ jobs: if: ${{ inputs.target == 'big' }} runs-on: [self-hosted, chestnut] env: - BIG_ONNX: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + MODELS_DIR: openpilot/selfdrive/modeld/models + COMPILER: tinygrad_repo/examples/openpilot 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: Pull big PKL via LFS + run: git lfs pull -I "${{ env.BIG_PKL }}" - name: Set environment variables run: | @@ -222,38 +206,33 @@ jobs: raise RuntimeError('Chestnut PCIe link not ready after 10 attempts') " - - name: Compile big model with stock compiler + - name: Compile big warp run: | source ${UV_PROJECT_ENVIRONMENT}/bin/activate export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}" + CHESTNUT_TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32" + COMPILER="${{ github.workspace }}/tinygrad_repo/examples/openpilot" + MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models" 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') - " + for res in $(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}')"); do + CAM_W=$(echo $res | cut -d x -f1) + CAM_H=$(echo $res | cut -d x -f2) + STRIDE_INFO=$(python3 -c " + import sys + from openpilot.system.camerad.cameras.nv12_info import get_nv12_info + s,y,u,sz = get_nv12_info(int(sys.argv[1]), int(sys.argv[2])) + print(f'{s},{y},{u},{sz}') + " $CAM_W $CAM_H) + WARP_PKL="${MODELS_DIR}/big_driving_warp_${res}_tinygrad.pkl" + env ${CHESTNUT_TG_FLAGS} python3 "${COMPILER}/compile_warp.py" \ + --frame ${CAM_W},${CAM_H},${STRIDE_INFO} \ + --warp-to ${MODEL_SIZE} \ + --layout yuv420 \ + --frames 2 \ + --output "${WARP_PKL}" + done - name: Prepare output env: @@ -266,14 +245,18 @@ jobs: 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/" + cp "$MODELS_DIR/${PKL_BASE}" "$OUTPUT_DIR/" + + PKL_LFS_SHA256=$(git cat-file blob "$(git ls-files -s "${{ github.workspace }}/${{ env.BIG_PKL }}" | awk '{print $2}')" | grep '^oid sha256:' | cut -d: -f2) 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 }}" + --upstream-branch "${{ needs.resolve.outputs.onnx_ref }}" \ + --onnx-sha256 "$PKL_LFS_SHA256" + + cp "$MODELS_DIR"/big_driving_warp_* "$OUTPUT_DIR/" || true echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt" @@ -311,7 +294,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Pull ONNX via LFS + - name: Pull LFS for resolved ONNX files + if: ${{ needs.resolve.outputs.onnx_path != '' }} run: git lfs pull -I "${{ needs.resolve.outputs.onnx_path }}" - name: Install huggingface_hub @@ -376,38 +360,28 @@ jobs: 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() - }) + matches = sorted(dm_dir.glob('dmonitoring_*_tinygrad.pkl')) + assert len(matches) == 1, f'expected one DM pkl in {dm_dir}, found {matches}' + pkl_name = matches[0].name 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) + with open(dm_dir / pkl_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 }}', + 'tinygrad_ref': '${{ needs.resolve.outputs.tinygrad_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 + 'download_uri': {'url': '', 'sha256': digest.hexdigest()} } }] }] @@ -466,23 +440,10 @@ jobs: 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 }}/tinygrad_repo/examples/openpilot/compile_onnx.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') - " + ${{ github.workspace }}/${{ env.DM_PKL }} \ + --out-of-band --benchmark-runs 1 - name: Compile DM warp run: | @@ -490,22 +451,33 @@ jobs: 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" - MODEL_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld" + COMPILER="${{ github.workspace }}/tinygrad_repo/examples/openpilot" + MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models" DM_SIZE=$(python3 -c "from openpilot.common.transformations.model import DM_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')") for res in $(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}')"); do - WARP_PKL="${MODEL_DIR}/models/dm_warp_${res}_tinygrad.pkl" - taskset -c 7 env ${TG_FLAGS} python3 ${MODEL_DIR}/compile_dm_warp.py \ - --camera-resolution ${res} \ + CAM_W=$(echo $res | cut -d x -f1) + CAM_H=$(echo $res | cut -d x -f2) + STRIDE_INFO=$(python3 -c " + import sys + from openpilot.system.camerad.cameras.nv12_info import get_nv12_info + s,y,u,sz = get_nv12_info(int(sys.argv[1]), int(sys.argv[2])) + print(f'{s},{y},{u},{sz}') + " $CAM_W $CAM_H) + WARP_PKL="${MODELS_DIR}/dm_warp_${res}_tinygrad.pkl" + taskset -c 7 env ${TG_FLAGS} python3 "${COMPILER}/compile_warp.py" \ + --frame ${CAM_W},${CAM_H},${STRIDE_INFO} \ --warp-to ${DM_SIZE} \ - --output ${WARP_PKL} + --layout luma \ + --border-fill 16 \ + --transform-device NPY \ + --output "${WARP_PKL}" done - 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/ + cp ${{ github.workspace }}/${{ env.DM_PKL }} dm_output/ cp ${{ github.workspace }}/openpilot/selfdrive/modeld/models/dm_warp_* dm_output/ - name: Upload DM artifact @@ -519,4 +491,3 @@ jobs: 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 eb4b3d0aab..077077a714 100644 --- a/.github/workflows/build-single-tinygrad-model.yaml +++ b/.github/workflows/build-single-tinygrad-model.yaml @@ -134,12 +134,10 @@ jobs: publish_model: if: ${{ !cancelled() && needs.build_model.result == 'success' }} - concurrency: - group: hf-push-${{ inputs.recompiled_dir }} - cancel-in-progress: false needs: build_model runs-on: ubuntu-latest permissions: + actions: read id-token: write contents: write steps: @@ -182,18 +180,21 @@ jobs: echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT - name: Download and extract model artifact - uses: actions/download-artifact@v4 - with: - name: ${{ steps.read-artifact-name.outputs.artifact_name }} - path: output + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }} + run: | + id=$(gh api -X GET "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" -f name="$ARTIFACT_NAME" --jq '.artifacts[0].id') + url=$(curl -sf -o /dev/null -w '%{redirect_url}' -H "Authorization: Bearer $GH_TOKEN" "https://api.github.com/repos/$GITHUB_REPOSITORY/actions/artifacts/$id/zip") + sudo apt-get install -y -qq aria2 > /dev/null + aria2c -q -x16 -s16 -k8M --max-tries=5 --file-allocation=none -o model.zip "$url" && unzip -q model.zip -d output && rm model.zip - name: Create models folder env: ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }} run: | 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" + cp output/metadata.json "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/" - name: Upload to Hugging Face env: @@ -205,13 +206,12 @@ jobs: "models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/" \ --repo-type=dataset - - name: Pull gh-pages + - name: Update and push JSON to docs repo run: | cd docs - git pull origin gh-pages + git config --global user.name "GitHub Action" + git config --global user.email "action@github.com" - - name: Run json_parser.py to update JSON - run: | FOLDER="${{ inputs.model_folder }}" if [ "$FOLDER" = "Other" ]; then FOLDER="${{ inputs.custom_model_folder }}" @@ -224,18 +224,27 @@ jobs: [ -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 "local_models/$RECOMPILED_DIR" \ - --sort-by-date \ - $ARGS - - name: Push updated JSON to GitHub docs repo - 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 + for attempt in $(seq 1 10); do + git fetch origin gh-pages + git reset --hard origin/gh-pages + + eval python3 json_parser.py \ + --json-path "docs/$(basename $JSON_FILE)" \ + --recompiled-dir "../local_models/$RECOMPILED_DIR" \ + --sort-by-date \ + $ARGS + + git add docs/"$(basename $JSON_FILE)" + git commit -m "Update $(basename $JSON_FILE) after recompiling model" || { echo "No changes to commit"; exit 0; } + + if git push origin gh-pages; then + echo "Push succeeded on attempt $attempt" + exit 0 + fi + + echo "Push failed (attempt $attempt/10), retrying after backoff..." + sleep $((RANDOM % 10 + 3)) + done + echo "All 10 push attempts failed" + exit 1 diff --git a/.github/workflows/compile-warp-kernels.yaml b/.github/workflows/compile-warp-kernels.yaml new file mode 100644 index 0000000000..89205b622f --- /dev/null +++ b/.github/workflows/compile-warp-kernels.yaml @@ -0,0 +1,80 @@ +name: Compile warp for sunnypilot modeld + +on: + workflow_dispatch: + +jobs: + compile_warps: + runs-on: [self-hosted, chestnut] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set environment variables + run: | + source /etc/profile + export UV_PROJECT_ENVIRONMENT=${HOME}/venv + export UV_PYTHON_PREFERENCE=managed + export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python + export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT + uv sync --frozen + printenv >> $GITHUB_ENV + + - name: Disable powersave + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable + + - name: Compile Warp Kernels + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}" + mkdir -p openpilot/sunnypilot/modeld_v2/models/ + 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}')") + echo "Model size: $MODEL_SIZE" + echo "Camera resolutions: $CAMERA_RES" + + for res in $CAMERA_RES; do + FRAME_PARAM=$(python3 -c "from openpilot.system.camerad.cameras.nv12_info import get_nv12_info; w, h = map(int, '${res}'.split('x')); stride, y_h, uv_h, _ = get_nv12_info(w, h); print(f'{w},{h},{stride},{y_h},{uv_h},{stride * (y_h + uv_h)}')") + + WARP_PKL="openpilot/sunnypilot/modeld_v2/models/driving_warp_${res}_tinygrad.pkl" + echo "Compiling $WARP_PKL on QCOM" + taskset -c 7 env DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1 python3 openpilot/sunnypilot/modeld_v2/compile_warp.py \ + --frame ${FRAME_PARAM} --warp-to ${MODEL_SIZE} --output "${WARP_PKL}" + + BIG_WARP_PKL="openpilot/sunnypilot/modeld_v2/models/big_driving_warp_${res}_tinygrad.pkl" + echo "Compiling $BIG_WARP_PKL on AMD" + taskset -c 7 env DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32 python3 openpilot/sunnypilot/modeld_v2/compile_warp.py \ + --frame ${FRAME_PARAM} --warp-to ${MODEL_SIZE} --output "${BIG_WARP_PKL}" + done + + - name: Re-enable powersave + if: always() + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable + + - name: Create Pull Request + uses: peter-evans/create-pull-request@9153d834b60caba6d51c9b9510b087acf9f33f83 + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: credential.https://huggingface.co.helper + GIT_CONFIG_VALUE_0: '!f() { test "$1" = get && echo username=user && echo "password=$HF_TOKEN"; }; f' + with: + author: github-actions[bot] + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "[bot] Update warp pkl for modeld_v2" + title: "[bot] Update modeld_v2 Warp" + branch: "auto/compile-warp-kernels" + base: "master" + delete-branch: true + labels: bot + add-paths: | + openpilot/sunnypilot/modeld_v2/models/*.pkl + + - name: Reset lfs access + if: always() + run: git config --local --unset-all "lfs.$(git config -f .lfsconfig lfs.url).access" || true diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 5c1a8f4dde..a114e9db0b 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -18,24 +18,25 @@ concurrency: env: GIT_CONFIG_COUNT: 1 GIT_CONFIG_KEY_0: lfs.fetchexclude - GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl jobs: docs: name: build docs - runs-on: ubuntu-24.04 + runs-on: ${{ + (github.repository == 'commaai/openpilot') && + ((github.event_name != 'pull_request') || + (github.event.pull_request.head.repo.full_name == 'commaai/openpilot')) + && fromJSON('["namespace-profile-amd64-8x16"]') + || fromJSON('["ubuntu-24.04"]') }} steps: - uses: commaai/timeout@v1 - - uses: actions/checkout@v7 - with: - submodules: true + - run: ./tools/op.sh setup # Build - name: Build docs - run: | - git lfs pull - python docs/serve.py --build + run: ./tools/op.sh docs --build # Push to docs.comma.ai - uses: actions/checkout@v7 diff --git a/.github/workflows/download-hf-model-chunks/action.yml b/.github/workflows/download-hf-model/action.yml similarity index 51% rename from .github/workflows/download-hf-model-chunks/action.yml rename to .github/workflows/download-hf-model/action.yml index 677385f1d6..863d81c89f 100644 --- a/.github/workflows/download-hf-model-chunks/action.yml +++ b/.github/workflows/download-hf-model/action.yml @@ -1,5 +1,5 @@ -name: Download HF model chunks -description: Resolve and download model chunks from HuggingFace in parallel +name: Download HF model +description: Resolve and download models from HuggingFace in parallel inputs: hf_repo: @@ -11,16 +11,21 @@ inputs: dest_dir: description: Destination directory for downloaded chunks required: true + tinygrad_ref: + description: 'Tinygrad ref to match bundle against' + required: false + default: '' runs: using: composite steps: - - name: Download model chunks + - name: Download models shell: bash env: HF_REPO: ${{ inputs.hf_repo }} MODELS_JSON: ${{ inputs.models }} DEST_DIR: ${{ inputs.dest_dir }} + TINYGRAD_REF: ${{ inputs.tinygrad_ref }} run: | set -eo pipefail DOWNLOAD_LIST=$(mktemp) @@ -28,30 +33,41 @@ runs: 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 + local DEFAULTS BUNDLE ARTIFACT FILE_URL BASE_URL DEFAULTS=$(curl -fsSL "$JSON_URL") - BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)') + if [ -n "$TINYGRAD_REF" ]; then + BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" --arg tg "$TINYGRAD_REF" ' + . as $root | [.bundles[] | select(.onnx_sha256 == $hash and (.tinygrad_ref == $tg or (.tinygrad_ref == null and $root.tinygrad_ref == $tg)))] | .[0] // empty + ') + else + BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" ' + [.bundles[] | select(.onnx_sha256 == $hash)] | .[0] // empty + ') + fi + + if [ -z "$BUNDLE" ] || [ "$BUNDLE" = "null" ]; then + echo "::error::No model bundle found matching ONNX hash $ONNX_HASH (tinygrad ref: $TINYGRAD_REF) in $HF_PATH" + return 1 + fi + 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') + FILE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url') + BASE_URL=$(echo "$FILE_URL" | sed 's|/[^/]*$||') 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" + ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${FILE_URL}', safe=':/'))") + printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${CANONICAL}" >> "$DOWNLOAD_LIST" if [ "$CANONICAL" = "dmonitoring_model_tinygrad.pkl" ]; then for warp in dm_warp_1928x1208_tinygrad.pkl dm_warp_1344x760_tinygrad.pkl; do ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${warp}', safe=':/'))") printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${warp}" >> "$DOWNLOAD_LIST" done + elif [ "$CANONICAL" = "big_driving_tinygrad.pkl" ]; then + for warp in big_driving_warp_1928x1208_tinygrad.pkl big_driving_warp_1344x760_tinygrad.pkl; do + ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${warp}', safe=':/'))") + printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${warp}" >> "$DOWNLOAD_LIST" + done fi } @@ -63,7 +79,7 @@ runs: done TOTAL=$(wc -l < "$DOWNLOAD_LIST") - echo "Downloading $TOTAL chunks with 8 parallel connections..." + echo "Downloading $TOTAL files with 8 parallel connections..." xargs -P8 -d'\n' -I{} bash -c ' URL="${1%% *}" DEST="${1#* }" diff --git a/.github/workflows/lfs-maintenance.yaml b/.github/workflows/lfs-maintenance.yaml index 3b92996f82..4c98c24722 100644 --- a/.github/workflows/lfs-maintenance.yaml +++ b/.github/workflows/lfs-maintenance.yaml @@ -1,8 +1,8 @@ name: Sync comma's LFS env: - LFS_URL: 'https://gitlab.com/sunnypilot/public/sunnypilot-new-lfs.git/info/lfs' - LFS_PUSH_URL: 'ssh://git@gitlab.com/sunnypilot/public/sunnypilot-new-lfs.git' + LFS_URL: 'https://huggingface.co/sunnypilot/sunnypilot-lfs.git/info/lfs' + LFS_PUSH_URL: 'https://huggingface.co/sunnypilot/sunnypilot-lfs.git/info/lfs' on: schedule: @@ -33,21 +33,19 @@ jobs: - name: LFS Fetch run: | - git lfs fetch + git lfs fetch || echo "::warning::some objects are missing on commaai/openpilot-lfs; mirroring the rest" - name: Set up Git run: | git config --global user.name 'GitHub Action' git config --global user.email 'action@github.com' - - name: Set up SSH - uses: webfactory/ssh-agent@v0.9.0 - with: - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - - name: Add GitLab public keys + - name: Setup hf + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + git config --global credential.helper store + echo "https://user:${HF_TOKEN}@huggingface.co" > ~/.git-credentials - name: Ensure branch run: | @@ -70,6 +68,7 @@ jobs: git config lfs.activitytimeout 300 git config lfs.transfer.maxretries 5 git config lfs.concurrenttransfers 4 + git config lfs.allowincompletepush true - name: Push LFS id: sync-and-commit @@ -83,3 +82,80 @@ jobs: done echo "All push attempts failed" exit 1 + + - name: Checkout sunnypilot + uses: actions/checkout@v4 + with: + repository: 'sunnypilot/sunnypilot' + path: sunnypilot + fetch-depth: 0 + filter: blob:limit=1k + lfs: false + + - name: Reference objects on sunnypilot-lfs + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + set -euo pipefail + GIT_LFS_SKIP_SMUDGE=1 git clone --quiet https://huggingface.co/sunnypilot/sunnypilot-lfs hf-lfs + mkdir -p hf-lfs/all + cat > scan_pointers.py <<'EOF' + import re, subprocess, sys, threading + repo = sys.argv[1] + chk = subprocess.run(["git", "-C", repo, "cat-file", "--batch-all-objects", + "--batch-check=%(objectname) %(objecttype) %(objectsize)"], + capture_output=True, text=True, check=True).stdout + small = [f[0] for f in (l.split() for l in chk.splitlines()) if f[1] == "blob" and int(f[2]) < 1024] + p = subprocess.Popen(["git", "-C", repo, "cat-file", "--batch=%(objectsize)"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE) + def feed(): + for sha in small: + p.stdin.write(sha.encode() + b"\n") + p.stdin.close() + threading.Thread(target=feed, daemon=True).start() + rx = re.compile(rb"^version https://git-lfs\.github\.com/spec/v1\noid sha256:([0-9a-f]{64})\nsize ([0-9]+)\n") + seen = {} + for _ in small: + n = int(p.stdout.readline()); body = p.stdout.read(n); p.stdout.read(1) + m = rx.match(body) + if m: + seen.setdefault(m.group(1).decode(), m.group(2).decode()) + for oid, size in sorted(seen.items()): + print(oid, size) + EOF + for repo in . sunnypilot; do + python3 scan_pointers.py "$repo" + done | sort -u | while read -r oid size; do + [ -e "hf-lfs/all/$oid" ] && continue + printf 'version https://git-lfs.github.com/spec/v1\noid sha256:%s\nsize %s\n' "$oid" "$size" > "hf-lfs/all/$oid" + echo "$oid" + done > new_oids.txt + echo "new pointer files: $(wc -l < new_oids.txt)" + + dropped=0 + while mapfile -t -n 100 chunk && ((${#chunk[@]})); do + body=$(for oid in "${chunk[@]}"; do + size=$(awk '/^size /{print $2}' "hf-lfs/all/$oid") + printf '{"oid":"%s","size":%s}\n' "$oid" "$size" + done | jq -s '{operation:"download",transfers:["basic"],objects:.}') + for attempt in 1 2 3 4 5; do + resp=$(curl -sS -u "user:${HF_TOKEN}" -w '\n%{http_code}' \ + -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' \ + --data "$body" "${LFS_URL}/objects/batch") && [ "$(tail -n1 <<<"$resp")" = 200 ] && break + echo "batch check attempt $attempt failed ($(tail -n1 <<<"$resp")), retrying in 60s"; sleep 60 + done + [ "$(tail -n1 <<<"$resp")" = 200 ] || { echo "batch check failed"; exit 1; } + for oid in $(sed '$d' <<<"$resp" | jq -r '.objects[] | select(.error) | .oid'); do + rm -f "hf-lfs/all/$oid"; dropped=$((dropped+1)) + done + done < new_oids.txt + echo "dropped (not on HF): $dropped" + + cd hf-lfs + git add -A all + if git diff --cached --quiet; then + echo "nothing new to reference" + exit 0 + fi + git commit -q -m "Reference $(git diff --cached --name-only | wc -l | tr -d ' ') new LFS objects" + git push origin HEAD:main diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2240713bcb..9406985f52 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -7,7 +7,7 @@ on: env: GIT_CONFIG_COUNT: 1 GIT_CONFIG_KEY_0: lfs.fetchexclude - GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl jobs: build___nightly: @@ -34,3 +34,7 @@ jobs: - run: ./tools/op.sh setup - name: Push __nightly run: BRANCH=__nightly tools/release/build_stripped.sh + - name: Push chestnut nightly + run: | + git lfs pull --exclude='' + INCLUDE_BIG_MODEL=1 BRANCH=__nightly-chestnut tools/release/build_stripped.sh diff --git a/.github/workflows/repo-maintenance.yaml b/.github/workflows/repo-maintenance.yaml index 731300512b..103c7974b9 100644 --- a/.github/workflows/repo-maintenance.yaml +++ b/.github/workflows/repo-maintenance.yaml @@ -11,7 +11,7 @@ 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 + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl jobs: package_updates: diff --git a/.github/workflows/sunnypilot-build-model.yaml b/.github/workflows/sunnypilot-build-model.yaml index 837c53be69..8276c40136 100644 --- a/.github/workflows/sunnypilot-build-model.yaml +++ b/.github/workflows/sunnypilot-build-model.yaml @@ -100,24 +100,56 @@ jobs: commit_date=$(git log -1 --format=%cd --date=format:'%B %d, %Y') echo "model_date=${commit_date}" >> $GITHUB_OUTPUT cat $GITHUB_OUTPUT - - run: | + + - name: Fetch Models + run: | cd ${{ github.workspace }}/openpilot/openpilot + fetch_lfs() { + sudo apt-get install -y -qq aria2 > /dev/null # Parallel ranges because gh is flaky + LFS_URL=$(git config -f "$(git rev-parse --show-toplevel)/.lfsconfig" lfs.url) + for f in "$@"; do + grep -qs "^version https://git-lfs" "$f" || continue + OID=$(awk -F'sha256:' '/^oid/{print $2}' "$f"); SIZE=$(awk '/^size/{print $2}' "$f") + HREF=$(curl -fsSL --retry 3 -X POST -H "Accept: application/vnd.git-lfs+json" -H "Content-Type: application/vnd.git-lfs+json" "$LFS_URL/objects/batch" \ + -d "{\"operation\":\"download\",\"transfer\":[\"basic\"],\"objects\":[{\"oid\":\"$OID\",\"size\":$SIZE}]}" | jq -r '.objects[0].actions.download.href') + aria2c -x 16 -s 16 -k 16M --max-tries=5 --retry-wait=5 --file-allocation=none --summary-interval=30 --allow-overwrite=true \ + -d "$(dirname "$f")" -o "$(basename "$f")" "$HREF" + echo "$OID $f" | sha256sum -c - + done + } + UPSTREAM_PKL=selfdrive/modeld/models/big_driving_tinygrad.pkl 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 + git lfs pull -X "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/dmonitoring_*.onnx,**/selfdrive/modeld/models/big_*.pkl,**/selfdrive/modeld/models/dmonitoring_*.pkl" + rm -f selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/dmonitoring_*.onnx selfdrive/modeld/models/big_*.pkl selfdrive/modeld/models/dmonitoring_*.pkl + elif compgen -G "selfdrive/modeld/models/big_*.onnx" > /dev/null; then + fetch_lfs selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/big_*.pkl + find selfdrive/modeld/models -type f \( -name "*.onnx" -o -name "*.pkl" \) ! -name "big_*.onnx" ! -name "big_*.pkl" -delete + elif [ -f "$UPSTREAM_PKL" ]; then + echo "No big model ONNX tracked: converting commaai's precompiled $UPSTREAM_PKL, with the tinygrad it was built with" + fetch_lfs "$UPSTREAM_PKL" + find selfdrive/modeld/models -type f \( -name "*.onnx" -o -name "*.pkl" \) ! -path "$UPSTREAM_PKL" -delete + GITMODULES="$(git rev-parse --show-toplevel)/.gitmodules" + TG_NAME=$(git config -f "$GITMODULES" --get-regexp '^submodule\..*\.path$' | awk '$2 == "tinygrad_repo" {sub(/^submodule\./, "", $1); sub(/\.path$/, "", $1); print $1}') + TG_URL=$(git config -f "$GITMODULES" "submodule.$TG_NAME.url" | sed 's|^\.\./\.\./|https://github.com/|') + echo "$TG_URL $(git rev-parse HEAD:tinygrad_repo) $(sha256sum "$UPSTREAM_PKL" | cut -d' ' -f1)" > selfdrive/modeld/models/upstream_pkl.txt else - git lfs pull -I "**/selfdrive/modeld/models/big_*.onnx" -X "" - find selfdrive/modeld/models -name "*.onnx" ! -name "big_*.onnx" -delete + echo "::error::no big model ONNX or precompiled big_driving_tinygrad.pkl at this ref" + exit 1 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" + + if grep -lIF "version https://git-lfs.github.com/spec/v1" selfdrive/modeld/models/*.onnx selfdrive/modeld/models/*.pkl 2>/dev/null; then + echo "::error::the ONNX or PKL 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 + compression-level: 0 + path: | + ${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.onnx + ${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.pkl + ${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/upstream_pkl.txt if-no-files-found: error build_model: @@ -132,7 +164,6 @@ jobs: fetch-depth: 1 submodules: recursive - - name: Set environment variables id: set-env run: | @@ -144,9 +175,6 @@ jobs: export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT uv sync --frozen printenv >> $GITHUB_ENV - if [[ "${{ runner.debug }}" == "1" ]]; then - cat $GITHUB_OUTPUT - fi - name: Setup build environment run: | @@ -158,10 +186,6 @@ jobs: echo "VERSION: ${{ steps.set-env.outputs.version }}" echo "UV_PROJECT_ENVIRONMENT: ${UV_PROJECT_ENVIRONMENT}" echo "VIRTUAL_ENV: ${VIRTUAL_ENV}" - echo "-------" - if [[ "${{ runner.debug }}" == "1" ]]; then - printenv - fi source ${UV_PROJECT_ENVIRONMENT}/bin/activate PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable rm -rf ${{ env.MODELS_DIR }}/*.onnx* @@ -184,11 +208,11 @@ jobs: 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_QCOM="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 OPENPILOT_HACKS=1" if [ "${{ inputs.target_hardware }}" == "chestnut" ]; then echo "CHESTNUT build" export CHESTNUT=1 - TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_OCCUPANCY_OPT=1" + TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32" OUTPUT_PKL="${{ env.MODELS_DIR }}/big_driving_tinygrad.pkl" else echo "QCOM build" @@ -196,64 +220,77 @@ jobs: 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_QCOM} python3 "${{ env.MODELS_DIR }}/../get_model_metadata.py" "$onnx_file" || true - done + NATIVE_PKL=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "*.pkl" -print -quit) - # Detect model type and build compile args - 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 + if [ -f "${{ env.MODELS_DIR }}/upstream_pkl.txt" ]; then + read -r TG_URL TG_SHA _ < "${{ env.MODELS_DIR }}/upstream_pkl.txt" + echo "Converting commaai's precompiled model with tinygrad $TG_SHA" + UPSTREAM_TG="${{ runner.temp }}/upstream_tinygrad" + rm -rf "$UPSTREAM_TG" && git init -q "$UPSTREAM_TG" && git -C "$UPSTREAM_TG" fetch -q --depth 1 "$TG_URL" "$TG_SHA" && git -C "$UPSTREAM_TG" checkout -q FETCH_HEAD + CONVERTED="${{ runner.temp }}/big_driving_tinygrad_plain.pkl" + env DEV=USB+AMD PYTHONPATH="$UPSTREAM_TG:${{ github.workspace }}" taskset -c 7 python3 openpilot/sunnypilot/modeld_v2/convert_upstream_pkl.py \ + "${{ env.MODELS_DIR }}/big_driving_tinygrad.pkl" "$CONVERTED" + mv "$CONVERTED" "$OUTPUT_PKL" + elif NEW_ARCH_ONNX=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "${{ inputs.target_hardware == 'chestnut' && 'big_' || '' }}*driving*.onnx" -print -quit) && + [ -n "$NEW_ARCH_ONNX" ] && grep -qa new_img "$NEW_ARCH_ONNX"; then # the new architecture: one ONNX taking the warped frames as new_img + echo "Compiling new architecture ONNX: $NEW_ARCH_ONNX" + env ${TG_FLAGS} taskset -c 7 python3 openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py \ + "$NEW_ARCH_ONNX" "$OUTPUT_PKL" --device-input "*" --benchmark-runs 1 + else + echo "Detected onnx architecture. Routing to native compile_modeld.py." + # Detect model type and build compile args + 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 + 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 + 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 + 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 + 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 - ONNX_ARGS="--vision-onnx $VISION_ONNX" - if [ -f "$ON_POLICY_ONNX" ] && [ -f "$OFF_POLICY_ONNX" ]; then - MODEL_TYPE=vision_multi_policy - ONNX_ARGS="$ONNX_ARGS --off-policy-onnx $OFF_POLICY_ONNX --on-policy-onnx $ON_POLICY_ONNX" - elif [ -f "$OFF_POLICY_ONNX" ] && [ -f "$POLICY_ONNX" ]; then - MODEL_TYPE=vision_multi_policy - ONNX_ARGS="$ONNX_ARGS --policy-onnx $POLICY_ONNX --off-policy-onnx $OFF_POLICY_ONNX" - elif [ -f "$POLICY_ONNX" ]; then - MODEL_TYPE=vision_policy - ONNX_ARGS="$ONNX_ARGS --policy-onnx $POLICY_ONNX" + MODEL_TYPE="" ONNX_ARGS="" OUTPUT_NAME="" + if [ -f "$VISION_ONNX" ]; then + ONNX_ARGS="--vision-onnx $VISION_ONNX" + if [ -f "$ON_POLICY_ONNX" ] && [ -f "$OFF_POLICY_ONNX" ]; then + MODEL_TYPE=vision_multi_policy + ONNX_ARGS="$ONNX_ARGS --off-policy-onnx $OFF_POLICY_ONNX --on-policy-onnx $ON_POLICY_ONNX" + elif [ -f "$OFF_POLICY_ONNX" ] && [ -f "$POLICY_ONNX" ]; then + MODEL_TYPE=vision_multi_policy + ONNX_ARGS="$ONNX_ARGS --policy-onnx $POLICY_ONNX --off-policy-onnx $OFF_POLICY_ONNX" + elif [ -f "$POLICY_ONNX" ]; then + MODEL_TYPE=vision_policy + ONNX_ARGS="$ONNX_ARGS --policy-onnx $POLICY_ONNX" + fi + elif [ -f "$SUPERCOMBO_ONNX" ]; then + MODEL_TYPE=supercombo + ONNX_ARGS="--supercombo-onnx $SUPERCOMBO_ONNX" fi - elif [ -f "$SUPERCOMBO_ONNX" ]; then - MODEL_TYPE=supercombo - ONNX_ARGS="--supercombo-onnx $SUPERCOMBO_ONNX" - fi - if [ -n "$MODEL_TYPE" ]; then - 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 "$OUTPUT_PKL" + if [ -n "$MODEL_TYPE" ]; then + echo "Detected: $MODEL_TYPE -> $OUTPUT_PKL" + env ${TG_FLAGS} taskset -c 7 python3 "$COMPILE_MODELD" \ + --model-type $MODEL_TYPE \ + --model-size $MODEL_SIZE \ + --camera-resolutions $CAMERA_RES \ + $ONNX_ARGS \ + --output "$OUTPUT_PKL" + fi fi - name: Prepare Output @@ -264,8 +301,7 @@ jobs: # Copy the model files rsync -avm \ --include='*.dlc' \ - --include='*.chunk*' \ - --include='*.chunkmanifest' \ + --include='/${{ inputs.target_hardware == 'chestnut' && 'big_' || '' }}driving_tinygrad.pkl' \ --exclude='*' \ --delete-excluded \ --chown=comma:comma \ @@ -276,6 +312,7 @@ jobs: --output-dir "${{ env.OUTPUT_DIR }}" \ --custom-name "${{ env.MODEL_NAME }}" \ --upstream-branch "${{ inputs.upstream_branch }}" \ + $([ -f "${{ env.MODELS_DIR }}/upstream_pkl.txt" ] && echo "--onnx-sha256 $(cut -d' ' -f3 "${{ env.MODELS_DIR }}/upstream_pkl.txt")") \ ${{ inputs.is_20hz && '--is-20hz' || '' }} - name: Write artifact name to file @@ -287,6 +324,7 @@ jobs: with: name: model-${{ env.MODEL_NAME }}${{ inputs.artifact_suffix }}-${{ github.run_number }} path: ${{ env.OUTPUT_DIR }} + compression-level: 0 - name: Upload artifact name file uses: actions/upload-artifact@v4 diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index dfcc7068e2..29e03a8437 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -170,6 +170,9 @@ jobs: scons -j1 cache_dir="$SCONS_CACHE" --minimal \ openpilot/selfdrive/locationd openpilot/sunnypilot/selfdrive/locationd echo "Building rest of sunnypilot" + sudo rm -rf /tmp/* || true + mkdir -p "$BUILD_DIR/tmp" + export TMPDIR="$BUILD_DIR/tmp" 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 @@ -221,37 +224,40 @@ jobs: cancel-in-progress: false outputs: onnx_sha256: ${{ steps.resolve.outputs.onnx_sha256 }} + tinygrad_ref: ${{ steps.resolve.outputs.tinygrad_ref }} 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 + - name: Resolve tinygrad ref via API id: resolve run: | REF="${{ github.head_ref || github.ref_name }}" - BLOB_SHA=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx?ref=${REF}" --jq '.sha') + BLOB_SHA=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl?ref=${REF}" --jq '.sha') + [ -n "$BLOB_SHA" ] || { echo "::error::Failed to resolve big_driving_tinygrad.pkl blob SHA"; exit 1; } ONNX_HASH=$(gh api "repos/${GH_REPO}/git/blobs/${BLOB_SHA}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2) - echo "ONNX hash: $ONNX_HASH" - [ -n "$ONNX_HASH" ] || { echo "::error::Failed to extract ONNX hash"; exit 1; } - echo "onnx_sha256=$ONNX_HASH" >> $GITHUB_OUTPUT + echo "big PKL hash: $ONNX_HASH" + [ -n "$ONNX_HASH" ] || { echo "::error::Failed to extract big PKL hash"; exit 1; } + 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" + echo "tinygrad_ref=$TINYGRAD_REF" >> "$GITHUB_OUTPUT" JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" check_defaults() { DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 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) + BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" --arg ref "$TINYGRAD_REF" ' + . as $root | [.bundles[] | select(.onnx_sha256 == $hash and (.tinygrad_ref == $ref or (.tinygrad_ref == null and $root.tinygrad_ref == $ref)))] | .[0] // empty + ' 2>/dev/null) [ -n "$BUNDLE" ] && [ "$BUNDLE" != "null" ] } if check_defaults; then - echo "HF defaults match repo ONNX hash and tinygrad ref" + echo "HF defaults match repo tinygrad ref" exit 0 fi @@ -304,12 +310,13 @@ jobs: cancel-in-progress: false outputs: driving_onnx_sha256: ${{ steps.resolve.outputs.driving_onnx_sha256 }} + tinygrad_ref: ${{ steps.resolve.outputs.tinygrad_ref }} 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 + - name: Resolve tinygrad ref via API id: resolve run: | REF="${{ github.head_ref || github.ref_name }}" @@ -318,23 +325,24 @@ jobs: DRIVING_HASH=$(gh api "repos/${GH_REPO}/git/blobs/${BLOB_SHA}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2) echo "Driving ONNX hash: $DRIVING_HASH" [ -n "$DRIVING_HASH" ] || { echo "::error::Failed to extract driving ONNX hash"; exit 1; } - echo "driving_onnx_sha256=$DRIVING_HASH" >> $GITHUB_OUTPUT + 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" + echo "tinygrad_ref=$TINYGRAD_REF" >> "$GITHUB_OUTPUT" JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" check_defaults() { DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 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 + DRIVING=$(echo "$DEFAULTS" | jq --arg hash "$DRIVING_HASH" --arg ref "$TINYGRAD_REF" ' + . as $root | [.bundles[] | select(.onnx_sha256 == $hash and (.tinygrad_ref == $ref or (.tinygrad_ref == null and $root.tinygrad_ref == $ref)))] | .[0] // empty + ' 2>/dev/null) + [ -n "$DRIVING" ] && [ "$DRIVING" != "null" ] } if check_defaults; then - echo "HF defaults match repo ONNX hash and tinygrad ref" + echo "HF defaults match repo tinygrad ref" exit 0 fi @@ -387,12 +395,13 @@ jobs: cancel-in-progress: false outputs: dm_onnx_sha256: ${{ steps.resolve.outputs.dm_onnx_sha256 }} + tinygrad_ref: ${{ steps.resolve.outputs.tinygrad_ref }} 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 + - name: Resolve tinygrad ref via API id: resolve run: | REF="${{ github.head_ref || github.ref_name }}" @@ -401,23 +410,24 @@ jobs: DM_HASH=$(gh api "repos/${GH_REPO}/git/blobs/${BLOB_SHA}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2) echo "DM ONNX hash: $DM_HASH" [ -n "$DM_HASH" ] || { echo "::error::Failed to extract DM ONNX hash"; exit 1; } - echo "dm_onnx_sha256=$DM_HASH" >> $GITHUB_OUTPUT + 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" + echo "tinygrad_ref=$TINYGRAD_REF" >> "$GITHUB_OUTPUT" JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" check_defaults() { DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 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 + DM=$(echo "$DEFAULTS" | jq --arg hash "$DM_HASH" --arg ref "$TINYGRAD_REF" ' + . as $root | [.bundles[] | select(.onnx_sha256 == $hash and (.tinygrad_ref == $ref or (.tinygrad_ref == null and $root.tinygrad_ref == $ref)))] | .[0] // empty + ' 2>/dev/null) + [ -n "$DM" ] && [ "$DM" != "null" ] } if check_defaults; then - echo "HF defaults match DM ONNX hash and tinygrad ref" + echo "HF defaults match DM tinygrad ref" exit 0 fi @@ -493,11 +503,12 @@ 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 + - name: Download models from HF + uses: ./.github/workflows/download-hf-model with: hf_repo: sunnypilot/sunnypilot_models_v1 dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models + tinygrad_ref: ${{ needs.prepare_dm_model.outputs.tinygrad_ref }} models: | [ {"hf_path": "models/defaults/small", "onnx_hash": "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}", "canonical": "driving_tinygrad.pkl"}, @@ -562,11 +573,12 @@ 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 + - name: Download models from HF + uses: ./.github/workflows/download-hf-model with: hf_repo: sunnypilot/sunnypilot_models_v1 dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models + tinygrad_ref: ${{ needs.prepare_dm_model.outputs.tinygrad_ref }} models: | [ {"hf_path": "models/defaults/small", "onnx_hash": "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}", "canonical": "driving_tinygrad.pkl"}, @@ -582,8 +594,14 @@ jobs: - name: Publish chestnut branch env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | CHESTNUT_BRANCH="${{ needs.prepare_strategy.outputs.new_branch }}-chestnut" + cp .lfsconfig "${{ env.OUTPUT_DIR }}/" + echo 'openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text' > "${{ env.OUTPUT_DIR }}/.gitattributes" + git lfs install + git config --global credential.helper store + echo "https://user:${HF_TOKEN}@huggingface.co" > ~/.git-credentials ${{ env.CI_DIR }}/publish.sh \ "${{ github.workspace }}" \ diff --git a/.github/workflows/test_models.yml b/.github/workflows/test_models.yml index 0a1a463aa2..bf22f14a11 100644 --- a/.github/workflows/test_models.yml +++ b/.github/workflows/test_models.yml @@ -3,9 +3,62 @@ on: pull_request: paths: - 'tinygrad_repo' + - 'openpilot/sunnypilot/modeld_v2/**' workflow_dispatch: +env: + CI: 1 + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: lfs.fetchexclude + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl + jobs: + plain-artifact: + name: Plain artifact round trip and pickle converter + runs-on: ubuntu-24.04 + env: + PYTHONPATH: ${{ github.workspace }}/tinygrad_repo:${{ github.workspace }} + TINYGRAD_TESTS: 1 + steps: + - uses: actions/checkout@v7 + - run: ./tools/op.sh setup + - name: Build openpilot + run: scons -j$(nproc) + - name: Compile, load, cache and fuse a model with the pinned tinygrad + env: + DEV: CPU + run: tools/op.sh test -v openpilot/sunnypilot/modeld_v2/tests/test_plain_artifact.py + - name: Install LLVM 20 and ROCm comgr for the amd mock, as tinygrad_repo/.github/actions/setup-tinygrad does + run: | + . /etc/os-release + curl -fsSL https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc > /dev/null + echo "deb http://apt.llvm.org/$VERSION_CODENAME/ llvm-toolchain-$VERSION_CODENAME-20 main" | sudo tee /etc/apt/sources.list.d/llvm.list + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://repo.radeon.com/rocm/rocm.gpg.key | gpg --dearmor | sudo tee /etc/apt/keyrings/rocm.gpg > /dev/null + echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/7.1 $VERSION_CODENAME main" | sudo tee /etc/apt/sources.list.d/rocm.list + printf 'Package: *\nPin: release o=repo.radeon.com\nPin-Priority: 600\n' | sudo tee /etc/apt/preferences.d/rocm-pin-600 + sudo apt-get -qq update && sudo apt-get -qq install -y --no-install-recommends libllvm20 clang-20 lld-20 comgr + printf '/opt/rocm/lib\n/opt/rocm/lib64\n' | sudo tee /etc/ld.so.conf.d/rocm.conf && sudo ldconfig + echo "/usr/lib/llvm-20/bin" >> "$GITHUB_PATH" + - name: Convert a pickled USB+AMD jit and replay it under tinygrad's amd mock + env: + DEV: MOCKUSB+AMD + CHECK_OOB: 0 + GMMU: 0 + run: tools/op.sh test -v openpilot/sunnypilot/modeld_v2/tests/test_convert_upstream_pkl.py + - name: Where tinygrad moved, for the helper or failure point named above + if: failure() && github.event_name == 'pull_request' + run: | + git fetch -q --depth=1 origin "${{ github.base_ref }}" + old=$(git rev-parse "origin/${{ github.base_ref }}:tinygrad_repo") new=$(git rev-parse HEAD:tinygrad_repo) + git -C tinygrad_repo fetch -q origin "$old" || true + echo "tinygrad_repo $old -> $new, in what helpers.py and plain_artifact_compiler.py read:" + git -C tinygrad_repo diff "$old" "$new" -- tinygrad/uop/ops.py tinygrad/engine/jit.py tinygrad/engine/realize.py tinygrad/device.py \ + tinygrad/helpers.py tinygrad/dtype.py tinygrad/renderer/__init__.py + echo "and in what convert_upstream_pkl.py decodes:" + git -C tinygrad_repo diff --stat "$old" "$new" -- tinygrad/runtime/autogen/am/pm4_nv.py tinygrad/runtime/autogen/amd_gpu.py + git -C tinygrad_repo diff "$old" "$new" -- tinygrad/runtime/support/hcq2.py tinygrad/runtime/support/usb.py tinygrad/runtime/ops_amd.py + generate-matrix: runs-on: ubuntu-latest outputs: @@ -47,33 +100,23 @@ jobs: with: submodules: true - - name: Download Model Chunks in Parallel + - name: Download model + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + MODEL_URL: ${{ matrix.artifact.download_uri.url }} + MODEL_FILE: ${{ matrix.artifact.file_name }} + shell: bash run: | - mkdir -p /tmp/model_chunks - echo '${{ toJson(matrix.artifact.chunks) }}' > chunks.json - - BASE_URL="${{ matrix.artifact.download_uri.url }}" - export BASE_DIR=$(dirname "$BASE_URL") - - python3 -c ' - import json, os - with open("chunks.json") as f: - chunks = json.load(f) - manifest_path = f"/tmp/model_chunks/${{ matrix.artifact.file_name }}.chunkmanifest" - with open(manifest_path, "w") as f: - f.write(str(len(chunks))) - base_dir = os.environ["BASE_DIR"] - with open("/tmp/curl_config.txt", "w") as f: - for c in chunks: - fn = c["file_name"] - f.write(f"url = \"{base_dir}/{fn}\"\noutput = \"/tmp/model_chunks/{fn}\"\n") - ' - curl -Z --parallel-immediate --parallel-max 16 -s -S -f -L -K /tmp/curl_config.txt + mkdir -p /tmp/models + AUTH=() + if [ -n "$HF_TOKEN" ]; then AUTH=(-H "Authorization: Bearer $HF_TOKEN"); fi + curl --retry 3 --retry-all-errors -s -S -f -L "${AUTH[@]}" -o "/tmp/models/$MODEL_FILE" "$MODEL_URL" - name: Run Model Compatibility Test env: MODEL_BASE_NAME: ${{ matrix.artifact.file_name }} - MODEL_CHUNK_DIR: "/tmp/model_chunks" - PYTHONPATH: ".:./tinygrad_repo" + MODEL_DIR: "/tmp/models" run: | + export PYTHONPATH="$GITHUB_WORKSPACE/tinygrad_repo:$GITHUB_WORKSPACE:$PYTHONPATH" + python3 -m pip uninstall -y tinygrad || uv pip uninstall -y tinygrad --system || true python3 -m pytest openpilot/sunnypilot/modeld_v2/tests/test_models.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index a50c7a5bf2..f64206d533 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -22,7 +22,7 @@ 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 + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl jobs: build_release: diff --git a/.gitignore b/.gitignore index 9dee0af9af..0a0284c421 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,8 @@ 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] *.stats *.pkl *.pkl* +!openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +!openpilot/sunnypilot/modeld_v2/models/*.pkl config.json compile_commands.json compare_runtime*.html diff --git a/.gitmodules b/.gitmodules index a2d892d07f..700963bd4e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -19,3 +19,4 @@ [submodule "sunnypilot/neural_network_data"] path = openpilot/sunnypilot/neural_network_data url = https://github.com/sunnypilot/neural-network-data.git + diff --git a/.lfsconfig b/.lfsconfig index 4375f2ec74..f331d69d7a 100644 --- a/.lfsconfig +++ b/.lfsconfig @@ -1,4 +1,5 @@ [lfs] - url = https://gitlab.com/sunnypilot/public/sunnypilot-new-lfs.git/info/lfs - pushurl = ssh://git@gitlab.com/sunnypilot/public/sunnypilot-new-lfs.git + url = https://huggingface.co/sunnypilot/sunnypilot-lfs.git/info/lfs + pushurl = https://huggingface.co/sunnypilot/sunnypilot-lfs.git/info/lfs + locksverify = false diff --git a/Jenkinsfile b/Jenkinsfile index af33edb821..5facce903f 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -12,16 +12,17 @@ def retryWithDelay(int maxRetries, int delay, Closure body) { def device(String ip, String step_label, String cmd) { withCredentials([file(credentialsId: 'id_rsa', variable: 'key_file')]) { def ssh_cmd = """ -ssh -o ControlMaster=auto -o ControlPath=/tmp/ssh_control_%C -o ControlPersist=yes -o ConnectTimeout=5 -o ServerAliveInterval=5 -o ServerAliveCountMax=2 -o BatchMode=yes -o StrictHostKeyChecking=no -i ${key_file} 'comma@${ip}' exec /usr/bin/bash <<'END' +ssh -o ControlMaster=no -o ControlPath=none -o ConnectTimeout=5 -o ServerAliveInterval=5 -o ServerAliveCountMax=12 -o BatchMode=yes -o StrictHostKeyChecking=no -i ${key_file} 'comma@${ip}' exec setpriv --pdeathsig HUP /usr/bin/bash <<'END' set -e export TERM=xterm-256color -shopt -s huponexit # kill all child processes when the shell exits +trap 'kill 0' HUP # stop this process group on SSH disconnect export CI=1 export PYTHONWARNINGS=error +export PYTHONFAULTHANDLER=1 export COMMA_CACHE=/data/tmp/comma_download_cache #export LOGPRINT=debug # this has gotten too spammy... export TEST_DIR=${env.TEST_DIR} @@ -69,7 +70,8 @@ export LD_LIBRARY_PATH="\$(python -c 'import ffmpeg; print(ffmpeg.LIB_DIR)'):/us ln -snf ${env.TEST_DIR} /data/pythonpath cd ${env.TEST_DIR} || true -time ${cmd} +time ( ${cmd} ) & +wait \$! END""" sh script: ssh_cmd, label: step_label @@ -159,6 +161,14 @@ def step(String name, String cmd, Map args = [:]) { return [name, cmd, args] } +def chestnutStage(String name = "chestnut") { + deviceStage(name, "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [ + step("build", "./openpilot/selfdrive/test/chestnut.sh"), + step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"), + step("onroad tests", "./openpilot/selfdrive/test/test_onroad.py TestChestnutOnroad", [timeout: 120]), + ]) +} + node { env.CI = "1" env.PYTHONWARNINGS = "error" @@ -169,7 +179,37 @@ node { env.GIT_BRANCH = checkout(scm).GIT_BRANCH env.GIT_COMMIT = checkout(scm).GIT_COMMIT - def excludeBranches = ['__nightly', 'devel', 'devel-staging', + if (env.JOB_BASE_NAME == 'chestnut-stresstest') { + sh 'git fetch --no-tags origin nightly-chestnut' + env.GIT_COMMIT = sh(script: 'git show FETCH_HEAD:git_src_commit', returnStdout: true).trim() + if (!(env.GIT_COMMIT ==~ /[0-9a-f]{40}/)) { + error("invalid nightly-chestnut source commit") + } + def results = [] + try { + timeout(time: 12, unit: 'HOURS') { + for (int run = 1; run <= 100; run++) { + results << "run ${run}: INCOMPLETE" + try { + chestnutStage("chestnut ${run}/100") + results[run - 1] = "run ${run}: PASS" + } catch (hudson.AbortException e) { + results[run - 1] = "run ${run}: FAIL" + echo e.toString() + } + } + } + } finally { + writeFile file: 'chestnut_stress_report.txt', text: "commit: ${env.GIT_COMMIT}\ncompleted: ${results.count { !it.endsWith('INCOMPLETE') }}/100\n" + results.join('\n') + '\n' + archiveArtifacts artifacts: 'chestnut_stress_report.txt' + } + if (results.any { !it.endsWith('PASS') }) { + error("chestnut stress test failed") + } + return + } + + def excludeBranches = ['__nightly', '__nightly-chestnut', 'devel', 'devel-staging', 'release-tizi', 'release-tizi-staging', 'release-mici', 'release-mici-staging', 'testing-closet*', 'hotfix-*'] def excludeRegex = excludeBranches.join('|').replaceAll('\\*', '.*') @@ -201,6 +241,21 @@ node { ) } + if (env.BRANCH_NAME == '__nightly-chestnut') { + parallel ( + 'nightly-chestnut': { + deviceStage("build nightly-chestnut", "mici-chestnut-ci", ["CHESTNUT=1"], [ + step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), + ]) + }, + 'nightly-chestnut-dev': { + deviceStage("build nightly-chestnut-dev", "mici-chestnut-ci", ["CHESTNUT=1"], [ + step("build nightly-chestnut-dev", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 PANDA_DEBUG_BUILD=1 RELEASE_BRANCH=nightly-chestnut-dev $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), + ]) + }, + ) + } + if (!env.BRANCH_NAME.matches(excludeRegex)) { parallel ( 'onroad tests': { @@ -252,6 +307,9 @@ node { step("test amp", "./openpilot/common/hardware/comma/tests/test_amplifier.py"), ]) }, + 'chestnut': { + chestnutStage() + }, ) } diff --git a/RELEASES.md b/RELEASES.md index 36ddadb431..ae3d2cb7e5 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,4 +1,14 @@ -Version 0.11.2 (2026-08-12) +Version 0.11.3 (2026-XX-XX) +======================== +* Autospeed + * On supported cars, openpilot will intelligently decide what speed to drive based on the scene, including: + * stopping at red lights, and going at green lights + * slowing down for curves + * adjusting speed based on surrounding traffic + * This behavior was previously available with openpilot's "Experimental mode", but is now out of beta and always enabled + * With openpilot deciding which speed to drive, the car's cruise speed is treated as a maximum speed openpilot will not exceed + +Version 0.11.2 (2026-10-14) ======================= * New driving model * Big model with 880M parameters diff --git a/SConstruct b/SConstruct index 4e9dedd947..dbeff51da9 100644 --- a/SConstruct +++ b/SConstruct @@ -20,7 +20,7 @@ SetOption('num_jobs', max(1, int(os.cpu_count()/(1 if "CI" in os.environ else 2) AddOption('--ccflags', action='store', type='string', default='', help='pass arbitrary flags over the command line') AddOption('--verbose', action='store_true', default=False, help='show full build commands') -release = not os.path.exists(File('#.gitattributes').abspath) # file absent on release branch, see release_files.py +release = not os.path.exists(File('#.gitmodules').abspath) # file absent on release branch, see release_files.py AddOption('--minimal', action='store_false', dest='extras', @@ -87,7 +87,6 @@ acados_include_dirs = [ # vendored in commaai/dependencies. allowed_system_libs = { "EGL", "GLESv2", "GL", - "Qt5Charts", "Qt5Core", "Qt5Gui", "Qt5Widgets", "dl", "drm", "gbm", "m", "pthread", } @@ -121,6 +120,7 @@ def _libflags(target, source, env, for_signature): env = Environment( ENV={ "PATH": os.environ['PATH'], + "TMPDIR": os.environ.get('TMPDIR', '/tmp'), "PYTHONPATH": os.pathsep.join(submodule_python_paths), "ACADOS_SOURCE_DIR": acados.DIR, "ACADOS_PYTHON_INTERFACE_PATH": acados.TEMPLATE_DIR, @@ -316,8 +316,6 @@ def count_scons_nodes(nodes): if node in seen: continue seen.add(node) - if hasattr(node, 'has_builder') and node.has_builder(): - build_product_nodes.add(node) executor = node.get_executor() if executor is not None: stack += executor.get_all_prerequisites() + executor.get_all_children() @@ -326,7 +324,6 @@ def count_scons_nodes(nodes): progress_interval = 5 progress_count = 0 -build_product_nodes = set() progress_total = max(1, count_scons_nodes(env.arg2nodes(BUILD_TARGETS or [Dir('.')], env.fs.Entry))) def progress_function(node): @@ -342,11 +339,3 @@ def progress_function(node): Progress(progress_function, interval=progress_interval) AddPostAction(BUILD_TARGETS or [Dir('.')], prune_cache_dir) - -def check_build_product_size(target, source, env): - limit = 50 * 1024 * 1024 # GitHub max size - for t in target: - if hasattr(t, 'isfile') and t.isfile() and (size := os.path.getsize(t.abspath)) > limit: - raise SCons.Errors.UserError(f"{t} is {size / (1024 * 1024):.1f} MiB, exceeding the {limit / (1024 * 1024):.1f} MiB limit") -if not GetOption('extras'): - AddPostAction(list(build_product_nodes), Action(check_build_product_size, None)) diff --git a/docs/CARS.md b/docs/CARS.md index 0866436c0d..8397e6e520 100644 --- a/docs/CARS.md +++ b/docs/CARS.md @@ -34,7 +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
||| +|CUPRA|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 MEB 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
||| @@ -99,7 +99,7 @@ A supported vehicle is one that just works when you install a comma device. All |Honda|Fit 2018-20|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.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|Freed 2020|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.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|HR-V 2019-22|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.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|HR-V 2023-25|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|HR-V 2023-27|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|Insight 2019-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|Inspire 2018|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|N-Box 2018|All|openpilot available[1,5](#footnotes)|0 mph|11 mph|[![star](assets/icon-star-full.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
||| @@ -268,8 +268,8 @@ A supported vehicle is one that just works when you install a comma device. All |Škoda[12](#footnotes)|Superb 2015-22[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
||| |Tesla[10](#footnotes)|Model 3 (with HW3) 2019-23[9](#footnotes)|All|openpilot available[1](#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 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Tesla[10](#footnotes)|Model 3 (with HW4) 2024-25[9](#footnotes)|All|openpilot available[1](#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 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Tesla[10](#footnotes)|Model Y (with HW3) 2020-23[9](#footnotes)|All|openpilot available[1](#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 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Tesla[10](#footnotes)|Model Y (with HW4) 2024-25[9](#footnotes)|All|openpilot available[1](#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 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Tesla[10](#footnotes)|Model Y (with HW3) 2020-24[9](#footnotes)|All|openpilot available[1](#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 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Tesla[10](#footnotes)|Model Y (with HW4) 2023-25[9](#footnotes)|All|openpilot available[1](#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 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Toyota|Alphard 2019-20|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 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Alphard Hybrid 2021|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 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Avalon 2016|Toyota Safety Sense P|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -335,8 +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|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 MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|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 MEB 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
||| @@ -363,7 +363,7 @@ A supported vehicle is one that just works when you install a comma device. All 6See more setup details for Nissan.
7In the non-US market, openpilot requires the car to come equipped with EyeSight with Lane Keep Assistance.
8Enabling longitudinal control (alpha) will disable all EyeSight functionality, including AEB, LDW, and RAB.
-9Some 2023 model years have HW4. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
+9Model years 2023 and 2024 can have either hardware type, depending on build date and factory. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
10See more setup details for Tesla.
11openpilot operates above 28mph for Camry 4CYL L, 4CYL LE and 4CYL SE which don't have Full-Speed Range Dynamic Radar Cruise Control.
12The J533 harness plugs in at the CAN gateway under the dashboard, just above the steering column. More information can be found at this guide.
diff --git a/docs/README.md b/docs/README.md index d6a0126b39..dc6eee6905 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,10 +5,10 @@ The site is updated on pushes to master by this [workflow](../.github/workflows/ **1. Build the site** ``` bash -python docs/serve.py --build +op docs --build ``` **2. Run the site locally** (rebuilds on change) ``` bash -python docs/serve.py +op docs ``` diff --git a/launch_chffrplus.sh b/launch_chffrplus.sh index f30e03ca62..806e41d4c1 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -18,21 +18,21 @@ function agnos_init { sudo chmod 660 /dev/adsprpc-smd /dev/ion /dev/kgsl-3d0 # Check if AGNOS update is required - if [ $(< /VERSION) != "$AGNOS_VERSION" ]; then + if [ "$(< /VERSION)" != "$AGNOS_VERSION" ]; then AGNOS_PY="$DIR/openpilot/common/hardware/comma/agnos.py" MANIFEST="$DIR/openpilot/system/hardware/comma/agnos.json" - if $AGNOS_PY --verify $MANIFEST; then + if "$AGNOS_PY" --verify "$MANIFEST"; then sudo reboot fi while true; do - $DIR/openpilot/common/hardware/comma/updater $AGNOS_PY $MANIFEST + "$DIR/openpilot/common/hardware/comma/updater" "$AGNOS_PY" "$MANIFEST" done fi } function launch { # Remove orphaned git lock if it exists on boot - [ -f "$DIR/.git/index.lock" ] && rm -f $DIR/.git/index.lock + [ -f "$DIR/.git/index.lock" ] && rm -f "$DIR/.git/index.lock" # Check to see if there's a valid overlay-based update available. Conditions # are as follows: @@ -44,7 +44,7 @@ function launch { # that completed successfully and synced to disk. if [ -f "${DIR}/.overlay_init" ]; then - find ${DIR}/.git -newer ${DIR}/.overlay_init | grep -q '.' 2> /dev/null + find "${DIR}/.git" -newer "${DIR}/.overlay_init" | grep -q '.' 2> /dev/null if [ $? -eq 0 ]; then echo "${DIR} has been modified, skipping overlay update installation" else @@ -53,9 +53,11 @@ function launch { echo "Valid overlay update found, installing" LAUNCHER_LOCATION="${BASH_SOURCE[0]}" - mv $DIR /data/safe_staging/old_openpilot - mv "${STAGING_ROOT}/finalized" $DIR - cd $DIR + mv "$DIR" /data/safe_staging/old_openpilot + mv "${STAGING_ROOT}/finalized" "$DIR" + cd "$DIR" + # releases before the openpilot/ move can't swap AGNOS on newer AGNOS, use ours + [ ! -L system/hardware/tici/agnos.json ] && /data/safe_staging/old_openpilot/openpilot/common/hardware/comma/agnos.py --verify system/hardware/tici/agnos.json && sudo reboot echo "Restarting launch script ${LAUNCHER_LOCATION}" unset AGNOS_VERSION @@ -69,7 +71,7 @@ function launch { fi # handle pythonpath - ln -sfn $(pwd) /data/pythonpath + ln -sfn "$(pwd)" /data/pythonpath export PYTHONPATH="$PWD" # submodule package symlinks for PYTHONPATH imports on device. @@ -90,7 +92,7 @@ function launch { # start manager cd openpilot/system/manager - if [ ! -f $DIR/prebuilt ]; then + if [ ! -f "$DIR/prebuilt" ]; then ./build.py fi ./manager.py diff --git a/launch_env.sh b/launch_env.sh index 4fcb243f12..0f913f287a 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -16,7 +16,7 @@ export VECLIB_MAXIMUM_THREADS=1 export QCOM_PRIORITY=12 if [ -z "$AGNOS_VERSION" ]; then - export AGNOS_VERSION="19.7" + export AGNOS_VERSION="19.9" fi export STAGING_ROOT="/data/safe_staging" diff --git a/opendbc_repo b/opendbc_repo index 28303fcd1c..8e60259eaf 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 28303fcd1cc457f67d405407858223e0b338df20 +Subproject commit 8e60259eaf1d3d493a3e594bd599527ed23bb4a6 diff --git a/openpilot/cereal/custom.capnp b/openpilot/cereal/custom.capnp index cfbbbe472a..d0e9dd463c 100644 --- a/openpilot/cereal/custom.capnp +++ b/openpilot/cereal/custom.capnp @@ -138,6 +138,9 @@ struct ModelManagerSP @0xaedffd8f31e7b55d { status @0 :DownloadStatus; progress @1 :Float32; eta @2 :UInt32; + speed @3 :Float32; + canceling @4 :Bool; + resuming @5 :Bool; } struct Chunk { @@ -196,7 +199,6 @@ struct ModelManagerSP @0xaedffd8f31e7b55d { } struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 { - dec @0 :DynamicExperimentalControl; longitudinalPlanSource @1 :LongitudinalPlanSource; smartCruiseControl @2 :SmartCruiseControl; speedLimit @3 :SpeedLimit; @@ -205,7 +207,11 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 { events @6 :List(OnroadEventSP.Event); e2eAlerts @7 :E2eAlerts; - struct DynamicExperimentalControl { + deprecated :group { + dec @0 :DynamicExperimentalControlDEPRECATED; + } + + struct DynamicExperimentalControlDEPRECATED { state @0 :DynamicExperimentalControlState; enabled @1 :Bool; active @2 :Bool; @@ -342,7 +348,7 @@ struct OnroadEventSP @0xda96579883444c35 { silentParkBrake @11; controlsMismatchLateral @12; hyundaiRadarTracksConfirmed @13; - experimentalModeSwitched @14; + experimentalModeSwitchedDEPRECATED @14; wrongCarModeAlertOnly @15; pedalPressedAlertOnly @16; laneTurnLeft @17; diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 5cee3266e0..0e3e9674b9 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -128,6 +128,7 @@ struct OnroadEvent @0xc4fa6047f024e718 { personalityChanged @91; aeb @92; userBookmark @95; + userBookmarkNotPaired @104; excessiveActuation @96; bigModelLoading @100; bigModelFailed @102; @@ -166,12 +167,7 @@ struct InitData { gitSrcCommit @23 :Text; gitSrcCommitDate @24 :Text; - androidProperties @16 :Map(Text, Text); - - pandaInfo @8 :PandaInfo; - dirty @9 :Bool; - passive @12 :Bool; params @17 :Map(Text, Data); commands @19 :Map(Text, Data); @@ -189,6 +185,17 @@ struct InitData { mici @7; # comma four } + deprecated :group { + gctx @1 :Text; + androidBuildInfo @5 :Deprecated.AndroidBuildInfo; + androidSensors @6 :List(Deprecated.AndroidSensor); + chffrAndroidExtra @7 :ChffrAndroidExtra; + pandaInfo @8 :PandaInfo; + passive @12 :Bool; + iosBuildInfo @14 :Deprecated.IosBuildInfo; + androidProperties @16 :Map(Text, Text); + } + struct PandaInfo { hasPanda @0 :Bool; dongleId @1 :Text; @@ -199,14 +206,6 @@ struct InitData { struct ChffrAndroidExtra { allCameraCharacteristics @0 :Map(Text, Text); } - - deprecated :group { - gctx @1 :Text; - androidBuildInfo @5 :Deprecated.AndroidBuildInfo; - androidSensors @6 :List(Deprecated.AndroidSensor); - chffrAndroidExtra @7 :ChffrAndroidExtra; - iosBuildInfo @14 :Deprecated.IosBuildInfo; - } } struct FrameData { @@ -572,6 +571,9 @@ struct PandaState @0xa7649e2575e4591e { controlsAllowedLateral @38 :Bool; controlsAllowedLongitudinal @39 :Bool; + nmiReset @40 :Bool; + hardfaultReset @41 :Bool; + enum FaultStatus { none @0; faultTemp @1; @@ -694,6 +696,7 @@ struct PeripheralState { struct UsbState { devices @0 :List(Device); + connected @1 :Bool; struct Device { busnum @0 :UInt8; @@ -819,7 +822,6 @@ struct SelfdriveState { alertHudVisual @12 :Car.CarControl.HUDControl.VisualAlert; # configurable driving settings - experimentalMode @10 :Bool; personality @11 :LongitudinalPersonality; enum AudibleAlert { @@ -863,6 +865,7 @@ struct SelfdriveState { deprecated :group { alertSound @8 :Car.CarControl.HUDControl.AudibleAlert; + experimentalMode @10 :Bool; } } @@ -1237,6 +1240,7 @@ struct LongitudinalPlan @0xe00b5b3eba12876c { shouldStop @37: Bool; allowThrottle @38: Bool; allowBrake @39: Bool; + accelBoost @40 :Float32; solverExecutionTime @35 :Float32; @@ -2234,6 +2238,7 @@ struct DriverMonitoringState { pose @0: Bool; eye @1: Bool; phone @2: Bool; + sleep @3: Bool; } struct Pose { @@ -2594,6 +2599,7 @@ struct Event { clocks @35 :Clocks; deviceState @6 :DeviceState; chestnutState @152 :ChestnutState; + chestnutGpuState @153 :ChestnutState; logMessage @18 :Text; errorLogMessage @85 :Text; @@ -2704,8 +2710,8 @@ struct Event { driverMonitoringStateDEPRECATED @71 :DriverMonitoringStateDEPRECATED; gpsNMEADEPRECATED @3 :GPSNMEAData; uploaderStateDEPRECATED @79 :UploaderState; - navInstructionDEPRECATED @82 :NavInstruction; - navRouteDEPRECATED @83 :NavRoute; + navInstruction @82 :NavInstruction; + navRoute @83 :NavRoute; navThumbnailDEPRECATED @84 :Thumbnail; gnssMeasurementsDEPRECATED @91 :GnssMeasurements; mapRenderStateDEPRECATED @105: MapRenderState; diff --git a/openpilot/cereal/services.py b/openpilot/cereal/services.py index ed718506f3..541a06298a 100755 --- a/openpilot/cereal/services.py +++ b/openpilot/cereal/services.py @@ -25,7 +25,8 @@ _services: dict[str, tuple] = { "accelerometer": (True, 104., 104), "temperatureSensor": (True, 2., 200), "deviceState": (True, 2., 1), - "chestnutState": (True, 10., 10), + "chestnutState": (True, 10., 1), + "chestnutGpuState": (False, 10.), "touch": (True, 20., 1), "can": (True, 100., 2053, QueueSize.BIG), # decimation gives ~3 msgs in a full segment "controlsState": (True, 100., 10, QueueSize.MEDIUM), @@ -51,6 +52,8 @@ _services: dict[str, tuple] = { "procLog": (True, 0.5, 15, QueueSize.BIG), "gpsLocationExternal": (True, 10., 10), "gpsLocation": (True, 1., 1), + "navInstruction": (True, 1., 10), + "navRoute": (True, 0.), "ubloxGnss": (True, 10.), "qcomGnss": (True, 2.), "clocks": (True, 0.1, 1), diff --git a/openpilot/common/api/__init__.py b/openpilot/common/api/__init__.py index 45c73e2a05..0199535a6b 100644 --- a/openpilot/common/api/__init__.py +++ b/openpilot/common/api/__init__.py @@ -1,4 +1,4 @@ -from openpilot.common.api.comma_connect import CommaConnectApi +from openpilot.common.api.comma_connect import API_HOST as API_HOST, CommaConnectApi class Api: diff --git a/openpilot/common/constants.py b/openpilot/common/constants.py index 7ca425c4b2..a20af50295 100644 --- a/openpilot/common/constants.py +++ b/openpilot/common/constants.py @@ -16,6 +16,10 @@ class CV: DEG_TO_RAD = np.pi / 180. RAD_TO_DEG = 1. / DEG_TO_RAD + # Length + METER_TO_FOOT = 3.28084 + METER_TO_MILE = 1. / 1609.344 + # Mass LB_TO_KG = 0.453592 diff --git a/openpilot/common/esim/base.py b/openpilot/common/esim/base.py index b783a630b2..547e36b12f 100644 --- a/openpilot/common/esim/base.py +++ b/openpilot/common/esim/base.py @@ -1,6 +1,8 @@ from abc import ABC, abstractmethod from dataclasses import dataclass +WEBBING_ICCID_PREFIX = "8985235" + class LPAError(RuntimeError): pass @@ -19,7 +21,14 @@ class Profile: @property def is_comma(self) -> bool: - return self.provider == 'Webbing' and self.iccid.startswith('8985235') + return self.provider == 'Webbing' and self.iccid.startswith(WEBBING_ICCID_PREFIX) + + @property + def display_name(self) -> str: + if self.is_comma: + return "comma prime" + name = self.nickname or self.provider or "" + return f"{name} (...{self.iccid[-4:]})" class LPABase(ABC): diff --git a/openpilot/common/esim/lpa.py b/openpilot/common/esim/lpa.py index fb128c32e1..600869fcb7 100644 --- a/openpilot/common/esim/lpa.py +++ b/openpilot/common/esim/lpa.py @@ -613,7 +613,7 @@ def parse_lpa_activation_code(activation_code: str) -> tuple[str, str]: if not activation_code.startswith("LPA:"): raise ValueError("Invalid activation code format") parts = activation_code[4:].split("$") - if len(parts) != 3: + if len(parts) != 3 or not all(parts): raise ValueError("Invalid activation code format") return parts[1], parts[2] diff --git a/openpilot/common/file_chunker.py b/openpilot/common/file_chunker.py deleted file mode 100755 index 2d080c3fff..0000000000 --- a/openpilot/common/file_chunker.py +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env python3 -import io -import sys -import math -import os -from pathlib import Path - -CHUNK_SIZE = 45 * 1024 * 1024 # 45MB, under GitHub's 50MB limit - -def get_chunk_name(name, idx, num_chunks): - return f"{name}.chunk{idx+1:02d}of{num_chunks:02d}" - -def get_manifest_path(name): - return f"{name}.chunkmanifest" - -def _chunk_paths(path, num_chunks): - return [get_manifest_path(path)] + [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)] - -def get_chunk_targets(path, file_size): - num_chunks = math.ceil(file_size / CHUNK_SIZE) - return _chunk_paths(path, num_chunks) - -def chunk_file(path, targets): - manifest_path, *chunk_paths = targets - actual_num_chunks = max(1, math.ceil(os.path.getsize(path) / CHUNK_SIZE)) - assert len(chunk_paths) >= actual_num_chunks, f"Allowed {len(chunk_paths)} chunks but needs at least {actual_num_chunks}, for path {path}" - with open(path, 'rb') as f: - for chunk_path in chunk_paths: - with open(chunk_path, 'wb') as out: - out.write(f.read(CHUNK_SIZE)) - Path(manifest_path).write_text(str(len(chunk_paths))) - os.remove(path) - -def get_existing_chunks(path): - if os.path.isfile(path): - return [path] - if os.path.isfile(manifest := get_manifest_path(path)): - num_chunks = int(Path(manifest).read_text().strip()) - return _chunk_paths(path, num_chunks) - raise FileNotFoundError(path) - -class ChunkStream(io.RawIOBase): - def __init__(self, paths): - self._paths = iter(paths) - self._f = None - - def readable(self): - return True - - def readinto(self, b): - n = 0 - view = memoryview(b) - while n < len(b): - if self._f is None: - p = next(self._paths, None) - if p is None: - break - self._f = open(p, 'rb') - count = self._f.readinto(view[n:]) - if not count: - self._f.close() - self._f = None - continue - n += count - return n - -def open_file_chunked(path): - manifest_path = get_manifest_path(path) - if os.path.isfile(manifest_path): - num_chunks = int(Path(manifest_path).read_text().strip()) - paths = [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)] - elif os.path.isfile(path): - paths = [path] - else: - raise FileNotFoundError(path) - return io.BufferedReader(ChunkStream(paths)) - - -if __name__ == "__main__": - path = sys.argv[1] - chunk_paths = get_chunk_targets(path, os.path.getsize(path)) - chunk_file(path, chunk_paths) diff --git a/openpilot/common/gpio.py b/openpilot/common/gpio.py index 8f025a2daf..5f4295e7bd 100644 --- a/openpilot/common/gpio.py +++ b/openpilot/common/gpio.py @@ -27,16 +27,6 @@ def gpio_read(pin: int) -> bool | None: return val -def gpio_export(pin: int) -> None: - if os.path.isdir(f"/sys/class/gpio/gpio{pin}"): - return - - try: - with open("/sys/class/gpio/export", 'w') as f: - f.write(str(pin)) - except Exception: - print(f"Failed to export gpio {pin}") - @cache def get_irq_action(irq: int) -> list[str]: try: diff --git a/openpilot/common/hardware/base.py b/openpilot/common/hardware/base.py index 5d8f7770dc..7c8842a0ec 100644 --- a/openpilot/common/hardware/base.py +++ b/openpilot/common/hardware/base.py @@ -145,6 +145,9 @@ class HardwareBase(ABC): def get_modem_temperatures(self): return [] + def get_modem_state(self) -> dict: + return {} + def initialize_hardware(self): pass diff --git a/openpilot/common/hardware/comma/agnos.json b/openpilot/common/hardware/comma/agnos.json index 4a48016ed2..96803bdb85 100644 --- a/openpilot/common/hardware/comma/agnos.json +++ b/openpilot/common/hardware/comma/agnos.json @@ -56,28 +56,28 @@ }, { "name": "boot", - "url": "https://commadist.azureedge.net/agnosupdate/boot-6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d.img.xz", - "hash": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d", - "hash_raw": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d", - "size": 46897152, + "url": "https://commadist.azureedge.net/agnosupdate/boot-b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615.img.xz", + "hash": "b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615", + "hash_raw": "b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615", + "size": 46962688, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "d12e1e5b9455b62a1464558716493b33e470d7a7e88da1c4105a3b21d0961808" + "ondevice_hash": "3ef4769bb1cc73bfc31f96f073b46af42dcc0bad4ab2dde32d11929745d0cd28" }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img.xz", - "hash": "74ffc9c551e1f29cda897ace8a69080fe644f8039977c6885f2b48362e39b744", - "hash_raw": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f", + "url": "https://commadist.azureedge.net/agnosupdate/system-80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7.img.xz", + "hash": "9aebca09b9d142718e71efbfe44b723010eec6e70067c043c63186067291739d", + "hash_raw": "80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7", "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "6a992680183685eea9db99d915219a37935f45989330d9b619e880450257f448", + "ondevice_hash": "270b76c5c5568828e3883390b83ea445425a104dd42119d68f06093ce6b6fcef", "alt": { - "hash": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f", - "url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img", + "hash": "80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7", + "url": "https://commadist.azureedge.net/agnosupdate/system-80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7.img", "size": 4718592000 } } diff --git a/openpilot/common/hardware/comma/hardware.py b/openpilot/common/hardware/comma/hardware.py index effb4d9dbe..7de6e704c2 100644 --- a/openpilot/common/hardware/comma/hardware.py +++ b/openpilot/common/hardware/comma/hardware.py @@ -8,6 +8,7 @@ from functools import cached_property, lru_cache from pathlib import Path from openpilot.cereal import log +from openpilot.common.esim.base import WEBBING_ICCID_PREFIX from openpilot.common.utils import sudo_read, sudo_write from openpilot.common.gpio import gpio_set, gpio_init, get_irqs_for_action from openpilot.common.esim.base import LPABase @@ -207,6 +208,8 @@ class HardwareComma(HardwareBase): def get_network_metered(self, network_type) -> bool: if network_type in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G): + if self.get_modem_state().get("iccid", "").startswith(WEBBING_ICCID_PREFIX): + return True from openpilot.common.params import Params return Params().get_bool("GsmMetered") try: diff --git a/openpilot/common/hardware/comma/modem.py b/openpilot/common/hardware/comma/modem.py index 901efae2b1..f1b79862b1 100755 --- a/openpilot/common/hardware/comma/modem.py +++ b/openpilot/common/hardware/comma/modem.py @@ -17,6 +17,8 @@ from ipaddress import IPv4Address, AddressValueError from enum import Enum +from openpilot.common.esim.base import WEBBING_ICCID_PREFIX + logging.basicConfig( level=logging.INFO, format="%(asctime)s.%(msecs)03d %(levelname)-7s modem: %(message)s", @@ -44,7 +46,6 @@ NETWORK_TYPE = {0: "gsm", 1: "gsm", 3: "gsm", 8: "gsm", 11: "nr", 12: "nr", 13: "nr"} DIAL_CID = 1 -WEBBING_ICCID_PREFIX = "8985235" PPPD_CMD = [ "sudo", "pppd", PPP_PORT, "460800", "noauth", "nodetach", "noipdefault", "usepeerdns", diff --git a/openpilot/common/hardware/comma/updater b/openpilot/common/hardware/comma/updater index 44b82d0c54..241d0fc168 100755 --- a/openpilot/common/hardware/comma/updater +++ b/openpilot/common/hardware/comma/updater @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a94ab8395f20d20a9d5a2a2bacca0694f072df8421cf13adca6250d28065bdc -size 24709205 +oid sha256:f92c0a9b41613e063b6a1cd086729f5555d9df6482249e171ed1e276272cad7d +size 24709520 diff --git a/openpilot/common/hardware/usb.py b/openpilot/common/hardware/usb.py index c3c2050b6b..55d970a245 100644 --- a/openpilot/common/hardware/usb.py +++ b/openpilot/common/hardware/usb.py @@ -1,7 +1,7 @@ import os from pathlib import Path -CHESTNUT_FW_VERSION = "ed4e39b7" +CHESTNUT_FW_VERSION = "8516b768" CHESTNUT_USB_IDS = ((0xADD1, 0x0001), (0x3801, 0x0001)) CHESTNUT_ROM_USB_IDS = ((0x174C, 0x2464), (0x174C, 0x2463)) CHESTNUT_USB_PRODUCT = f"custom {CHESTNUT_FW_VERSION}-CLEAN" @@ -36,6 +36,10 @@ def read_int(path: Path, base: int = 10) -> int: return 0 +def cable_connected() -> bool: + return read_int(TYPEC_CC_ORIENTATION_PATH) != 0 + + def usb_devices() -> list[Path]: try: devices = (d for d in USB_DEVICES_PATH.glob("*") if (d / "idVendor").exists()) @@ -91,3 +95,4 @@ def set_usb_state(device_state, devices: list[dict]) -> None: chestnut_present = True device_state.chestnutPresent = chestnut_present + device_state.usbState.connected = cable_connected() diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index daeb928a84..f8543d8bed 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -28,6 +28,7 @@ inline static std::unordered_map keys = { {"ControlsReady", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"CurrentBootlog", {PERSISTENT, STRING}}, {"CurrentRoute", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, STRING}}, + {"DisableDriverCameraIR", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"DisableLogging", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"DisablePowerDown", {PERSISTENT | BACKUP, BOOL}}, {"DisableUpdates", {PERSISTENT | BACKUP, BOOL, "0"}}, @@ -39,8 +40,6 @@ inline static std::unordered_map keys = { {"DriverTooDistracted", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, BOOL}}, {"DriverLockoutCount", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, INT, "0"}}, {"AlphaLongitudinalEnabled", {PERSISTENT | DEVELOPMENT_ONLY | BACKUP, BOOL}}, - {"ExperimentalMode", {PERSISTENT | BACKUP, BOOL}}, - {"ExperimentalModeConfirmed", {PERSISTENT | BACKUP, BOOL}}, {"FirmwareQueryDone", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"ForcePowerDown", {PERSISTENT, BOOL}}, {"GitBranch", {PERSISTENT, STRING}}, @@ -68,7 +67,7 @@ inline static std::unordered_map keys = { {"JoystickDebugMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"LanguageSetting", {PERSISTENT | BACKUP, STRING, "en"}}, {"LastAthenaPingTime", {CLEAR_ON_MANAGER_START, INT}}, - {"LastGPSPosition", {PERSISTENT, STRING}}, + {"LastGPSPosition", {PERSISTENT, BYTES}}, {"LastManagerExitReason", {CLEAR_ON_MANAGER_START, STRING}}, {"LastOffroadStatusPacket", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, JSON}}, {"LastAgnosPowerMonitorShutdown", {CLEAR_ON_MANAGER_START, STRING}}, @@ -138,7 +137,15 @@ inline static std::unordered_map keys = { {"UptimeOnroad", {PERSISTENT, FLOAT, "0.0"}}, {"ChestnutActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, {"ChestnutLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, - {"ChestnutModelError", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, + {"ChestnutRetry", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, BOOL}}, + {"InitialCruiseSpeedKph", {PERSISTENT, INT, "40"}}, + {"NavDestination", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, JSON}}, + {"NavigationEnabled", {PERSISTENT, BOOL, "0"}}, + {"Offroad_ChestnutModelError", {CLEAR_ON_MANAGER_START, JSON}}, + {"Offroad_Pairing", {CLEAR_ON_MANAGER_START, JSON}}, + {"Offroad_UploadsIgnored", {CLEAR_ON_MANAGER_START, JSON}}, + {"PairingEmail", {PERSISTENT, STRING}}, + {"PairingProvider", {PERSISTENT, STRING}}, {"Version", {PERSISTENT, STRING}}, // --- sunnypilot params --- // @@ -240,7 +247,6 @@ inline static std::unordered_map keys = { {"ToyotaEnforceStockLongitudinal", {PERSISTENT | BACKUP, BOOL, "0"}}, {"ToyotaStopAndGoHack", {PERSISTENT | BACKUP, BOOL, "0"}}, - {"DynamicExperimentalControl", {PERSISTENT | BACKUP, BOOL, "0"}}, {"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}}, // sunnypilot model params diff --git a/openpilot/common/qrcode.py b/openpilot/common/qrcode.py index b6b345c227..895daece39 100644 --- a/openpilot/common/qrcode.py +++ b/openpilot/common/qrcode.py @@ -1,16 +1,47 @@ -"""Small QR encoder for the UI's byte-mode, error-correction-level-L codes.""" +"""QR code encoding, decoding, and UI textures.""" + +import functools +import itertools import numpy as np import pyray as rl -# Indexes are QR versions. These are the only two Reed-Solomon parameters needed -# for error-correction level L. -_ECC_LEN = (0, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28) -_NUM_BLOCKS = (0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8) +# (ec codewords per block, block count) for levels L, M, Q, H, versions 1-40 +_EC = [ + ((7, 1), (10, 1), (13, 1), (17, 1)), ((10, 1), (16, 1), (22, 1), (28, 1)), ((15, 1), (26, 1), (18, 2), (22, 2)), + ((20, 1), (18, 2), (26, 2), (16, 4)), ((26, 1), (24, 2), (18, 4), (22, 4)), ((18, 2), (16, 4), (24, 4), (28, 4)), + ((20, 2), (18, 4), (18, 6), (26, 5)), ((24, 2), (22, 4), (22, 6), (26, 6)), ((30, 2), (22, 5), (20, 8), (24, 8)), + ((18, 4), (26, 5), (24, 8), (28, 8)), ((20, 4), (30, 5), (28, 8), (24, 11)), ((24, 4), (22, 8), (26, 10), (28, 11)), + ((26, 4), (22, 9), (24, 12), (22, 16)), ((30, 4), (24, 9), (20, 16), (24, 16)), ((22, 6), (24, 10), (30, 12), (24, 18)), + ((24, 6), (28, 10), (24, 17), (30, 16)), ((28, 6), (28, 11), (28, 16), (28, 19)), ((30, 6), (26, 13), (28, 18), (28, 21)), + ((28, 7), (26, 14), (26, 21), (26, 25)), ((28, 8), (26, 16), (30, 20), (28, 25)), ((28, 8), (26, 17), (28, 23), (30, 25)), + ((28, 9), (28, 17), (30, 23), (24, 34)), ((30, 9), (28, 18), (30, 25), (30, 30)), ((30, 10), (28, 20), (30, 27), (30, 32)), + ((26, 12), (28, 21), (30, 29), (30, 35)), ((28, 12), (28, 23), (28, 34), (30, 37)), ((30, 12), (28, 25), (30, 34), (30, 40)), + ((30, 13), (28, 26), (30, 35), (30, 42)), ((30, 14), (28, 28), (30, 38), (30, 45)), ((30, 15), (28, 29), (30, 40), (30, 48)), + ((30, 16), (28, 31), (30, 43), (30, 51)), ((30, 17), (28, 33), (30, 45), (30, 54)), ((30, 18), (28, 35), (30, 48), (30, 57)), + ((30, 19), (28, 37), (30, 51), (30, 60)), ((30, 19), (28, 38), (30, 53), (30, 63)), ((30, 20), (28, 40), (30, 56), (30, 66)), + ((30, 21), (28, 43), (30, 59), (30, 70)), ((30, 22), (28, 45), (30, 62), (30, 74)), ((30, 24), (28, 47), (30, 65), (30, 77)), + ((30, 25), (28, 49), (30, 68), (30, 81)), +] -# 15 format-info bits for level L (01) with mask 0: ((0x08 << 10) | bch_remainder) ^ 0x5412 -_FORMAT_BITS = 0b111011111000100 +# GF(256) with the QR polynomial x^8 + x^4 + x^3 + x^2 + 1: powers of alpha and their logs +_EXP = [1] +for _ in range(254): + _EXP.append(_EXP[-1] << 1 ^ (0x11D if _EXP[-1] & 0x80 else 0)) +_LOG = {v: i for i, v in enumerate(_EXP)} + + +def _bch_format(data: int) -> int: + v = data << 10 + for shift in range(14, 9, -1): + if v >> shift & 1: + v ^= 0x537 << (shift - 10) + return (data << 10 | v) ^ 0x5412 + + +# 15-bit format info indexed by (level bits << 3 | mask). Level bits: L=01, M=00, Q=11, H=10. +_FORMATS = [_bch_format(d) for d in range(32)] def _raw_modules(version: int) -> int: @@ -21,8 +52,24 @@ def _raw_modules(version: int) -> int: return result - (36 if version >= 7 else 0) +def _block_lengths(version: int, level: int) -> list[int]: + """Data codewords per Reed-Solomon block. The last blocks may be one longer.""" + ec, nblocks = _EC[version - 1][level] + total = _raw_modules(version) // 8 - ec * nblocks + return [total // nblocks + (i >= nblocks - total % nblocks) for i in range(nblocks)] + + +def _interleaved(version: int, level: int) -> list[tuple[int, int]]: + """(block, index within block) of each transmitted codeword: data column-major, then ECC column-major.""" + ec, nblocks = _EC[version - 1][level] + lens = _block_lengths(version, level) + data = [(b, i) for i in range(max(lens)) for b in range(nblocks) if i < lens[b]] + ecc = [(b, lens[b] + i) for i in range(ec) for b in range(nblocks)] + return data + ecc + + def _capacity(version: int) -> int: - return _raw_modules(version) // 8 - _ECC_LEN[version] * _NUM_BLOCKS[version] + return sum(_block_lengths(version, 0)) def _append_bits(bits: list[int], value: int, length: int) -> None: @@ -49,37 +96,18 @@ def _data_codewords(data: bytes, version: int) -> bytes: def _codewords(data: bytes, version: int) -> bytes: """Split data codewords into Reed-Solomon blocks and interleave data + ECC.""" data = _data_codewords(data, version) - num_blocks = _NUM_BLOCKS[version] - ecc_len = _ECC_LEN[version] - raw_codewords = _raw_modules(version) // 8 - short_len = raw_codewords // num_blocks - num_short = num_blocks - raw_codewords % num_blocks - divisor = _divisor(ecc_len) - blocks: list[tuple[bytes, bytes]] = [] + divisor = _divisor(_EC[version - 1][0][0]) + blocks = [] offset = 0 - for i in range(num_blocks): - length = short_len - ecc_len + (0 if i < num_short else 1) + for length in _block_lengths(version, 0): block = data[offset:offset + length] - blocks.append((block, _remainder(block, divisor))) + blocks.append(block + _remainder(block, divisor)) offset += length - result = bytearray() - for i in range(short_len - ecc_len + 1): - for block, _ in blocks: - result.extend(block[i:i + 1]) - for i in range(ecc_len): - for _, ecc in blocks: - result.append(ecc[i]) - return bytes(result) + return bytes(blocks[b][i] for b, i in _interleaved(version, 0)) def _multiply(x: int, y: int) -> int: - result = 0 - for _ in range(8): - result = (result << 1) ^ (0x11D if result & 0x80 else 0) - if y & 0x80: - result ^= x - y <<= 1 - return result + return _EXP[(_LOG[x] + _LOG[y]) % 255] if x and y else 0 def _divisor(degree: int) -> bytes: @@ -108,7 +136,7 @@ def _alignment_positions(version: int) -> list[int]: if version == 1: return [] count = version // 7 + 2 - step = ((version * 4 + count * 2 + 1) // (count * 2 - 2)) * 2 + step = (version * 8 + count * 3 + 5) // (count * 4 - 4) * 2 return [6] + [version * 4 + 10 - step * i for i in range(count - 1)][::-1] @@ -171,7 +199,7 @@ class _Qr: def _format(self) -> None: for i in range(15): - bit = ((_FORMAT_BITS >> i) & 1) != 0 + bit = ((_FORMATS[1 << 3 | 0] >> i) & 1) != 0 # level L, mask 0 y_pos = i if i < 6 else i + 1 if i < 8 else self.size - 15 + i self._set_function(8, y_pos, bit) x_pos = self.size - 1 - i if i < 8 else 15 - i if i < 9 else 14 - i @@ -216,3 +244,496 @@ def make_texture(data: str, inverted: bool = False) -> rl.Texture: rl_image.mipmaps = 1 rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 return rl.load_texture_from_image(rl_image) + + +# ---- Symbol structure for decoding ---- + + +class QRError(Exception): + pass + + +_LEVELS = (1, 0, 3, 2) # format info level bits -> column in _EC + +_MASKS = [ + lambda i, j: (i + j) % 2 == 0, + lambda i, j: i % 2 == 0, + lambda i, j: j % 3 == 0, + lambda i, j: (i + j) % 3 == 0, + lambda i, j: (i // 2 + j // 3) % 2 == 0, + lambda i, j: (i * j) % 2 + (i * j) % 3 == 0, + lambda i, j: ((i * j) % 2 + (i * j) % 3) % 2 == 0, + lambda i, j: ((i + j) % 2 + (i * j) % 3) % 2 == 0, +] + +_ALIGNMENT = np.ones((5, 5), dtype=bool) +_ALIGNMENT[1:4, 1:4] = False +_ALIGNMENT[2, 2] = True + + +def _gf_inv(a: int) -> int: + return _EXP[-_LOG[a] % 255] + + +@functools.lru_cache +def _data_coords(version: int) -> tuple[np.ndarray, np.ndarray]: + """(rows, cols) of the data and error correction modules in placement order: two-column zigzag from the right.""" + dim = version * 4 + 17 + func = np.zeros((dim, dim), dtype=bool) # finder, timing, alignment, format, and version modules + func[:9, :9] = func[:9, dim - 8:] = func[dim - 8:, :9] = True + func[6, :] = func[:, 6] = True + positions = _alignment_positions(version) + for r, c in itertools.product(positions, positions): + if (r, c) not in ((6, 6), (6, dim - 7), (dim - 7, 6)): + func[r - 2:r + 3, c - 2:c + 3] = True + if version >= 7: + func[:6, dim - 11:dim - 8] = func[dim - 11:dim - 8, :6] = True + ys = np.arange(dim) + rows, cols = [], [] + # the vertical timing column is skipped, so the pairs left of it start at odd columns + for i, right in enumerate(col if col > 6 else col - 1 for col in range(dim - 1, 0, -2)): + r = np.repeat(ys[::-1] if i % 2 == 0 else ys, 2) + c = np.tile((right, right - 1), dim) + keep = ~func[r, c] + rows.append(r[keep]) + cols.append(c[keep]) + return np.concatenate(rows), np.concatenate(cols) + + +# ---- Matrix decoding ---- + + +def _poly_eval(p: list[int], x: int) -> int: + # p is highest degree first + y = 0 + for c in p: + y = _multiply(y, x) ^ c + return y + + +_EXP_TABLE = np.array(_EXP) +_LOG_TABLE = np.array([_LOG.get(v, 0) for v in range(256)]) + + +def _syndromes(msg: list[int], nsym: int) -> list[int]: + """syn[i] = msg(alpha^i), msg highest degree first.""" + m = np.array(msg) + exponents = np.arange(nsym)[:, None] * (len(msg) - 1 - np.arange(len(msg))) + return np.bitwise_xor.reduce(_EXP_TABLE[(_LOG_TABLE[m] + exponents) % 255] * (m != 0), axis=1).tolist() + + +def _rs_correct(msg: list[int], nsym: int) -> list[int]: + """Corrects up to nsym // 2 errors in a Reed-Solomon codeword, in place.""" + n = len(msg) + syn = _syndromes(msg, nsym) + if not any(syn): + return msg + + # Berlekamp-Massey, sigma is lowest degree first + sigma, prev, L, m, b = [1], [1], 0, 1, 1 + for r in range(nsym): + d = syn[r] + for i in range(1, L + 1): + d ^= _multiply(sigma[i], syn[r - i]) + if d == 0: + m += 1 + continue + coef = _multiply(d, _gf_inv(b)) + shifted = [0] * m + prev + saved = sigma[:] + sigma = sigma + [0] * max(0, len(shifted) - len(sigma)) + for i, c in enumerate(shifted): + sigma[i] ^= _multiply(coef, c) + if 2 * L <= r: + L, prev, b, m = r + 1 - L, saved, d, 1 + else: + m += 1 + sigma = sigma[:L + 1] + if 2 * L > nsym: + raise QRError("too many errors") + + # Chien search: codeword position p has locator alpha^(n-1-p) + positions = [p for p in range(n) if _poly_eval(sigma[::-1], _EXP[(p - n + 1) % 255]) == 0] + if len(positions) != L: + raise QRError("error locator mismatch") + + # solve syn[i] = sum_k e_k * X_k^i for the magnitudes e_k + xlog = [(n - 1 - p) % 255 for p in positions] + A = [[_EXP[(xlog[k] * i) % 255] for k in range(L)] + [syn[i]] for i in range(L)] + for col in range(L): + piv = next((r for r in range(col, L) if A[r][col]), None) + if piv is None: + raise QRError("singular") + A[col], A[piv] = A[piv], A[col] + inv = _gf_inv(A[col][col]) + A[col] = [_multiply(inv, v) for v in A[col]] + for r in range(L): + if r != col and A[r][col]: + f = A[r][col] + A[r] = [a ^ _multiply(f, c) for a, c in zip(A[r], A[col], strict=True)] + for k, p in enumerate(positions): + msg[p] ^= A[k][L] + + if any(_syndromes(msg, nsym)): + raise QRError("uncorrectable") + return msg + + +def _read_format(m: np.ndarray) -> int: + """Returns the closest format info (level bits << 3 | mask) from either copy.""" + dim = m.shape[0] + copies = ([(8, i) for i in range(6)] + [(8, 7), (8, 8), (7, 8)] + [(5 - i, 8) for i in range(6)], + [(dim - 1 - i, 8) for i in range(7)] + [(8, dim - 8 + i) for i in range(8)]) # (row, col), msb first + candidates = [] + for coords in copies: + bits = int("".join(str(int(m[r, c])) for r, c in coords), 2) + candidates += [((bits ^ f).bit_count(), i) for i, f in enumerate(_FORMATS)] + distance, fmt = min(candidates) + if distance > 3: + raise QRError("bad format info") + return fmt + + +_ALNUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:" + +_ECI_ENCODINGS = { + 0: "cp437", 2: "cp437", 1: "iso8859-1", 3: "iso8859-1", + **{i + 2: f"iso8859-{i}" for i in range(2, 17) if i != 12}, + 20: "shift_jis", 21: "cp1250", 22: "cp1251", 23: "cp1252", 24: "cp1256", + 25: "utf-16-be", 26: "utf-8", 27: "ascii", 170: "ascii", 28: "big5", 29: "gb18030", 30: "euc_kr", +} + + +class _Bits: + def __init__(self, data: list[int]): + self._value = int.from_bytes(bytes(data), "big") + self.remaining = len(data) * 8 + + def read(self, n: int) -> int: + if n > self.remaining: + raise QRError("bitstream underflow") + self.remaining -= n + return self._value >> self.remaining & (1 << n) - 1 + + def read_below(self, n: int, limit: int) -> int: + v = self.read(n) + if v >= limit: + raise QRError("value out of range") + return v + + +def _parse_data(data: list[int], version: int) -> str: + bits = _Bits(data) + out: list[str] = [] + encoding = None + band = 0 if version <= 9 else 1 if version <= 26 else 2 + while bits.remaining >= 4: + mode = bits.read(4) + if mode == 0: + break + if mode == 7: # ECI character set assignment + first = bits.read(8) + extra = 0 if first < 0x80 else 8 if first < 0xC0 else 16 if first < 0xE0 else -1 # 1, 2, or 3 byte assignment + if extra < 0: + raise QRError("bad ECI assignment") + assignment = (first & 0x7F >> extra // 8) << extra | bits.read(extra) + encoding = _ECI_ENCODINGS.get(assignment) + if encoding is None: + raise QRError(f"unsupported ECI assignment {assignment}") + elif mode == 1: + n = bits.read((10, 12, 14)[band]) + while n > 0: + k = min(n, 3) # 3 digits in 10 bits, the last 2 or 1 in 7 or 4 + out.append(f"{bits.read_below((4, 7, 10)[k - 1], 10 ** k):0{k}d}") + n -= k + elif mode == 2: + n = bits.read((9, 11, 13)[band]) + while n > 0: + k = min(n, 2) # 2 characters in 11 bits, a last one in 6 + v = bits.read_below((6, 11)[k - 1], 45 ** k) + out.append(_ALNUM[v // 45] * (k - 1) + _ALNUM[v % 45]) + n -= k + elif mode == 4: + n = bits.read((8, 16, 16)[band]) + segment = bytes(bits.read(8) for _ in range(n)) + try: + out.append(segment.decode(encoding or "utf-8")) + except UnicodeDecodeError as e: + if encoding is not None: + raise QRError("invalid ECI byte segment") from e + out.append(segment.decode("latin-1")) + elif mode == 8: + n = bits.read((8, 10, 12)[band]) + for _ in range(n): + v = bits.read(13) + c = (v // 0xC0) << 8 | v % 0xC0 + c += 0x8140 if c < 0x1F00 else 0xC140 + try: + out.append(c.to_bytes(2, "big").decode("shift_jis")) + except UnicodeDecodeError as e: + raise QRError("invalid Kanji character") from e + else: + raise QRError(f"unsupported mode {mode}") + return "".join(out) + + +def decode_matrix(m: np.ndarray) -> str: + """Decodes a square boolean module matrix (True = dark) without a quiet zone.""" + dim = m.shape[0] + if m.shape != (dim, dim) or dim % 4 != 1 or not 21 <= dim <= 177: + raise QRError("bad matrix size") + version = (dim - 17) // 4 + + fmt = _read_format(m) + level = _LEVELS[fmt >> 3] + rows, cols = _data_coords(version) + bits = m[rows, cols] ^ _MASKS[fmt & 7](rows, cols) + codewords = np.packbits(bits[:len(bits) // 8 * 8]).tolist() + + ec, _ = _EC[version - 1][level] + lens = _block_lengths(version, level) + blocks = [[0] * (n + ec) for n in lens] + for (b, i), codeword in zip(_interleaved(version, level), codewords, strict=True): + blocks[b][i] = codeword + + data: list[int] = [] + for block, n in zip(blocks, lens, strict=True): + data += _rs_correct(block, ec)[:n] + return _parse_data(data, version) + + +# ---- Image decoding ---- + + +def _box_sums(a: np.ndarray, radii: tuple[int, ...]) -> list[np.ndarray]: + """Sums over (2r + 1)^2 neighborhoods of the last two axes, edge padded, from one integral image.""" + P = max(radii) + lead = [(0, 0)] * (a.ndim - 2) + cs = np.pad(np.cumsum(np.cumsum(np.pad(a, lead + [(P, P), (P, P)], mode="edge"), -2), -1), lead + [(1, 0), (1, 0)]) + H, W = a.shape[-2:] + out = [] + for r in radii: + lo, hi = P - r, P + r + 1 + out.append(cs[..., hi:hi + H, hi:hi + W] - cs[..., lo:lo + H, hi:hi + W] - cs[..., hi:hi + H, lo:lo + W] + cs[..., lo:lo + H, lo:lo + W]) + return out + + +def _binarize(gray: np.ndarray) -> np.ndarray: + """Adaptive threshold: each pixel against the mean of the surrounding tiles that have contrast.""" + h, w = gray.shape + if h < 21 or w < 21: + raise QRError("image too small") + B = max(8, min(h, w) // 128 * 2) + H, W = -(-h // B), -(-w // B) + padded = np.pad(gray, ((0, H * B - h), (0, W * B - w)), mode="edge") + # block statistics from a subsample are plenty + sub = np.ascontiguousarray(padded[::2, ::2].reshape(H, B // 2, W, B // 2).transpose(0, 2, 1, 3)).reshape(H, W, -1) + blocks = sub.sum(axis=2, dtype=np.uint32) / sub.shape[2] + known = sub.max(axis=2) - sub.min(axis=2) >= 32 + # Flat tiles cannot estimate their own threshold: use the tiles with contrast nearby, then + # further out, then the global midrange. A flat tile is then all dark or all light. + est = np.full((H, W), (blocks.min() + blocks.max()) / 2) + filled = np.zeros((H, W), dtype=bool) + for total, count in _box_sums(np.stack((known * blocks, known.astype(float))), (2, 6)): + fill = ~filled & (count > 0) + est[fill] = total[fill] / count[fill] + filled |= fill + thr = np.where(known, np.minimum(est, 254) + 1, np.where(blocks <= est, 255, 0)).astype(np.uint8) + return (padded.reshape(H, B, W, B) < thr[:, None, :, None]).reshape(H * B, W * B)[:h, :w] + + +class _Runs: + """Run-length table of a padded, flattened binary image with a per-pixel run index.""" + + def __init__(self, padded: np.ndarray): + self.flat = padded.ravel() + self.lines, self.stride = padded.shape + change = self.flat[1:] != self.flat[:-1] + self.starts = np.concatenate(([0], np.flatnonzero(change) + 1)) + self.lengths = np.diff(np.append(self.starts, self.flat.size)).astype(np.int32) + + def run_at(self, line: np.ndarray, pos: np.ndarray) -> np.ndarray: + """Index of the run containing the pixel at `pos` along `line`.""" + return np.searchsorted(self.starts, line * self.stride + pos + 1, side="right") - 1 + + @staticmethod + def _match(lengths: list[np.ndarray], ratios: tuple[int, ...]) -> tuple[np.ndarray, np.ndarray]: + """Checks windows of runs against the ratios, given the length of each run. Returns (ok, module size).""" + S = sum(ratios) + total = sum(lengths[1:], start=lengths[0]) + ok = total >= 2 * S # modules need to be at least 2 px + for L, r in zip(lengths, ratios, strict=True): + ok &= np.abs(2 * S * L - 2 * r * total) <= r * total # integer form of |L - r * total / S| <= r * total / (2 * S) + return ok, total / S + + def scan(self, ratios: tuple[int, ...]) -> np.ndarray: + """Returns the indices of all dark runs starting a window of runs matching the ratios.""" + n = len(ratios) + N = len(self.lengths) - n + 1 + if N <= 0: + return np.zeros(0, dtype=int) + ok, _ = self._match([self.lengths[k:N + k] for k in range(n)], ratios) + ok &= self.flat[self.starts[:N]] + first = np.flatnonzero(ok) + return first[self.starts[first] // self.stride == self.starts[first + n - 1] // self.stride] + + def check(self, first: np.ndarray, ratios: tuple[int, ...]) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Checks the run windows starting at run index `first`. Returns (ok, center position along the line, module size).""" + n, half = len(ratios), len(ratios) // 2 + ok = (first >= 0) & (first + n <= len(self.starts)) + idx = np.clip(first[:, None] + np.arange(n), 0, len(self.starts) - 1) + matched, module = self._match([self.lengths[idx[:, k]] for k in range(n)], ratios) + ok &= matched & self.flat[self.starts[idx[:, 0]]] + ok &= self.starts[idx[:, 0]] // self.stride == self.starts[idx[:, -1]] // self.stride + center = self.starts[idx[:, half]] % self.stride - 1 + self.lengths[idx[:, half]] / 2 + return ok, center, module + + +def _find_patterns(binary: np.ndarray, ratios: tuple[int, ...]) -> list[tuple[float, float, float]]: + """Finds dark/light run patterns with the given module ratios. Returns (x, y, module size).""" + half = len(ratios) // 2 + step = 2 # the center rows of a 2 px finder pattern still get scanned twice + rows_t = _Runs(np.pad(binary[::step], ((0, 0), (1, 1)))) + first = rows_t.scan(ratios) + if len(first) == 0: + return [] + _, cx, hmod = rows_t.check(first, ratios) + row = rows_t.starts[first] // rows_t.stride * step + + xi = cx.astype(int) + xs, col = np.unique(xi, return_inverse=True) + cols_t = _Runs(np.pad(binary[:, xs].T, ((0, 0), (1, 1)))) + ok, cy, vmod = cols_t.check(cols_t.run_at(col, row) - half, ratios) + ok &= (0.5 <= vmod / hmod) & (vmod / hmod <= 2) + line = np.clip(np.rint(cy / step), 0, rows_t.lines - 1).astype(int) + ok2, cx2, hmod2 = rows_t.check(rows_t.run_at(line, xi) - half, ratios) + ok &= ok2 & (0.5 <= hmod2 / vmod) & (hmod2 / vmod <= 2) + + found: list[list[float]] = [] # [x, y, module, count] + for x, y, module in zip(cx2[ok], cy[ok], (hmod2[ok] + vmod[ok]) / 2, strict=True): + for f in found: + if abs(f[0] - x) <= f[2] and abs(f[1] - y) <= f[2] and 0.5 <= f[2] / module <= 2: + c = f[3] + f[0], f[1], f[2], f[3] = (f[0] * c + x) / (c + 1), (f[1] * c + y) / (c + 1), (f[2] * c + module) / (c + 1), c + 1 + break + else: + found.append([x, y, module, 1]) + found.sort(key=lambda f: -f[3]) + return [(f[0], f[1], f[2]) for f in found if f[3] >= 2] + + +def _pick_finders(patterns: list[tuple[float, float, float]]) -> tuple[np.ndarray, np.ndarray, np.ndarray, float]: + """Returns (top-left, top-right, bottom-left) centers and the module size of the most square-looking triple.""" + best = None + for a, b, c in itertools.combinations(patterns[:10], 3): + mods = sorted((a[2], b[2], c[2])) + if mods[2] / mods[0] > 1.5: + continue + pts = [np.array(p[:2]) for p in (a, b, c)] + d = [np.linalg.norm(pts[(i + 1) % 3] - pts[(i + 2) % 3]) for i in range(3)] + tl = int(np.argmax(d)) # opposite the hypotenuse + p1, p2 = pts[(tl + 1) % 3], pts[(tl + 2) % 3] + v1, v2 = p1 - pts[tl], p2 - pts[tl] + n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2) + if n1 == 0 or n2 == 0: + continue + cos = abs(np.dot(v1, v2)) / (n1 * n2) + if cos > 0.35 or not 0.6 <= n1 / n2 <= 1.6: + continue + score = cos + abs(np.log(n1 / n2)) + np.log(mods[2] / mods[0]) + if best is not None and score >= best[0]: + continue + if v1[0] * v2[1] - v1[1] * v2[0] < 0: + p1, p2 = p2, p1 + best = (score, pts[tl], p1, p2, float(sum(mods) / 3)) + if best is None: + raise QRError("no finder patterns") + return best[1:] + + +def _perspective(src: np.ndarray, dst: np.ndarray) -> np.ndarray: + """Homography mapping the four src points onto the four dst points.""" + A = [row for (x, y), (u, v) in zip(src, dst, strict=True) + for row in ([x, y, 1, 0, 0, 0, -u * x, -u * y], [0, 0, 0, x, y, 1, -v * x, -v * y])] + try: + h = np.linalg.solve(np.array(A, dtype=float), np.asarray(dst, dtype=float).ravel()) + except np.linalg.LinAlgError as e: + raise QRError("degenerate geometry") from e + return np.append(h, 1).reshape(3, 3) + + +def _transform(H: np.ndarray, pts: np.ndarray) -> np.ndarray: + p = np.column_stack((pts, np.ones(len(pts)))) @ H.T + return p[:, :2] / p[:, 2:3] + + +def _match_alignment(binary: np.ndarray, est: np.ndarray, offs: np.ndarray, r: int, module: float) -> np.ndarray | None: + h, w = binary.shape + dy = np.arange(max(0, int(est[1]) - r), min(h, int(est[1]) + r)) - est[1] + dx = np.arange(max(0, int(est[0]) - r), min(w, int(est[0]) + r)) - est[0] + if len(dy) == 0 or len(dx) == 0: + return None + y = np.rint(est[1] + dy[:, None, None] + offs[None, None, :, 1]).astype(int) + x = np.rint(est[0] + dx[None, :, None] + offs[None, None, :, 0]).astype(int) + valid = ((y >= 0) & (y < h) & (x >= 0) & (x < w)).all(axis=2) + samples = binary[np.clip(y, 0, h - 1), np.clip(x, 0, w - 1)] + score = np.where(valid, (samples == _ALIGNMENT.ravel()).sum(axis=2), 0) + if score.max() < 23: + return None + hits = np.argwhere(score == score.max()) + centers = np.column_stack((est[0] + dx[hits[:, 1]], est[1] + dy[hits[:, 0]])) + closest = centers[np.argmin(np.linalg.norm(centers - est, axis=1))] + return centers[np.linalg.norm(centers - closest, axis=1) <= module / 2].mean(axis=0) + + +def _locate_alignment(binary: np.ndarray, H: np.ndarray, center: float, module: float) -> np.ndarray | None: + """Template matches the 5x5 alignment pattern around its position estimated from H.""" + grid = np.mgrid[-2:3, -2:3].reshape(2, -1).T[:, ::-1] + center # (25, 2) module coords (x, y) + pts = _transform(H, grid) + # the affine estimate can be off in both position and local scale under perspective + for radius in (2, 4, 8, 16): + for scale in (1.0, 0.8, 1.25, 0.65, 1.5): + found = _match_alignment(binary, pts[12], (pts - pts[12]) * scale, int(module * radius), module) + if found is not None: + return found + return None + + +def _sample(binary: np.ndarray, tl: np.ndarray, tr: np.ndarray, bl: np.ndarray, module: float, dim: int, use_alignment: bool) -> np.ndarray: + src = np.array([(3.5, 3.5), (dim - 3.5, 3.5), (3.5, dim - 3.5), (dim - 3.5, dim - 3.5)]) + dst = np.array([tl, tr, bl, tr + bl - tl]) + H = _perspective(src, dst) + if use_alignment and dim > 21: + align = _locate_alignment(binary, H, dim - 6.5, module) + if align is not None: + src[3], dst[3] = (dim - 6.5, dim - 6.5), align + H = _perspective(src, dst) + + rows, cols = np.mgrid[0:dim, 0:dim] + pts = _transform(H, np.column_stack((cols.ravel() + 0.5, rows.ravel() + 0.5))) + xy = np.rint(pts).astype(int) + h, w = binary.shape + if (xy < 0).any() or (xy[:, 0] >= w).any() or (xy[:, 1] >= h).any(): + raise QRError("code extends outside image") + return binary[xy[:, 1], xy[:, 0]].reshape(dim, dim) + + +def decode(gray: np.ndarray) -> str | None: + """Decodes the QR code in a 2D uint8 grayscale image. Modules need to be at least 2 px. + Returns None if nothing could be decoded.""" + try: + binary = _binarize(gray) + tl, tr, bl, module = _pick_finders(_find_patterns(binary, (1, 1, 3, 1, 1))) + except QRError: + return None + + d = (np.linalg.norm(tr - tl) + np.linalg.norm(bl - tl)) / 2 + dim = int(round((d / module + 7 - 17) / 4)) * 4 + 17 + dims = [cand for cand in (dim, dim - 4, dim + 4) if 21 <= cand <= 177] + for cand, use_alignment, transpose in itertools.product(dims, (True, False), (False, True)): + try: + m = _sample(binary, tl, tr, bl, module, cand, use_alignment) + return decode_matrix(m.T if transpose else m) + except QRError: + pass + return None diff --git a/openpilot/common/realtime.py b/openpilot/common/realtime.py index e0b9e8a3a5..792226b08c 100644 --- a/openpilot/common/realtime.py +++ b/openpilot/common/realtime.py @@ -91,8 +91,13 @@ class Ratekeeper: self.avg_dt.add_value(self._last_monitor_time - prev) lagged = False - remaining = self._next_frame_time - time.monotonic() - self._next_frame_time += self._interval + now = time.monotonic() + remaining = self._next_frame_time - now + if remaining < -self._interval: + # more than a frame behind (e.g. a stall): resync instead of running catch-up frames back-to-back + self._next_frame_time = now + self._interval + else: + self._next_frame_time += self._interval if self._print_delay_threshold is not None and remaining < -self._print_delay_threshold: print(f"{self._process_name} lagging by {-remaining * 1000:.2f} ms") lagged = True diff --git a/openpilot/common/tests/fixtures/qrcode_0.npz b/openpilot/common/tests/fixtures/qrcode_0.npz new file mode 100644 index 0000000000..96f550e5f4 Binary files /dev/null and b/openpilot/common/tests/fixtures/qrcode_0.npz differ diff --git a/openpilot/common/tests/fixtures/qrcode_1.npz b/openpilot/common/tests/fixtures/qrcode_1.npz new file mode 100644 index 0000000000..8ddd170935 Binary files /dev/null and b/openpilot/common/tests/fixtures/qrcode_1.npz differ diff --git a/openpilot/common/tests/fixtures/qrcode_2.npz b/openpilot/common/tests/fixtures/qrcode_2.npz new file mode 100644 index 0000000000..e0c62e9f63 Binary files /dev/null and b/openpilot/common/tests/fixtures/qrcode_2.npz differ diff --git a/openpilot/common/tests/fixtures/qrcode_3.npz b/openpilot/common/tests/fixtures/qrcode_3.npz new file mode 100644 index 0000000000..8991156679 Binary files /dev/null and b/openpilot/common/tests/fixtures/qrcode_3.npz differ diff --git a/openpilot/common/tests/fixtures/qrcode_eci.npz b/openpilot/common/tests/fixtures/qrcode_eci.npz new file mode 100644 index 0000000000..93abae0bbe Binary files /dev/null and b/openpilot/common/tests/fixtures/qrcode_eci.npz differ diff --git a/openpilot/common/tests/test_qrcode.py b/openpilot/common/tests/test_qrcode.py new file mode 100644 index 0000000000..bc664d2ee1 --- /dev/null +++ b/openpilot/common/tests/test_qrcode.py @@ -0,0 +1,178 @@ +import hashlib +import math +from pathlib import Path + +import numpy as np + +from openpilot.common import qrcode as qr +from openpilot.common.test import OpenpilotTestCase + +LPA = "LPA:1$rsp.truphone.com$QRF-BETTERROAMING-PMRDGIR2EARDEIT5" + + +# Matrices generated with python-qrcode 8.2, covering all versions and EC levels. +# Packed fixtures keep the decoder tests independent of our encoder. +FIXTURES = {} +for path in Path(__file__).with_name("fixtures").glob("qrcode_*.npz"): + with np.load(path) as fixtures: + FIXTURES.update({key: fixtures[key] for key in fixtures.files}) + + +def fixture(key: str) -> np.ndarray: + bits = np.unpackbits(FIXTURES[key]) + size = math.isqrt(len(bits)) + return bits[:size * size].reshape(size, size).astype(bool) + + +def render(matrix: np.ndarray, box: int = 6, border: int = 4) -> np.ndarray: + img = np.repeat(np.repeat(np.pad(matrix, border), box, axis=0), box, axis=1) + return np.where(img, 0, 255).astype(np.uint8) + + +def make(data: str, version: int | None = None, level: int = 0, box: int = 6, border: int = 4): + matrix = fixture(hashlib.sha256(f"{version}:{level}:{data}".encode()).hexdigest()) + return matrix, render(matrix, box, border) + + +def warp(img: np.ndarray, H: np.ndarray) -> np.ndarray: + """Bilinear resampling through the output -> input homography H, white outside the image.""" + h, w = img.shape + rows, cols = np.mgrid[0:h, 0:w] + pts = qr._transform(H, np.column_stack((cols.ravel() + 0.5, rows.ravel() + 0.5))) - 0.5 + x0, y0 = np.floor(pts[:, 0]).astype(int), np.floor(pts[:, 1]).astype(int) + fx, fy = pts[:, 0] - x0, pts[:, 1] - y0 + padded = np.pad(img.astype(float), 1, constant_values=255) + + def at(y, x): + return padded[np.clip(y + 1, 0, h + 1), np.clip(x + 1, 0, w + 1)] + + out = at(y0, x0) * (1 - fx) * (1 - fy) + at(y0, x0 + 1) * fx * (1 - fy) + at(y0 + 1, x0) * (1 - fx) * fy + at(y0 + 1, x0 + 1) * fx * fy + return np.clip(out, 0, 255).astype(np.uint8).reshape(h, w) + + +def rotate(img: np.ndarray, angle: float) -> np.ndarray: + h, w = img.shape + t = np.radians(angle) + R = np.array([[np.cos(t), -np.sin(t)], [np.sin(t), np.cos(t)]]) + center = np.array([w / 2, h / 2]) + corners = np.array([(0, 0), (w, 0), (w, h), (0, h)], dtype=float) + return warp(img, qr._perspective((corners - center) @ R.T + center, corners)) + + +class TestQRCode(OpenpilotTestCase): + def test_alignment_positions(self): + assert qr._alignment_positions(7) == [6, 22, 38] + assert qr._alignment_positions(32) == [6, 34, 60, 86, 112, 138] + assert qr._alignment_positions(40) == [6, 30, 58, 86, 114, 142, 170] + + def test_all_versions(self): + for version in range(1, 41): + for level in range(4): + with self.subTest(version=version, level=level): + data = "".join(chr(ord("a") + i % 26) for i in range(version)) + matrix, img = make(data, version, level, box=3) + assert qr.decode_matrix(matrix) == data + assert qr.decode(img) == data + + def test_modes(self): + for data in ["0123456789012345", "HELLO WORLD $1.50", LPA, "こんにちは", "ünïcødé", "mixed 123 ABC xyz"]: + with self.subTest(data=data): + matrix, img = make(data) + assert qr.decode_matrix(matrix) == data + assert qr.decode(img) == data + + def test_error_correction(self): + matrix, _ = make(LPA, level=2) + rng = np.random.default_rng(0) + flipped = matrix.copy() + for r, c in rng.integers(9, matrix.shape[0] - 9, size=(40, 2)): + flipped[r, c] ^= True + assert qr.decode_matrix(flipped) == LPA + + def test_large_modules(self): + for data in ["0123456789012345", "HELLO WORLD $1.50", LPA, "mixed 123 ABC xyz"]: + for box in [16, 20, 24, 32]: + for dark, light in [(0, 255), (60, 200), (140, 250)]: + with self.subTest(data=data, box=box, dark=dark): + _, img = make(data, box=box) + img = np.where(img == 0, dark, light).astype(np.uint8) + assert qr.decode(img) == data + + def test_image_edges(self): + # a code touching the image edge must not lose the rows and columns left over from tiling + matrix, _ = make(LPA) + for size in (200, 203): + with self.subTest(size=size): + img = np.full((size, size), 255, dtype=np.uint8) + code = render(matrix, box=5, border=0) + img[size - code.shape[0]:, size - code.shape[1]:] = code + assert qr.decode(img) == LPA + + def test_eci(self): + # qrcode_eci.npz: packed Segno 1.6.6 matrices, generated with mode='byte', + # eci=True, micro=False and the named encoding. Mixed also includes numeric, + # alphanumeric, and Kanji segments after changing the byte encoding twice. + cases = { + "iso8859-5": "Привет", "utf-16-be": "héllo", "utf-8": "こんにちは", + "shift_jis": "日本語", "cp1251": "Привет", "iso8859-1": "héllo", + "mixed": "hélloПривет日本語123ABC漢字", + } + for encoding, expected in cases.items(): + with self.subTest(encoding=encoding): + matrix = fixture(encoding) + assert qr.decode_matrix(matrix) == expected + assert qr.decode(render(matrix)) == expected + + def test_parse_data(self): + def parse(stream: str) -> str: + stream += '0' * (-len(stream) % 8) + return qr._parse_data([int(stream[i:i + 8], 2) for i in range(0, len(stream), 8)], 1) + + def eci(assignment: str, payload: bytes = b'A') -> str: + return parse('0111' + assignment + '0100' + f'{len(payload):08b}' + ''.join(f'{b:08b}' for b in payload) + '0000') + + # ASCII assignment 170 uses the two-byte ECI representation. + assert eci('1000000010101010') == 'A' + for assignment in ['00001110', '1000001111100111', '110000010000000000000000', '11100000']: + with self.subTest(assignment=assignment), self.assertRaises(qr.QRError): + eci(assignment) + with self.assertRaises(qr.QRError): + eci('00011010', b'\xff') # Invalid UTF-8 must not fall back to Latin-1. + + # out-of-range numeric, alphanumeric, and Kanji values are format errors, not crashes + for stream in ['0001' + '0000000011' + '1111111111', '0001' + '0000000010' + '1111111', + '0010' + '000000010' + '11111111111', '0010' + '000000001' + '111111', + '1000' + '00000001' + '0000000111111']: + with self.subTest(stream=stream), self.assertRaises(qr.QRError): + parse(stream) + + def test_rotation(self): + for angle in [0, 90, 180, 270, 25, 110]: + with self.subTest(angle=angle): + _, img = make(LPA, box=8, border=12) + assert qr.decode(rotate(img, angle)) == LPA + + def test_mirrored(self): + _, img = make(LPA) + assert qr.decode(img[:, ::-1]) == LPA + + def test_perspective_and_noise(self): + _, img = make(LPA, box=10, border=8) + h, w = img.shape + corners = np.array([(40, 60), (w - 20, 30), (w - 60, h - 40), (30, h - 90)]) + arr = warp(img, qr._perspective(corners, np.array([(0, 0), (w, 0), (w, h), (0, h)]))).astype(float) + rng = np.random.default_rng(1) + arr = arr * 0.6 + 60 + rng.normal(0, 12, arr.shape) # low contrast + noise + # uneven lighting + arr += np.linspace(-40, 40, w)[None, :] + assert qr.decode(np.clip(arr, 0, 255).astype(np.uint8)) == LPA + + def test_no_code(self): + rng = np.random.default_rng(2) + assert qr.decode(rng.integers(0, 256, size=(240, 320), dtype=np.uint8)) is None + assert qr.decode(np.full((240, 320), 200, dtype=np.uint8)) is None + + def test_encoder_roundtrip(self): + for version in range(1, 21): + with self.subTest(version=version): + assert qr.decode_matrix(np.array(qr._Qr(version, b"hello").modules)) == "hello" diff --git a/openpilot/common/utils.py b/openpilot/common/utils.py index a73d2b8226..28b3782a4c 100644 --- a/openpilot/common/utils.py +++ b/openpilot/common/utils.py @@ -5,7 +5,6 @@ import contextlib import subprocess import time import functools -from subprocess import Popen, PIPE, TimeoutExpired import zstandard as zstd LOG_COMPRESSION_LEVEL = 10 # little benefit up to level 15. level ~17 is a small step change @@ -157,20 +156,6 @@ def run_cmd_default(cmd: list[str], default: str = "", cwd=None, env=None) -> st return default -@contextlib.contextmanager -def managed_proc(cmd: list[str], env: dict[str, str]): - proc = Popen(cmd, env=env, stdout=PIPE, stderr=PIPE) - try: - yield proc - finally: - if proc.poll() is None: - proc.terminate() - try: - proc.wait(timeout=5) - except TimeoutExpired: - proc.kill() - - def tabulate(tabular_data, headers=(), tablefmt="simple", floatfmt="g", stralign="left", numalign=None): rows = [list(row) for row in tabular_data] diff --git a/openpilot/common/version.h b/openpilot/common/version.h index 5a09588e22..16b8750286 100644 --- a/openpilot/common/version.h +++ b/openpilot/common/version.h @@ -1 +1 @@ -#define COMMA_VERSION "0.11.2" +#define COMMA_VERSION "0.11.3" diff --git a/openpilot/selfdrive/assets/icons/couch.png b/openpilot/selfdrive/assets/icons/couch.png deleted file mode 100644 index 677ad0f9ee..0000000000 --- a/openpilot/selfdrive/assets/icons/couch.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:911f59f248015600da7ecc689398103d47dfc57f6be17ac8c8e543a726a6c64b -size 3311 diff --git a/openpilot/selfdrive/assets/icons/couch.svg b/openpilot/selfdrive/assets/icons/couch.svg deleted file mode 100644 index f56970f289..0000000000 --- a/openpilot/selfdrive/assets/icons/couch.svg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:dd5d8d3fce5e30662797f7eeed224f5775b2cabcc055f01f5ebf6e7b2657b616 -size 1801 diff --git a/openpilot/selfdrive/assets/icons/experimental.png b/openpilot/selfdrive/assets/icons/experimental.png deleted file mode 100644 index 2332fe11d0..0000000000 --- a/openpilot/selfdrive/assets/icons/experimental.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1e58deb1778cf2826339f27e9f09eecc79ea137c1436210c14b71c352a649c77 -size 34953 diff --git a/openpilot/selfdrive/assets/icons/experimental.svg b/openpilot/selfdrive/assets/icons/experimental.svg deleted file mode 100644 index 8a97cdeac1..0000000000 --- a/openpilot/selfdrive/assets/icons/experimental.svg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b7ab989c9fe22e7d2119bac5284cdccfba2889477d69f5f6c6b4470c7ee0aab3 -size 1801 diff --git a/openpilot/selfdrive/assets/icons/experimental_grey.png b/openpilot/selfdrive/assets/icons/experimental_grey.png deleted file mode 100644 index 058c3e1358..0000000000 --- a/openpilot/selfdrive/assets/icons/experimental_grey.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8f37a02dd914405c6f86f415700dd5985eb976b923e7abd6580d2da76533594e -size 9466 diff --git a/openpilot/selfdrive/assets/icons/experimental_grey.svg b/openpilot/selfdrive/assets/icons/experimental_grey.svg deleted file mode 100644 index 25ab33f388..0000000000 --- a/openpilot/selfdrive/assets/icons/experimental_grey.svg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:90d441310f8e1833c661d8a4f0546aab2eb50eb21cc21beb0aff0d27b5ee6066 -size 1571 diff --git a/openpilot/selfdrive/assets/icons/experimental_white.png b/openpilot/selfdrive/assets/icons/experimental_white.png deleted file mode 100644 index 6a9cfc4407..0000000000 --- a/openpilot/selfdrive/assets/icons/experimental_white.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6b2dad33fead9f064c3a548651d6ef37daf82b6127c329683f538ec6e986ecbc -size 11204 diff --git a/openpilot/selfdrive/assets/icons/experimental_white.svg b/openpilot/selfdrive/assets/icons/experimental_white.svg deleted file mode 100644 index 51d7698947..0000000000 --- a/openpilot/selfdrive/assets/icons/experimental_white.svg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:82648254da89edb0cf65ef63fc5e6e0741414051bfea8a40449132fd61b1ed0f -size 1533 diff --git a/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png b/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png index d3982abf87..41316e610c 100644 --- a/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png +++ b/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:845c40ff0d37612e8f2f482a36845744b5ae91ce2fcfc8117990d7d278b59820 -size 13079 +oid sha256:bf97a6738b294ac0aed9b2d075916cee0b7d3215bcd23381760900ede6a92748 +size 13256 diff --git a/openpilot/selfdrive/assets/icons_mici/experimental_mode.png b/openpilot/selfdrive/assets/icons_mici/experimental_mode.png deleted file mode 100644 index 75850d08f5..0000000000 --- a/openpilot/selfdrive/assets/icons_mici/experimental_mode.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:01841b602632c66ab14a8e52b874a1623f09641dc2ef0620f4e2d00bb4a913f3 -size 16243 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/car.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/car.png new file mode 100644 index 0000000000..221a8e1ceb --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/car.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0936a3d890a3ad68ffe021e4905b9b9e59ffc2ae19ee9c703daa3e7c92e7c78 +size 1793 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/car_green.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/car_green.png new file mode 100644 index 0000000000..82d757faf6 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/car_green.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57c7ba72642b0350ee764c924586fa66ff94915be4aba1813797e67eb4b74376 +size 33215 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1.png new file mode 100644 index 0000000000..3bccf1b0be --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71dc069bd3dc5b11ba8e1b52eb3a1025ee0ad8c82834f94b4b2269ec0d401920 +size 237 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1_green.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1_green.png new file mode 100644 index 0000000000..038d82958f --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1_green.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73a6fe02cf19105c0051a6664a424c1bf2ede364c6cf3791f235c86ffa203f39 +size 10348 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2.png new file mode 100644 index 0000000000..c164b7b9d4 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f500f118dca3eedbd86fc6cc943ad8be67fe5d4f7b474d5b452cd757ad805c3 +size 282 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2_green.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2_green.png new file mode 100644 index 0000000000..b2b56b6c01 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2_green.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a49996f0ce069a048abe94ba9424de655dcff53745676ab35e045204af88a3c +size 12103 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3.png new file mode 100644 index 0000000000..ef4b57207d --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8542374779f1908dc52c8a126bc041efea4c4d605afdd34e3bf8e520a7e78bf2 +size 320 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3_green.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3_green.png new file mode 100644 index 0000000000..f4688fc2d3 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3_green.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21e656b387c1a7e8de54d3e1b1c246f442cffe3c5e0924273d6d6d897c00b568 +size 13868 diff --git a/openpilot/selfdrive/assets/icons_mici/onroad/bookmark.png b/openpilot/selfdrive/assets/icons_mici/onroad/bookmark.png index 305561f509..75a19e7983 100644 --- a/openpilot/selfdrive/assets/icons_mici/onroad/bookmark.png +++ b/openpilot/selfdrive/assets/icons_mici/onroad/bookmark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd91685bf656e828648acf035a4737acb2c4709e8514cf0aa0a10fa470a9bb60 -size 11580 +oid sha256:92431d37a844206570d73d48f39dd8d934539a89668fe8d5f4cccae38f7b301b +size 11530 diff --git a/openpilot/selfdrive/assets/icons_mici/onroad/bookmark_fill.png b/openpilot/selfdrive/assets/icons_mici/onroad/bookmark_fill.png new file mode 100644 index 0000000000..94cc355358 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/onroad/bookmark_fill.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3efe7d12ae82e0f5a1bd773129cf1eafe3c8c36ff5cba43a6c95f295706062b5 +size 19724 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/apple.png b/openpilot/selfdrive/assets/icons_mici/settings/device/apple.png new file mode 100644 index 0000000000..04af670a48 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/apple.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dfb645e59636687d61d20283bae65b68ddda6f7b2d4fda01a6e613a842e3b41f +size 823 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/commacare.png b/openpilot/selfdrive/assets/icons_mici/settings/device/commacare.png new file mode 100644 index 0000000000..0e484035cf --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/commacare.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df07383a6ddfe8cd7eeb2f22271cbd13068327ca0978873f8d10368860562e40 +size 111083 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/github.png b/openpilot/selfdrive/assets/icons_mici/settings/device/github.png new file mode 100644 index 0000000000..136c09aac7 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/github.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7518f3409e2ff8f7e99fb7f0c06afa8bdf0b0a506147b5cf9d4931b820a1a75 +size 1107 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/google.png b/openpilot/selfdrive/assets/icons_mici/settings/device/google.png new file mode 100644 index 0000000000..daeb574532 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/google.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a18e3de5f455b4089a824da8d5d78d5d532ef71facb05cc75c178b24d5beeb7 +size 920 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/green_cell.png b/openpilot/selfdrive/assets/icons_mici/settings/device/green_cell.png new file mode 100644 index 0000000000..99635bcc2b --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/green_cell.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:710e74b526adb5bbac15cd8915a828dd673dd8ce15b2fd694c64c182eef6f56f +size 13744 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/green_settings.png b/openpilot/selfdrive/assets/icons_mici/settings/device/green_settings.png new file mode 100644 index 0000000000..230beb85dd --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/green_settings.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25db08a9038b5faa3b200ef5625cb216d7f4b31ea3d49fb0395f9b726bfaf8e6 +size 13135 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/paired_apple.png b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_apple.png new file mode 100644 index 0000000000..75708bf51c --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_apple.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:547f466cc59d29647f3bf131d8444d7f22b63b186bcea55ceb8109c68ca74fe8 +size 2345 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/paired_github.png b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_github.png new file mode 100644 index 0000000000..8514100359 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_github.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55050096cd98e7f4ea3e843d927c18815e458dfbd5e7d100b503db92075d061b +size 2373 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/paired_google.png b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_google.png new file mode 100644 index 0000000000..853de37d9d --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_google.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f9c439ed89bc0357e86c005d5dbb6f1ad2b8d72cdc0890e3201d842d8c3928f1 +size 2443 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/phone.png b/openpilot/selfdrive/assets/icons_mici/settings/device/phone.png new file mode 100644 index 0000000000..6114cd9124 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/phone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7aeccd3e0d8517f74eb34f7d73632c0b29b0ad5df5b9686c0f509bb6b136561e +size 1728 diff --git a/openpilot/selfdrive/assets/prep-svg.sh b/openpilot/selfdrive/assets/prep-svg.sh index 567c17da32..ee6ba0a3ac 100755 --- a/openpilot/selfdrive/assets/prep-svg.sh +++ b/openpilot/selfdrive/assets/prep-svg.sh @@ -23,8 +23,8 @@ done # sudo apt install inkscape -for svg in $(find $DIR -type f | grep svg$); do - bunx svgo $svg --multipass --pretty --indent 2 +for svg in $(find "$DIR" -type f | grep svg$); do + bunx svgo "$svg" --multipass --pretty --indent 2 # convert to PNG png="${svg%.svg}.png" diff --git a/openpilot/selfdrive/assets/sounds/critical_max.wav b/openpilot/selfdrive/assets/sounds/critical_max.wav new file mode 100644 index 0000000000..c3ad9de84d --- /dev/null +++ b/openpilot/selfdrive/assets/sounds/critical_max.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d02369e21d3c78a3a5ece7e78deedb82343bc3228a28db73da48b5c27860004 +size 52374 diff --git a/openpilot/selfdrive/assets/sounds/dm_critical_max.wav b/openpilot/selfdrive/assets/sounds/dm_critical_max.wav new file mode 100644 index 0000000000..17eb686ee2 --- /dev/null +++ b/openpilot/selfdrive/assets/sounds/dm_critical_max.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3bef898e4e784baf98f3ceca53cbe95b1020da5e01162cdaa14b79b1ce0b1340 +size 52374 diff --git a/openpilot/selfdrive/assets/sounds/warning.wav b/openpilot/selfdrive/assets/sounds/warning.wav index bb71a0176a..dae0658ef0 100644 --- a/openpilot/selfdrive/assets/sounds/warning.wav +++ b/openpilot/selfdrive/assets/sounds/warning.wav @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1bb440f424c989c06e203bfba0b32d39aede8f6c78a9b335e5e778460202b601 +oid sha256:884e828e5c51d980d41b6e2400fcb4f4a926275018a00674d8239184e1eb96db size 73026 diff --git a/openpilot/selfdrive/car/card.py b/openpilot/selfdrive/car/card.py index e253788e5e..137c6f503e 100755 --- a/openpilot/selfdrive/car/card.py +++ b/openpilot/selfdrive/car/card.py @@ -127,9 +127,6 @@ class Car: set_alternative_experience(self.CP, self.CP_SP, self.params) set_car_specific_params(self.CP, self.CP_SP, self.params) - # Dynamic Experimental Control - self.dynamic_experimental_control = self.params.get_bool("DynamicExperimentalControl") - openpilot_enabled_toggle = self.params.get_bool("OpenpilotEnabledToggle") controller_available = self.CI.CC is not None and openpilot_enabled_toggle and not self.CP.dashcamOnly self.CP.passive = not controller_available or self.CP.dashcamOnly @@ -181,14 +178,11 @@ class Car: self.v_cruise_helper = VCruiseHelper(self.CP, self.CP_SP) self.is_metric = self.params.get_bool("IsMetric") - self.experimental_mode = self.params.get_bool("ExperimentalMode") + self.initial_cruise_speed = self.params.get("InitialCruiseSpeedKph", return_default=True) # card is driven by can recv, expected at 100Hz self.rk = Ratekeeper(100, print_delay_threshold=None) - # log fingerprint in sentry - sunnypilot_interfaces.log_fingerprint(self.CP) - def state_update(self) -> tuple[car.CarState, custom.CarStateSP, structs.RadarDataT | None]: """carState update loop, driven by can""" @@ -217,7 +211,7 @@ class Car: self.v_cruise_helper.update_v_cruise(CS, self.sm['carControl'].enabled, self.is_metric) if self.sm['carControl'].enabled and not self.CC_prev.enabled: # Use CarState w/ buttons from the step selfdrived enables on - self.v_cruise_helper.initialize_v_cruise(self.CS_prev, self.experimental_mode, self.dynamic_experimental_control) + self.v_cruise_helper.initialize_v_cruise(self.CS_prev, self.initial_cruise_speed) # TODO: mirror the carState.cruiseState struct? CS.vCruise = float(self.v_cruise_helper.v_cruise_kph) @@ -302,10 +296,9 @@ class Car: def params_thread(self, evt): while not evt.is_set(): self.is_metric = self.params.get_bool("IsMetric") - self.experimental_mode = self.params.get_bool("ExperimentalMode") and self.CP.openpilotLongitudinalControl + self.initial_cruise_speed = self.params.get("InitialCruiseSpeedKph", return_default=True) # sunnypilot - self.dynamic_experimental_control = self.params.get_bool("DynamicExperimentalControl") self.v_cruise_helper.read_custom_set_speed_params() time.sleep(0.1) diff --git a/openpilot/selfdrive/car/cruise.py b/openpilot/selfdrive/car/cruise.py index b554c1a978..ab2eaba020 100644 --- a/openpilot/selfdrive/car/cruise.py +++ b/openpilot/selfdrive/car/cruise.py @@ -13,7 +13,6 @@ V_CRUISE_MIN = 8 V_CRUISE_MAX = 145 V_CRUISE_UNSET = 255 V_CRUISE_INITIAL = 40 -V_CRUISE_INITIAL_EXPERIMENTAL_MODE = 105 IMPERIAL_INCREMENT = round(CV.MPH_TO_KPH, 1) # round here to avoid rounding errors incrementing set speed ButtonEvent = car.CarState.ButtonEvent @@ -138,17 +137,14 @@ class VCruiseHelper(VCruiseHelperSP): self.button_timers[b.type.raw] = 1 if b.pressed else 0 self.button_change_states[b.type.raw] = {"standstill": CS.cruiseState.standstill, "enabled": enabled} - def initialize_v_cruise(self, CS, experimental_mode: bool, dynamic_experimental_control: bool) -> None: + def initialize_v_cruise(self, CS, initial_speed: int) -> None: # initializing is handled by the PCM if self.CP.pcmCruise: return - initial_experimental_mode = experimental_mode and not dynamic_experimental_control - initial = V_CRUISE_INITIAL_EXPERIMENTAL_MODE if initial_experimental_mode else V_CRUISE_INITIAL - if any(b.type in (ButtonType.accelCruise, ButtonType.resumeCruise) for b in CS.buttonEvents) and self.v_cruise_initialized: self.v_cruise_kph = self.v_cruise_kph_last else: - self.v_cruise_kph = int(round(np.clip(CS.vEgo * CV.MS_TO_KPH, initial, V_CRUISE_MAX))) + self.v_cruise_kph = int(round(np.clip(max(CS.vEgo * CV.MS_TO_KPH, initial_speed), V_CRUISE_INITIAL, V_CRUISE_MAX))) self.v_cruise_cluster_kph = self.v_cruise_kph diff --git a/openpilot/selfdrive/car/tests/test_cruise_speed.py b/openpilot/selfdrive/car/tests/test_cruise_speed.py index 60af346838..1f8d37cf74 100644 --- a/openpilot/selfdrive/car/tests/test_cruise_speed.py +++ b/openpilot/selfdrive/car/tests/test_cruise_speed.py @@ -14,7 +14,7 @@ ButtonEvent = car.CarState.ButtonEvent ButtonType = car.CarState.ButtonEvent.Type -def run_cruise_simulation(cruise, e2e, personality, t_end=20.): +def run_cruise_simulation(cruise, personality, t_end=20.): man = Maneuver( '', duration=t_end, @@ -24,7 +24,6 @@ def run_cruise_simulation(cruise, e2e, personality, t_end=20.): cruise_values=[cruise], prob_lead_values=[0.0], breakpoints=[0.], - e2e=e2e, personality=personality, ) valid, output = man.evaluate() @@ -32,8 +31,7 @@ def run_cruise_simulation(cruise, e2e, personality, t_end=20.): return output[-1, 3] -@parameterized_class(("e2e", "personality", "speed"), itertools.product( - [True, False], # e2e +@parameterized_class(("personality", "speed"), itertools.product( log.LongitudinalPersonality.schema.enumerants, # personality [5,35])) # speed class TestCruiseSpeed(OpenpilotTestCase): @@ -41,7 +39,7 @@ class TestCruiseSpeed(OpenpilotTestCase): print(f'Testing {self.speed} m/s') cruise_speed = float(self.speed) - simulation_steady_state = run_cruise_simulation(cruise_speed, self.e2e, self.personality) + simulation_steady_state = run_cruise_simulation(cruise_speed, self.personality) self.assertAlmostEqual(simulation_steady_state, cruise_speed, delta=.01, msg=f'Did not reach {self.speed} m/s') @@ -59,16 +57,16 @@ class TestVCruiseHelper(OpenpilotTestCase): for _ in range(2): self.v_cruise_helper.update_v_cruise(car.CarState(cruiseState={"available": False}), enabled=False, is_metric=False) - def enable(self, v_ego, experimental_mode, dynamic_experimental_control): + def enable(self, v_ego): # Simulates user pressing set with a current speed - self.v_cruise_helper.initialize_v_cruise(car.CarState(vEgo=v_ego), experimental_mode, dynamic_experimental_control) + self.v_cruise_helper.initialize_v_cruise(car.CarState(vEgo=v_ego), V_CRUISE_INITIAL) def test_adjust_speed(self): """ Asserts speed changes on falling edges of buttons. """ - self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS) for btn in (ButtonType.accelCruise, ButtonType.decelCruise): for pressed in (True, False): @@ -92,7 +90,7 @@ class TestVCruiseHelper(OpenpilotTestCase): CS.buttonEvents = [ButtonEvent(type=ButtonType.decelCruise, pressed=pressed)] self.v_cruise_helper.update_v_cruise(CS, enabled=enabled, is_metric=False) if pressed: - self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS) # Expected diff on enabling. Speed should not change on falling edge of pressed assert (not pressed) == (self.v_cruise_helper.v_cruise_kph == self.v_cruise_helper.v_cruise_kph_last) @@ -102,7 +100,7 @@ class TestVCruiseHelper(OpenpilotTestCase): Asserts we don't increment set speed if user presses resume/accel to exit cruise standstill. """ - self.enable(0, False, False) + self.enable(0) for standstill in (True, False): for pressed in (True, False): @@ -122,7 +120,7 @@ class TestVCruiseHelper(OpenpilotTestCase): for v_ego in np.linspace(0, 100, 101): self.reset_cruise_speed_state() - self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS) # first decrement speed, then perform gas pressed logic expected_v_cruise_kph = self.v_cruise_helper.v_cruise_kph - IMPERIAL_INCREMENT @@ -143,12 +141,10 @@ class TestVCruiseHelper(OpenpilotTestCase): Asserts allowed cruise speeds on enabling with SET. """ - for experimental_mode in (True, False): - for dynamic_experimental_control in (True, False): - for v_ego in np.linspace(0, 100, 101): - self.reset_cruise_speed_state() - assert not self.v_cruise_helper.v_cruise_initialized + for v_ego in np.linspace(0, 100, 101): + self.reset_cruise_speed_state() + assert not self.v_cruise_helper.v_cruise_initialized - self.enable(float(v_ego), experimental_mode, dynamic_experimental_control) - assert V_CRUISE_INITIAL <= self.v_cruise_helper.v_cruise_kph <= V_CRUISE_MAX - assert self.v_cruise_helper.v_cruise_initialized + self.enable(float(v_ego)) + assert V_CRUISE_INITIAL <= self.v_cruise_helper.v_cruise_kph <= V_CRUISE_MAX + assert self.v_cruise_helper.v_cruise_initialized diff --git a/openpilot/selfdrive/controls/lib/accel_boost.py b/openpilot/selfdrive/controls/lib/accel_boost.py new file mode 100644 index 0000000000..17920c6daa --- /dev/null +++ b/openpilot/selfdrive/controls/lib/accel_boost.py @@ -0,0 +1,38 @@ +import numpy as np +from openpilot.common.constants import CV +from openpilot.common.realtime import DT_MDL + +ACCEL_BOOST_MAX = 0.2 +ACCEL_BOOST_RATE = 0.025 +ACCEL_BOOST_PER_OVERRIDE = 0.05 +ACCEL_BOOST_MIN_SPEED = 10 * CV.MPH_TO_MS + + +class AccelBoost: + def __init__(self): + self.total_boost = 0.0 + self.boost_this_override = 0.0 + self.boost_eligible = False + + def update(self, sm, output_a_target_e2e, output_a_target_mpc, a_cruise): + enabled = sm['selfdriveState'].enabled + gas_pressed = sm['carState'].gasPressed + v_ego = sm['carState'].vEgo + speed_scale = np.interp(v_ego, [ACCEL_BOOST_MIN_SPEED, 2 * ACCEL_BOOST_MIN_SPEED], [0.0, 1.0]) + boost_scale = speed_scale * np.interp(output_a_target_e2e, [-1.0, -0.5, 5.0], [0.0, 1.0, 1.0], right=0.0) + model_limited = output_a_target_e2e + self.total_boost * boost_scale < min(output_a_target_mpc, a_cruise) - 0.1 + + if not enabled or not gas_pressed: + self.boost_this_override = 0.0 + # Latch eligibility before the override changes the lead plan. + self.boost_eligible = enabled and model_limited + + if not enabled: + self.total_boost = 0.0 + elif gas_pressed and self.boost_eligible: + increase = min(ACCEL_BOOST_RATE * DT_MDL * speed_scale, + ACCEL_BOOST_PER_OVERRIDE - self.boost_this_override, ACCEL_BOOST_MAX - self.total_boost) + self.total_boost += increase + self.boost_this_override += increase + + return output_a_target_e2e + self.total_boost * boost_scale diff --git a/openpilot/selfdrive/controls/lib/drive_helpers.py b/openpilot/selfdrive/controls/lib/drive_helpers.py index 35ca45e56e..c350cff632 100644 --- a/openpilot/selfdrive/controls/lib/drive_helpers.py +++ b/openpilot/selfdrive/controls/lib/drive_helpers.py @@ -4,7 +4,6 @@ from openpilot.common.realtime import DT_CTRL, DT_MDL MIN_SPEED = 1.0 CONTROL_N = 17 -CAR_ROTATION_RADIUS = 0.0 # This is a turn radius smaller than most cars can achieve MAX_CURVATURE = 0.2 MIN_STABLE_DELAY = 0.3 diff --git a/openpilot/selfdrive/controls/lib/latcontrol_curvature.py b/openpilot/selfdrive/controls/lib/latcontrol_curvature.py index 3dfe691e10..efa0734609 100644 --- a/openpilot/selfdrive/controls/lib/latcontrol_curvature.py +++ b/openpilot/selfdrive/controls/lib/latcontrol_curvature.py @@ -1,11 +1,13 @@ import math +import numpy as np +from collections import deque from openpilot.cereal import log from openpilot.common.pid import PIDController from openpilot.selfdrive.controls.lib.latcontrol import LatControl from openpilot.selfdrive.controls.lib.drive_helpers import MAX_CURVATURE -CURVATURE_SATURATION_THRESHOLD = 1e-3 # 1/m +LAT_ACCEL_SATURATION_THRESHOLD = 0.5 # m/s^2 class LatControlCurvature(LatControl): @@ -20,6 +22,8 @@ class LatControlCurvature(LatControl): else: self.pid = None self.kf = 1. + self.desired_curvature_buffer_len = int(1. / self.dt) + self.desired_curvature_buffer = deque([0.] * self.desired_curvature_buffer_len, maxlen=self.desired_curvature_buffer_len) def reset(self): super().reset() @@ -30,6 +34,9 @@ class LatControlCurvature(LatControl): curvature_log = log.ControlsState.LateralCurvatureState.new_message() actual_curvature = -VM.calc_curvature(math.radians(CS.steeringAngleDeg - params.angleOffsetDeg), CS.vEgo, params.roll) error = desired_curvature - actual_curvature + self.desired_curvature_buffer.append(desired_curvature) + delay_frames = int(np.clip(lat_delay / self.dt + 1, 1, self.desired_curvature_buffer_len)) + expected_curvature = self.desired_curvature_buffer[-delay_frames] if not active: output_curvature = 0.0 @@ -53,6 +60,6 @@ class LatControlCurvature(LatControl): curvature_log.actualCurvature = float(actual_curvature) curvature_log.desiredCurvature = float(desired_curvature) curvature_log.output = float(output_curvature) - curvature_log.saturated = bool(self._check_saturation(abs(error) > CURVATURE_SATURATION_THRESHOLD, CS, + curvature_log.saturated = bool(self._check_saturation(abs(expected_curvature - actual_curvature) * CS.vEgo ** 2 > LAT_ACCEL_SATURATION_THRESHOLD, CS, False, curvature_limited)) return 0.0, float(output_curvature), curvature_log diff --git a/openpilot/selfdrive/controls/lib/longitudinal_planner.py b/openpilot/selfdrive/controls/lib/longitudinal_planner.py index bf91c5e1f9..87f62fbe95 100755 --- a/openpilot/selfdrive/controls/lib/longitudinal_planner.py +++ b/openpilot/selfdrive/controls/lib/longitudinal_planner.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -import math import numpy as np import openpilot.cereal.messaging as messaging @@ -8,6 +7,7 @@ from openpilot.common.constants import CV from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.modeld.constants import ModelConstants +from openpilot.selfdrive.controls.lib.accel_boost import AccelBoost 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 @@ -17,39 +17,15 @@ from openpilot.common.swaglog import cloudlog from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlannerSP -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 +J_CRUISE_BP = [0., 10.0, 25., 40.] +A_CRUISE_MIN = -0.5 CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N] -ALLOW_THROTTLE_THRESHOLD = 0.4 -MIN_ALLOW_THROTTLE_SPEED = 2.5 -# Lookup table for turns -_A_TOTAL_MAX_V = [1.7, 3.2] -_A_TOTAL_MAX_BP = [20., 40.] -def get_max_accel(v_ego): - return np.interp(v_ego, A_CRUISE_MAX_BP, A_CRUISE_MAX_VALS) - -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 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) - - 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) +def get_cruise_accel(v_cruise, v_ego, a_cruise_prev, dt): + target_accel = np.clip(v_cruise - v_ego, A_CRUISE_MIN, ACCEL_MAX) + j_cruise = np.interp(v_ego, J_CRUISE_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 @@ -62,7 +38,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP): LongitudinalPlannerSP.__init__(self, self.CP, CP_SP, self.mpc) self.fcw = False self.dt = dt - self.allow_throttle = True + self.accel_boost = AccelBoost() self.v_desired_filter = FirstOrderFilter(init_v, 2.0, self.dt) self.a_cruise = init_a @@ -76,11 +52,6 @@ class LongitudinalPlanner(LongitudinalPlannerSP): def update(self, sm): LongitudinalPlannerSP.update(self, sm) - if len(sm['carControl'].orientationNED) == 3: - accel_coast = get_coast_accel(sm['carControl'].orientationNED[1]) - else: - accel_coast = ACCEL_MAX - v_ego = sm['carState'].vEgo v_cruise_kph = min(sm['carState'].vCruise, V_CRUISE_MAX) v_cruise = v_cruise_kph * CV.KPH_TO_MS @@ -95,12 +66,6 @@ class LongitudinalPlanner(LongitudinalPlannerSP): 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) @@ -116,7 +81,10 @@ class LongitudinalPlanner(LongitudinalPlannerSP): 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.output_a_target) + if self.CP.openpilotLongitudinalControl: + self.mpc.set_cur_state(self.v_desired_filter.x, self.output_a_target) + else: + self.mpc.set_cur_state(v_ego, sm['carState'].aEgo) 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) @@ -138,17 +106,14 @@ class LongitudinalPlanner(LongitudinalPlannerSP): output_a_target_e2e = sm['modelV2'].action.desiredAcceleration output_should_stop_e2e = sm['modelV2'].action.shouldStop - is_e2e = self.is_e2e(sm) - - 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) + self.a_cruise = get_cruise_accel(v_cruise, v_ego, self.a_cruise, self.dt) cruise_should_stop = should_stop(v_ego, self.a_cruise) + output_a_target_e2e = self.accel_boost.update(sm, output_a_target_e2e, output_a_target_mpc, 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)) + (self.a_cruise, LongitudinalPlanSource.cruise, cruise_should_stop), + (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) @@ -175,9 +140,9 @@ class LongitudinalPlanner(LongitudinalPlannerSP): longitudinalPlan.fcw = self.fcw longitudinalPlan.aTarget = float(self.output_a_target) + longitudinalPlan.accelBoost = float(self.accel_boost.total_boost) longitudinalPlan.shouldStop = bool(self.output_should_stop) longitudinalPlan.allowBrake = True - longitudinalPlan.allowThrottle = bool(self.allow_throttle) pm.send('longitudinalPlan', plan_send) diff --git a/openpilot/selfdrive/controls/plannerd.py b/openpilot/selfdrive/controls/plannerd.py index e7f36d3240..645a6e8c8b 100755 --- a/openpilot/selfdrive/controls/plannerd.py +++ b/openpilot/selfdrive/controls/plannerd.py @@ -28,7 +28,7 @@ def main(): ldw = LaneDepartureWarning() longitudinal_planner = LongitudinalPlanner(CP, CP_SP) pm = messaging.PubMaster(['longitudinalPlan', 'driverAssistance', 'longitudinalPlanSP']) - sm = messaging.SubMaster(['carControl', 'carState', 'controlsState', 'vehicleParameters', 'radarState', 'modelV2', 'selfdriveState', + sm = messaging.SubMaster(['carControl', 'carState', 'controlsState', 'radarState', 'modelV2', 'selfdriveState', 'liveMapDataSP', 'carStateSP', 'selfdriveStateSP', gps_location_service], poll='modelV2', ignore_alive=ignore_services, ignore_avg_freq=ignore_services, ignore_valid=ignore_services) diff --git a/openpilot/selfdrive/controls/tests/test_following_distance.py b/openpilot/selfdrive/controls/tests/test_following_distance.py index 1914769282..ccae879692 100644 --- a/openpilot/selfdrive/controls/tests/test_following_distance.py +++ b/openpilot/selfdrive/controls/tests/test_following_distance.py @@ -13,7 +13,7 @@ def desired_follow_distance(v_ego, v_lead, t_follow=None): t_follow = get_T_FOLLOW() return get_safe_obstacle_distance(v_ego, t_follow) - get_stopped_equivalence_factor(v_lead) -def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personality=0): +def run_following_distance_simulation(v_lead, t_end=100.0, personality=0): man = Maneuver( '', duration=t_end, @@ -22,7 +22,6 @@ def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personalit initial_distance_lead=100, speed_lead_values=[v_lead], breakpoints=[0.], - e2e=e2e, personality=personality, ) valid, output = man.evaluate() @@ -30,8 +29,7 @@ def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personalit return output[-1,2] - output[-1,1] -@parameterized_class(("e2e", "personality", "speed"), itertools.product( - [True, False], # e2e +@parameterized_class(("personality", "speed"), itertools.product( [log.LongitudinalPersonality.relaxed, # personality log.LongitudinalPersonality.standard, log.LongitudinalPersonality.aggressive], @@ -39,9 +37,8 @@ def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personalit 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) + simulation_steady_state = run_following_distance_simulation(v_lead, 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 self.assertAlmostEqual(simulation_steady_state, correct_steady_state, - delta=err_ratio * correct_steady_state + abs_err_margin) + delta=0.2 * 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 0dc26887e3..d45d12d957 100644 --- a/openpilot/selfdrive/controls/tests/test_latcontrol.py +++ b/openpilot/selfdrive/controls/tests/test_latcontrol.py @@ -8,12 +8,14 @@ from opendbc.car.honda.values import CAR as HONDA from opendbc.car.toyota.values import CAR as TOYOTA from opendbc.car.nissan.values import CAR as NISSAN from opendbc.car.gm.values import CAR as GM +from opendbc.car.volkswagen.values import CAR as VOLKSWAGEN from opendbc.car.vehicle_model import VehicleModel from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.car.helpers import convert_to_capnp 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.controls.lib.latcontrol_curvature import LatControlCurvature 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 @@ -22,7 +24,8 @@ from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfac class TestLatControl(OpenpilotTestCase): @parameterized.expand([(HONDA.HONDA_CIVIC, LatControlPID), (TOYOTA.TOYOTA_RAV4, LatControlTorque), - (NISSAN.NISSAN_LEAF, LatControlAngle), (GM.CHEVROLET_BOLT_EUV, LatControlTorque)]) + (NISSAN.NISSAN_LEAF, LatControlAngle), (GM.CHEVROLET_BOLT_EUV, LatControlTorque), + (VOLKSWAGEN.VOLKSWAGEN_ID4_MK2, LatControlCurvature)]) def test_saturation(self, car_name, controller): CarInterface = interfaces[car_name] CP = CarInterface.get_non_essential_params(car_name) diff --git a/openpilot/selfdrive/locationd/locationd.py b/openpilot/selfdrive/locationd/locationd.py index 9fbec991d5..3c76793e1c 100755 --- a/openpilot/selfdrive/locationd/locationd.py +++ b/openpilot/selfdrive/locationd/locationd.py @@ -278,12 +278,12 @@ def main(): estimator = LocationEstimator(DEBUG) filter_initialized = False - critcal_services = ["accelerometer", "gyroscope", "cameraOdometry"] + critical_services = ["accelerometer", "gyroscope", "cameraOdometry"] observation_input_invalid = defaultdict(int) - input_invalid_limit = {s: round(INPUT_INVALID_LIMIT * (SERVICE_LIST[s].frequency / 20.)) for s in critcal_services} - input_invalid_threshold = {s: input_invalid_limit[s] - 0.5 for s in critcal_services} - input_invalid_decay = {s: calculate_invalid_input_decay(input_invalid_limit[s], INPUT_INVALID_RECOVERY, SERVICE_LIST[s].frequency) for s in critcal_services} + input_invalid_limit = {s: round(INPUT_INVALID_LIMIT * (SERVICE_LIST[s].frequency / 20.)) for s in critical_services} + input_invalid_threshold = {s: input_invalid_limit[s] - 0.5 for s in critical_services} + input_invalid_decay = {s: calculate_invalid_input_decay(input_invalid_limit[s], INPUT_INVALID_RECOVERY, SERVICE_LIST[s].frequency) for s in critical_services} initial_pose_data = params.get("LocationFilterInitialState") if initial_pose_data is not None: @@ -313,7 +313,7 @@ def main(): if valid: t = log_mono_time * 1e-9 res = estimator.handle_log(t, which, msg) - if which not in critcal_services: + if which not in critical_services: continue if res == HandleLogResult.TIMING_INVALID: @@ -328,7 +328,7 @@ def main(): filter_initialized = sm.all_checks() and sensor_all_checks(acc_msgs, gyro_msgs, sensor_valid, sensor_recv_time, sensor_alive, SIMULATION) if sm.updated["cameraOdometry"]: - critical_service_inputs_valid = all(observation_input_invalid[s] < input_invalid_threshold[s] for s in critcal_services) + critical_service_inputs_valid = all(observation_input_invalid[s] < input_invalid_threshold[s] for s in critical_services) inputs_valid = sm.all_valid() and critical_service_inputs_valid sensors_valid = sensor_all_checks(acc_msgs, gyro_msgs, sensor_valid, sensor_recv_time, sensor_alive, SIMULATION) diff --git a/openpilot/selfdrive/locationd/models/car_kf.py b/openpilot/selfdrive/locationd/models/car_kf.py index 27cc4ef9c9..9bcec919dd 100755 --- a/openpilot/selfdrive/locationd/models/car_kf.py +++ b/openpilot/selfdrive/locationd/models/car_kf.py @@ -80,7 +80,6 @@ class CarKalman(KalmanFilter): } global_vars = [ - 'mass', 'rotational_inertia', 'center_to_front', 'center_to_rear', @@ -99,7 +98,10 @@ class CarKalman(KalmanFilter): # globals global_vars = [sp.Symbol(name) for name in CarKalman.global_vars] - m, j, aF, aR, cF_orig, cR_orig = global_vars + j, aF, aR, cF_orig, cR_orig = global_vars + # Tire stiffness and rotational inertia are already divided by vehicle mass, which cancels out of the model. + # Use unit mass to preserve the standard bicycle-model equations below. + m = 1 # make functions and jacobians with sympy # state variables @@ -166,8 +168,7 @@ class CarKalman(KalmanFilter): self.filter = EKF_sym_pyx(generated_dir, CarKalman.name, CarKalman.Q, CarKalman.initial_x, CarKalman.P_initial, dim_state, dim_state_err, global_vars=CarKalman.global_vars, logger=cloudlog) - def set_globals(self, mass, rotational_inertia, center_to_front, center_to_rear, stiffness_front, stiffness_rear): - self.filter.set_global("mass", mass) + def set_globals(self, rotational_inertia, center_to_front, center_to_rear, stiffness_front, stiffness_rear): self.filter.set_global("rotational_inertia", rotational_inertia) self.filter.set_global("center_to_front", center_to_front) self.filter.set_global("center_to_rear", center_to_rear) diff --git a/openpilot/selfdrive/locationd/models/pose_kf.py b/openpilot/selfdrive/locationd/models/pose_kf.py index a8ff80c713..f71503c201 100755 --- a/openpilot/selfdrive/locationd/models/pose_kf.py +++ b/openpilot/selfdrive/locationd/models/pose_kf.py @@ -40,7 +40,7 @@ class PoseKalman(KalmanFilter): initial_P = np.diag([0.01**2, 0.01**2, 0.01**2, 10**2, 10**2, 10**2, 1**2, 1**2, 1**2, - 1**2, 1**2, 1**2, + 0.1**2, 0.1**2, 0.1**2, 100**2, 100**2, 100**2, 0.01**2, 0.01**2, 0.01**2]) diff --git a/openpilot/selfdrive/locationd/paramsd.py b/openpilot/selfdrive/locationd/paramsd.py index 760176bc5c..18215c67b9 100755 --- a/openpilot/selfdrive/locationd/paramsd.py +++ b/openpilot/selfdrive/locationd/paramsd.py @@ -36,12 +36,11 @@ class VehicleParamsLearner: self.P_initial = P_initial if P_initial is not None else CarKalman.P_initial self.kf.set_globals( - mass=CP.mass, - rotational_inertia=CP.rotationalInertia, + rotational_inertia=CP.rotationalInertiaPerMass, center_to_front=CP.centerToFront, center_to_rear=CP.wheelbase - CP.centerToFront, - stiffness_front=CP.tireStiffnessFront, - stiffness_rear=CP.tireStiffnessRear + stiffness_front=CP.tireStiffnessFrontPerMass, + stiffness_rear=CP.tireStiffnessRearPerMass ) self.min_sr, self.max_sr = 0.5 * CP.steerRatio, 2.0 * CP.steerRatio diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index d19afd21dd..aec903235f 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -1,142 +1,72 @@ +import ctypes.util import glob -import json import os -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, chestnut_present, modeld_pkl_path +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info Import('env', 'arch') -chunker_file = File("#openpilot/common/file_chunker.py") + +# TODO: add GCC support to tinygrad? +# tinygrad's CPU:LLVM backend requires libLLVM, skip the build on PC if it's not available +if arch not in ('comma_arm64', 'Darwin') and ctypes.util.find_library('LLVM') is None: + print("\033[93mmodeld: LLVM not found, skipping tinygrad model compilation. Install LLVM to compile models.\033[0m") + Return() + lenv = env.Clone() +lenv.PrependENVPath('PYTHONPATH', Dir('#tinygrad_repo').abspath) tinygrad_root = env.Dir("#").abspath tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=tinygrad_root) if 'pycache' not in x and os.path.isfile(os.path.join(tinygrad_root, x))] -def estimate_pickle_max_size(onnx_size): - # QCOM programs for models with spatial recurrent features can approach 2x - # the ONNX size. Overestimating only adds an empty trailing chunk. - return 2.0 * onnx_size + 10 * 1024 * 1024 +camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)] if arch == 'comma_arm64': - from openpilot.common.hardware import HARDWARE - camera = _os_fisheye if HARDWARE.get_device_type() == "mici" else _ar_ox_fisheye - camera_configs = [(camera.width, camera.height)] - tg_backend = 'QCOM' - tg_flags = f'DEV={tg_backend} IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' + tg_flags = 'DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' else: - camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)] - tg_backend = 'CPU' - tg_flags = f'DEV=CPU' if arch == 'Darwin' else 'DEV=CPU:LLVM' - -tg_devices = { # which device to put jit inputs to at runtime - 'openpilot.selfdrive.modeld.dmonitoringmodeld': { - 'default': {'DEV': tg_backend} - }, -} - -CHESTNUT = chestnut_present() -if CHESTNUT: - chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_OCCUPANCY_OPT=1' - # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it - chestnut_lock = File("models/.chestnut.lock").abspath - -def write_tg_devices(target, source, env): - with open(str(target[0]), "w") as f: - json.dump(tg_devices, f) - f.write("\n") - -tg_devices_node = lenv.Command( - str(TG_INPUT_DEVICES_PATH), - [Value(tg_devices)], - write_tg_devices, -) + # JIT=2 disables graph batching, which produces incorrect outputs after buffers change. + tg_flags = 'DEV=METAL JIT=2' if arch == 'Darwin' else 'DEV=CPU:LLVM' # tinygrad calls brew which needs a $HOME in the env mac_brew_string = f'HOME={os.path.expanduser("~")}' if arch == 'Darwin' else '' -modeld_dir = Dir("#openpilot/selfdrive/modeld").abspath -compile_modeld_script = [ - File(f"{modeld_dir}/compile_modeld.py"), - File(f"{modeld_dir}/get_model_metadata.py"), - File("#openpilot/system/camerad/cameras/nv12_info.py"), - File("#openpilot/common/hardware/hw.py"), -] -model_w, model_h = MEDMODEL_INPUT_SIZE -frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ +warp_deps = [File("#openpilot/system/camerad/cameras/nv12_info.py")] +compiler = Dir('#tinygrad_repo/examples/openpilot').abspath +# build on all cores +taskset = 'taskset -c 0-7 ' if arch == 'comma_arm64' else '' -if not os.getenv('SKIP_TINYGRAD_COMPILE'): - for chestnut in [False, True] if CHESTNUT else [False]: - target_pkl_path = File(modeld_pkl_path(chestnut)).abspath - file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) - driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath) - camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in camera_configs) - # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. - taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' - cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_modeld.py ' - f'--model-size {model_w}x{model_h} ' - f'--camera-resolutions {camera_res_args} ' - f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} ' - f'--output {target_pkl_path} --frame-skip {frame_skip}') - onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps) - chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) - def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets): - from openpilot.system.hardware.chestnut.flash import link_up - # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars - for _ in range(10): - if link_up(): - break - time.sleep(1) - else: - print("Chestnut not ready, skipping big model build") - return - if ret := env.Execute(command): - return ret - chunk_file(pkl, chunks) - def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets): - chunk_file(pkl, chunks) - actions = Action(do_compile, " [CHESTNUT] $TARGET") if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] - node = lenv.Command( - chunk_targets, - tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(camera_res_args), Value(chunk_targets), chunker_file], - actions, - ) - if chestnut: - lenv.SideEffect(chestnut_lock, node) - -# get model metadata -fn = File(f"models/dmonitoring_model").abspath -script_files = [File(Dir("#openpilot/selfdrive/modeld").File("get_model_metadata.py").abspath)] -cmd = f'{tg_flags} {mac_brew_string} python3 {Dir("#openpilot/selfdrive/modeld").abspath}/get_model_metadata.py {fn}.onnx' -lenv.Command(fn + "_metadata.pkl", [fn + ".onnx"] + tinygrad_files + script_files + [tg_devices_node], cmd) - -dm_w, dm_h = DM_INPUT_SIZE -compile_dm_warp_script = [File(f"{modeld_dir}/compile_dm_warp.py")] -for cam_w, cam_h in camera_configs: - dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath - cmd = (f'{tg_flags} {mac_brew_string} python3 {modeld_dir}/compile_dm_warp.py ' - f'--camera-resolution {cam_w}x{cam_h} --warp-to {dm_w}x{dm_h} ' - f'--output {dm_pkl_path}') - lenv.Command(dm_pkl_path, tinygrad_files + compile_dm_warp_script + compile_modeld_script + [tg_devices_node], cmd) - -def tg_compile(flags, model_name): - pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + '"' - fn = File(f"models/{model_name}").abspath - pkl = fn + "_tinygrad.pkl" - onnx_path = fn + ".onnx" - chunk_targets = get_chunk_targets(pkl, estimate_pickle_max_size(os.path.getsize(onnx_path))) - def do_chunk(target, source, env): - chunk_file(pkl, chunk_targets) - return lenv.Command( - chunk_targets, - [onnx_path] + tinygrad_files + [Value(chunk_targets), chunker_file, tg_devices_node], - [f'{pythonpath_string} {flags} python3 {Dir("#tinygrad_repo").abspath}/examples/openpilot/compile3.py {fn}.onnx {pkl}', - Action(do_chunk, " [CHUNK] $TARGET")], +def compile_model(onnx_path, pkl_path): + onnx_path, target_pkl_path = File(onnx_path).abspath, File(pkl_path).abspath + cmd = (f'{tg_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_onnx.py" ' + f'"{onnx_path}" "{target_pkl_path}" --out-of-band --benchmark-runs 1') + lenv.Command( + target_pkl_path, + tinygrad_files + [onnx_path, Value(cmd)], + Action(cmd, " [ONNX] $TARGET"), ) -tg_compile(tg_flags, 'dmonitoring_model') +if not os.getenv('SKIP_TINYGRAD_COMPILE'): + compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl') + compile_model('models/driving_supercombo.onnx', 'models/driving_tinygrad.pkl') + +model_w, model_h = MEDMODEL_INPUT_SIZE +for cam_w, cam_h in camera_configs: + warp_pkl_path = File(f"models/driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath + stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) + cmd = (f'{tg_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_warp.py" ' + f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{stride * (y_height + uv_height)} ' + f'--warp-to {model_w}x{model_h} --layout yuv420 --frames 2 ' + f'--output {warp_pkl_path}') + lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], cmd) + +dm_w, dm_h = DM_INPUT_SIZE +for cam_w, cam_h in [] if os.getenv('SKIP_TINYGRAD_COMPILE') else camera_configs: + dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath + stride, y_height, uv_height, frame_size = get_nv12_info(cam_w, cam_h) + cmd = (f'{tg_flags} {mac_brew_string} python3 "{compiler}/compile_warp.py" ' + f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{frame_size} --warp-to {dm_w}x{dm_h} ' + f'--layout luma --border-fill 16 --transform-device NPY --output {dm_pkl_path}') + lenv.Command(dm_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], cmd) diff --git a/openpilot/selfdrive/modeld/compile_dm_warp.py b/openpilot/selfdrive/modeld/compile_dm_warp.py deleted file mode 100755 index 16e0d39fbb..0000000000 --- a/openpilot/selfdrive/modeld/compile_dm_warp.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import pickle -import time - -from tinygrad.tensor import Tensor -from tinygrad.device import Device -from tinygrad.engine.jit import TinyJit - -from openpilot.system.camerad.cameras.nv12_info import get_nv12_info -from openpilot.selfdrive.modeld.compile_modeld import NV12Frame, warp_perspective_tinygrad, _parse_size - - -def make_warp_dm(nv12: NV12Frame, dm_w, dm_h): - cam_w, cam_h, stride, _, _, _ = nv12 - stride_pad = stride - cam_w - - def warp_dm(input_frame, M_inv): - M_inv = M_inv.to(Device.DEFAULT).realize() - return warp_perspective_tinygrad(input_frame[:cam_h*stride], M_inv, - (dm_w, dm_h), (cam_h, cam_w), stride_pad, border_fill_val=16).reshape(-1, dm_h * dm_w) # Y - return warp_dm - - -def compile_dm_warp(nv12: NV12Frame, dm_w, dm_h, pkl_path): - print(f"Compiling DM warp for {nv12.width}x{nv12.height} -> {dm_w}x{dm_h}...") - - warp_dm_jit = TinyJit(make_warp_dm(nv12, dm_w, dm_h), prune=True) - - for i in range(10): - frame = Tensor.randint(nv12.size, low=0, high=256, dtype='uint8').realize() - M_inv = Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY') - Device.default.synchronize() - st = time.perf_counter() - warp_dm_jit(frame, M_inv).realize() - 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") - - with open(pkl_path, "wb") as f: - pickle.dump(warp_dm_jit, f) - print(f" Saved to {pkl_path}") - - -if __name__ == "__main__": - p = argparse.ArgumentParser() - p.add_argument('--camera-resolution', type=_parse_size, required=True, help='camera resolution WxH') - p.add_argument('--warp-to', type=_parse_size, required=True, help='DM input WxH') - p.add_argument('--output', required=True) - args = p.parse_args() - - cam_w, cam_h = args.camera_resolution - nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - dm_w, dm_h = args.warp_to - compile_dm_warp(nv12, dm_w, dm_h, args.output) diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py deleted file mode 100755 index d851c3cc86..0000000000 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ /dev/null @@ -1,332 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import atexit -import math -import os -import tempfile -import time -import shutil -from functools import partial -from collections import namedtuple - -import numpy as np - -from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob - -def _patch_tinygrad_fetch_fw(): - import hashlib - import pathlib - import zstandard - from tinygrad import helpers - _orig = 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(path, name, sha256) - helpers.fetch_fw = fetch_fw -_patch_tinygrad_fetch_fw() - - -from tinygrad.tensor import Tensor -from tinygrad.helpers import Context -from tinygrad.device import Device -from tinygrad.engine.jit import TinyJit - - -NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size']) -MODELD_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'] - - -def nv12_copy_size(stride: int, y_height: int, uv_height: int) -> int: - # Retain the padded Y and UV plane storage, but skip the trailing kernel/guard allocation. - return stride * (y_height + uv_height) - - -def warp_perspective_tinygrad(src_flat, M_inv, dst_shape, src_shape, stride_pad, border_fill_val=None): - w_dst, h_dst = dst_shape - h_src, w_src = src_shape - - x = Tensor.arange(w_dst).reshape(1, w_dst).expand(h_dst, w_dst).reshape(-1) - y = Tensor.arange(h_dst).reshape(h_dst, 1).expand(h_dst, w_dst).reshape(-1) - - # inline 3x3 matmul as elementwise to avoid reduce op (enables fusion with gather) - src_x = M_inv[0, 0] * x + M_inv[0, 1] * y + M_inv[0, 2] - src_y = M_inv[1, 0] * x + M_inv[1, 1] * y + M_inv[1, 2] - src_w = M_inv[2, 0] * x + M_inv[2, 1] * y + M_inv[2, 2] - - src_x = src_x / src_w - src_y = src_y / src_w - - x_round = Tensor.round(src_x) - y_round = Tensor.round(src_y) - x_nn_clipped = x_round.clip(0, w_src - 1).cast('int') - y_nn_clipped = y_round.clip(0, h_src - 1).cast('int') - idx = y_nn_clipped * (w_src + stride_pad) + x_nn_clipped - sampled = src_flat[idx] - - if border_fill_val is None: - return sampled - - in_bounds = ((x_round >= 0) & (x_round <= w_src - 1) & - (y_round >= 0) & (y_round <= h_src - 1)).cast(sampled.dtype) - return sampled * in_bounds + Tensor(border_fill_val, dtype=sampled.dtype) * (1 - in_bounds) - - -def frames_to_tensor(frames): - H = (frames.shape[0] * 2) // 3 - W = frames.shape[1] - in_img1 = Tensor.cat(frames[0:H:2, 0::2], - frames[1:H:2, 0::2], - frames[0:H:2, 1::2], - frames[1:H:2, 1::2], - frames[H:H+H//4].reshape((H//2, W//2)), - frames[H+H//4:H+H//2].reshape((H//2, W//2)), dim=0).reshape((6, H//2, W//2)) - return in_img1 - - -def make_frame_prepare(nv12: NV12Frame, model_w, model_h): - cam_w, cam_h, stride, y_height, uv_height, _ = nv12 - uv_offset = stride * y_height - stride_pad = stride - cam_w - - def frame_prepare_tinygrad(input_frame, M_inv): - # UV_SCALE @ M_inv @ UV_SCALE_INV simplifies to elementwise scaling - M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=Device.DEFAULT) - # deinterleave NV12 UV plane (UVUV... -> separate U, V) - uv = input_frame[uv_offset:uv_offset + uv_height * stride].reshape(uv_height, stride) - with Context(SPLIT_REDUCEOP=0): - y = warp_perspective_tinygrad(input_frame[:cam_h*stride], - M_inv, (model_w, model_h), - (cam_h, cam_w), stride_pad).realize() - u = warp_perspective_tinygrad(uv[:cam_h//2, :cam_w:2].flatten(), - M_inv_uv, (model_w//2, model_h//2), - (cam_h//2, cam_w//2), 0).realize() - v = warp_perspective_tinygrad(uv[:cam_h//2, 1:cam_w:2].flatten(), - M_inv_uv, (model_w//2, model_h//2), - (cam_h//2, cam_w//2), 0).realize() - yuv = y.cat(u).cat(v).reshape((model_h * 3 // 2, model_w)) - tensor = frames_to_tensor(yuv) - return tensor - return frame_prepare_tinygrad - - -def get_policy_npy_shapes(input_shapes): - dp = input_shapes['desire_pulse'] # (1, 25, 8) - tc = input_shapes['traffic_convention'] # (1, 2) - at = input_shapes['action_t'] # (1, 2) - fb = input_shapes['features_buffer'] # (1, T-1, ...) e.g. (1, 24, 32, 512) with spatial features - feat_dim = math.prod(fb[2:]) - # TODO prev_feat shouldn't exist and be handled inside the JIT, but corrupt on QCOM for now - shapes = {'desire': (dp[2],), 'traffic_convention': tuple(tc), 'action_t': tuple(at), 'prev_feat': (fb[0], feat_dim)} - return shapes, [math.prod(s) for s in shapes.values()] - - -def make_input_queues(input_shapes, frame_skip, device, frame_copy_size): - img = input_shapes['img'] # (1, 12, 128, 256) - fb = input_shapes['features_buffer'] # (1, T-1, ...), past features only; the model appends the current frame's feature - feat_dim = math.prod(fb[2:]) - dp = input_shapes['desire_pulse'] # (1, 25, 8) - n_frames = img[1] // 6 - img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img[2], img[3]) - - policy_shapes, _ = get_policy_npy_shapes(input_shapes) - shapes = {'tfm': (3, 3), 'big_tfm': (3, 3)} | policy_shapes - sizes = [math.prod(s) for s in shapes.values()] - packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize - packed_input = np.zeros(packed_npy_size + 2 * frame_copy_size, dtype=np.uint8) - packed_npy_inputs = packed_input[:packed_npy_size].view(np.float32) - frames = packed_input[packed_npy_size:] - frame_views = {'img': frames[:frame_copy_size], 'big_img': frames[frame_copy_size:]} - # views into the packed inputs, to be refilled at runtime - npy = {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)} - input_queues = { - 'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), - 'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), - 'feat_q': Tensor(np.zeros((frame_skip * fb[1], fb[0], feat_dim), dtype=np.float32), device=device).contiguous().realize(), - 'desire_q': Tensor(np.zeros((frame_skip * dp[1], dp[0], dp[2]), dtype=np.float32), device=device).contiguous().realize(), - 'packed_npy_inputs': Tensor(packed_input, device='NPY').realize(), - } - return input_queues, npy, frame_views - - -def shift_and_sample(buf, new_val, sample_fn): - buf.assign(buf[1:].cat(new_val, dim=0).contiguous()) - return sample_fn(buf) - - -def sample_skip(buf, frame_skip): - return buf[::frame_skip].contiguous().flatten(0, 1).unsqueeze(0) - - -def sample_desire(buf, frame_skip): - return buf.reshape(-1, frame_skip, *buf.shape[1:]).max(1).flatten(0, 1).unsqueeze(0) - - -def make_warp(nv12, model_w, model_h): - frame_prepare = make_frame_prepare(nv12, model_w, model_h) - - def warp(tfm, big_tfm, frame, big_frame): - tfm = tfm.to(Device.DEFAULT) - big_tfm = big_tfm.to(Device.DEFAULT) - frame = frame.to(Device.DEFAULT) - big_frame = big_frame.to(Device.DEFAULT) - Tensor.realize(tfm, big_tfm, frame, big_frame) - - warped_frame = frame_prepare(frame, tfm).unsqueeze(0) - warped_big_frame = frame_prepare(big_frame, big_tfm).unsqueeze(0) - return Tensor.cat(warped_frame, warped_big_frame) - - return warp - - -def make_run_policy(model_runner, model_metadata, frame_skip): - sample_desire_fn = partial(sample_desire, frame_skip=frame_skip) - sample_skip_fn = partial(sample_skip, frame_skip=frame_skip) - npy_shapes, npy_sizes = get_policy_npy_shapes(model_metadata['input_shapes']) - model_input_dtypes = {name: spec.dtype for name, spec in model_runner.graph_inputs.items()} - - def run_policy(warped, img_q, big_img_q, feat_q, desire_q, packed_npy_inputs): - packed_npy_inputs = packed_npy_inputs.to(Device.DEFAULT) - Tensor.realize(packed_npy_inputs, warped) - - img = shift_and_sample(img_q, warped[0:1], sample_skip_fn) - big_img = shift_and_sample(big_img_q, warped[1:2], sample_skip_fn) - - desire, traffic_convention, action_t, prev_feat = (t.reshape(s) for t, s in zip(packed_npy_inputs.split(npy_sizes), npy_shapes.values(), strict=True)) - desire_buf = shift_and_sample(desire_q, desire.reshape(1, 1, -1), sample_desire_fn) - feat_buf = shift_and_sample(feat_q, prev_feat.reshape(1, 1, -1), sample_skip_fn) - - inputs = { - 'img': img, - 'big_img': big_img, - 'features_buffer': feat_buf.reshape(model_metadata['input_shapes']['features_buffer']), - 'desire_pulse': desire_buf, - 'traffic_convention': traffic_convention, - 'action_t': action_t, - } - inputs = {name: value.cast(model_input_dtypes[name]) for name, value in inputs.items()} - out = next(iter(model_runner(inputs).values())).cast('float32') - return out, - return run_policy - - -def make_run_model(warp, run_policy, model_metadata, frame_copy_size): - _, policy_sizes = get_policy_npy_shapes(model_metadata['input_shapes']) - packed_npy_size = (18 + sum(policy_sizes)) * np.dtype(np.float32).itemsize - - def run_model(img_q, big_img_q, feat_q, desire_q, packed_npy_inputs): - packed_input = packed_npy_inputs.to(Device.DEFAULT) - Tensor.realize(packed_input) - packed_npy_inputs = packed_input[:packed_npy_size].bitcast('float32') - frame = packed_input[packed_npy_size:packed_npy_size + frame_copy_size] - big_frame = packed_input[packed_npy_size + frame_copy_size:] - tfm, big_tfm, policy_inputs = packed_npy_inputs.split([9, 9, sum(policy_sizes)]) - warped = warp(tfm.reshape(3, 3), big_tfm.reshape(3, 3), frame, big_frame) - return run_policy(warped, img_q, big_img_q, feat_q, desire_q, policy_inputs) - return run_model - - -def compile_jit(jit, input_keys, make_queues, benchmark_runs): - if benchmark_runs < 1: - raise ValueError("benchmark_runs must be at least 1") - - SEED = 42 - def random_inputs_run(fn, seed, n_runs, test_val=None, test_buffers=None, expect_match=True): - input_queues, npy, frame_views = make_queues(Device.DEFAULT) - rng = np.random.default_rng(seed) - - for i in range(n_runs): - for v in npy.values(): - v[:] = rng.standard_normal(v.shape).astype(v.dtype) - for v in frame_views.values(): - v[:] = rng.integers(0, 256, size=v.shape, dtype=np.uint8) - Device.default.synchronize() - st = time.perf_counter() - outs = fn(**{k: input_queues[k] for k in input_keys}) - 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] - buffers = [np.copy(v.numpy().copy()) for v in input_queues.values()] - - if test_val is not None: - match = all(np.array_equal(a, b) for a, b in zip(val, test_val, strict=True)) - assert match == expect_match, f"outputs {'differ from' if expect_match else 'match'} baseline (seed={seed})" - 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 val, buffers - - print('capture + replay') - test_val, test_buffers = random_inputs_run(jit, SEED, 3) - print(f'pickle round trip ({benchmark_runs} runs per seed)') - with tempfile.TemporaryFile(dir=".") as f: - dump_oob(jit, f) - f.seek(0) - loaded_jit = load_oob(f) - random_inputs_run(loaded_jit, SEED, benchmark_runs, test_val, test_buffers, expect_match=True) - random_inputs_run(loaded_jit, SEED+1, benchmark_runs, test_val, test_buffers, expect_match=False) - # Keep the original so per-resolution JITs share model weight buffers in the final pickle. - return jit - - -def _parse_size(s): - w, h = s.lower().split('x') - return int(w), int(h) - - -def read_file_chunked_to_disk(path): - 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 - - -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() - 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, - help='camera resolutions WxH (one or more)') - p.add_argument('--onnx', required=True) - p.add_argument('--output', required=True) - p.add_argument('--frame-skip', type=int, required=True) - p.add_argument('--benchmark-runs', type=int, default=1, - help='timed loaded-JIT runs for each correctness seed') - args = p.parse_args() - - model_path = read_file_chunked_to_disk(args.onnx) - model_w, model_h = args.model_size - - model_runner = OnnxRunner(model_path) - out = { - 'metadata': make_metadata_dict(model_path), - 'input_devices': {'model': Device.DEFAULT}, - 'run_model': {}, - } - - run_policy = make_run_policy(model_runner, out['metadata'], args.frame_skip) - - for cam_w, cam_h in args.camera_resolutions: - nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - frame_copy_size = nv12_copy_size(nv12.stride, nv12.y_height, nv12.uv_height) - make_model_queues = partial(make_input_queues, out['metadata']['input_shapes'], args.frame_skip, - frame_copy_size=frame_copy_size) - warp = make_warp(nv12, model_w, model_h) - run_model_jit = TinyJit(make_run_model(warp, run_policy, out['metadata'], frame_copy_size), prune=True) - out['run_model'][(cam_w,cam_h)] = compile_jit(run_model_jit, MODELD_INPUTS, make_model_queues, - args.benchmark_runs) - - with open(args.output, "wb") as f: - dump_oob(out, f) - print(f"Saved JITs to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB)") diff --git a/openpilot/selfdrive/modeld/dmonitoringmodeld.py b/openpilot/selfdrive/modeld/dmonitoringmodeld.py index 4010725b89..5a6e9216ad 100755 --- a/openpilot/selfdrive/modeld/dmonitoringmodeld.py +++ b/openpilot/selfdrive/modeld/dmonitoringmodeld.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import os -from openpilot.selfdrive.modeld.helpers import MODELS_DIR, get_tg_input_devices +import base64 +from openpilot.selfdrive.modeld.helpers import MODELS_DIR, load_oob from tinygrad.tensor import Tensor import time import pickle @@ -15,13 +16,10 @@ from openpilot.common.realtime import config_realtime_process from openpilot.common.transformations.model import dmonitoringmodel_intrinsics from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.system.camerad.cameras.nv12_info import get_nv12_info -from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.parse_model_outputs import sigmoid, safe_exp -PROCESS_NAME = "openpilot.selfdrive.modeld.dmonitoringmodeld" SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') MODEL_PKL_PATH = MODELS_DIR / 'dmonitoring_model_tinygrad.pkl' -METADATA_PATH = MODELS_DIR / 'dmonitoring_model_metadata.pkl' class ModelState: @@ -29,11 +27,10 @@ class ModelState: output: np.ndarray def __init__(self, cam_w: int, cam_h: int): - 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'] - self.output_slices = model_metadata['output_slices'] + jits = load_oob(MODEL_PKL_PATH) + self.DEV = jits['input_specs']['input_img'][2] + self.input_shapes = jits['metadata']['input_shapes'] + self.output_slices = pickle.loads(base64.b64decode(jits['metadata']['metadata']['output_slices'])) self.numpy_inputs = { 'calib': np.zeros(self.input_shapes['calib'], dtype=np.float32), @@ -42,16 +39,19 @@ class ModelState: self.warp_inputs_np = {'transform': np.zeros((3,3), dtype=np.float32)} self.warp_inputs = {k: Tensor(v, device='NPY') for k,v in self.warp_inputs_np.items()} self.frame_buf_params = get_nv12_info(cam_w, cam_h) - self.tensor_inputs = {k: Tensor(v, device='NPY').realize() for k,v in self.numpy_inputs.items()} + self.tensor_inputs = {k: Tensor(v, device=self.DEV).realize() for k,v in self.numpy_inputs.items()} + self.calib_host = Tensor(self.numpy_inputs['calib'], device='NPY')._buffer() self._blob_cache : dict[int, Tensor] = {} - self.model_run = pickle.load(open_file_chunked(str(MODEL_PKL_PATH))) + self.model_run = jits['run'] + self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in jits['output_specs'].items()} with open(MODELS_DIR / f'dm_warp_{cam_w}x{cam_h}_tinygrad.pkl', "rb") as f: - self.image_warp = pickle.load(f) + self.image_warp = pickle.load(f)['run'] def run(self, buf: VisionBuf, calib: np.ndarray, transform: np.ndarray) -> tuple[np.ndarray, float]: self.numpy_inputs['calib'][0,:] = calib t1 = time.perf_counter() + self.tensor_inputs['calib']._buffer().copy_from(self.calib_host) ptr = np.frombuffer(buf.data, dtype=np.uint8).ctypes.data # There is a ringbuffer of imgs, just cache tensors pointing to all of them @@ -59,9 +59,10 @@ class ModelState: self._blob_cache[ptr] = Tensor.from_blob(ptr, (self.frame_buf_params[3],), dtype='uint8', device=self.DEV) self.warp_inputs_np['transform'][:] = transform[:] - self.tensor_inputs['input_img'] = self.image_warp(self._blob_cache[ptr], self.warp_inputs['transform']) + self.tensor_inputs['input_img'] = self.image_warp(input_frame=self._blob_cache[ptr], M_inv=self.warp_inputs['transform']) - output = self.model_run(**self.tensor_inputs).numpy().flatten() + self.model_run(output_buffers=self.outputs, **self.tensor_inputs) + output = self.outputs['outputs'].numpy().astype(np.float32).reshape(-1) t2 = time.perf_counter() return output, t2 - t1 diff --git a/openpilot/selfdrive/modeld/get_model_metadata.py b/openpilot/selfdrive/modeld/get_model_metadata.py deleted file mode 100755 index e4c173957a..0000000000 --- a/openpilot/selfdrive/modeld/get_model_metadata.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -import sys -import pathlib -import codecs -import pickle -from typing import Any - -from tinygrad.nn.onnx import OnnxPBParser - - -class MetadataOnnxPBParser(OnnxPBParser): - def _parse_ModelProto(self) -> dict: - obj: dict[str, Any] = {"graph": {"input": [], "output": []}, "metadata_props": []} - for fid, wire_type in self._parse_message(self.reader.len): - match fid: - case 7: - obj["graph"] = self._parse_GraphProto() - case 14: - obj["metadata_props"].append(self._parse_StringStringEntryProto()) - case _: - self.reader.skip_field(wire_type) - return obj - - -def get_name_and_shape(value_info: dict[str, Any]) -> tuple[str, tuple[int, ...]]: - shape = tuple(int(dim) if isinstance(dim, int) else 0 for dim in value_info["parsed_type"].shape) - name = value_info["name"] - return name, shape - - -def get_metadata_value_by_name(model: dict[str, Any], name: str) -> str | Any: - for prop in model["metadata_props"]: - if prop["key"] == name: - return prop["value"] - return None - - -def make_metadata_dict(model_path): - model = MetadataOnnxPBParser(model_path).parse() - output_slices = get_metadata_value_by_name(model, 'output_slices') - assert output_slices is not None, 'output_slices not found in metadata' - return { - '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"]), - } - - -if __name__ == "__main__": - model_path = pathlib.Path(sys.argv[1]) - metadata_path = model_path.parent / (model_path.stem + '_metadata.pkl') - with open(metadata_path, 'wb') as f: - pickle.dump(make_metadata_dict(model_path), f) - print(f'saved metadata to {metadata_path}') diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 23eb6cbd21..9ac772e697 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -1,51 +1,23 @@ -import io -import json -import pickle -import shutil -import struct -import tempfile +import sys +import time from pathlib import Path -from openpilot.common.file_chunker import get_manifest_path +from openpilot.common.hardware import AGNOS from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, is_chestnut_usb_id MODELS_DIR = Path(__file__).resolve().parent / 'models' -TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json' -CHESTNUT_POWERED_VOLTAGE = 5000 -CHESTNUT_PCIE_READY = 0x78 -def get_tg_input_devices(process_name: str, chestnut: bool): - with open(TG_INPUT_DEVICES_PATH) as f: - return json.load(f)[process_name]['default' if not chestnut else 'chestnut'] - def modeld_pkl_path(chestnut: bool): prefix = 'big_' if chestnut else '' return MODELS_DIR / f'{prefix}driving_tinygrad.pkl' -def dump_oob(obj, f): - with tempfile.TemporaryFile(dir=".") as tmp: - def buffer_callback(pb: pickle.PickleBuffer): - m = pb.raw() - tmp.write(struct.pack(' bool: for d in USB_DEVICES_PATH.glob("*"): @@ -58,9 +30,15 @@ def chestnut_present() -> bool: pass return False +def wait_for_chestnut(timeout: float = 10.) -> None: + # the cable is detected before chestnut enumerates + st = time.monotonic() + while not chestnut_present(): + if time.monotonic() - st > timeout: + raise TimeoutError("chestnut did not enumerate") + time.sleep(0.1) + def chestnut_compiled() -> bool: - return Path(get_manifest_path(modeld_pkl_path(chestnut=True))).is_file() - - -def chestnut_ready(state) -> bool: - return state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE and not state.supplyFault and state.pcieLtssm == CHESTNUT_PCIE_READY + path = modeld_pkl_path(chestnut=True) + return path.is_file() and all( + (MODELS_DIR / f'big_driving_warp_{size}_tinygrad.pkl').is_file() for size in ('1344x760', '1928x1208')) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index b98b4d0f58..7e8c705abc 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -1,12 +1,21 @@ #!/usr/bin/env python3 from collections.abc import Callable +import base64 import ctypes +import gc from functools import cached_property import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom -from tinygrad.device import Device -import usb1 -import struct +os.environ.setdefault('AM_POWER_LIMIT', '100') +os.environ.setdefault('AM_PERF_LEVEL', 'high') +from tinygrad.device import Buffer, Device +from tinygrad.dtype import DType, dtypes +from tinygrad.engine.realize import lower_and_compile +from tinygrad.tensor import Tensor +from tinygrad.helpers import round_up +from tinygrad.uop.ops import UOp +import math +import pickle import threading import time import numpy as np @@ -20,6 +29,7 @@ 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 +from openpilot.common.hardware.usb import cable_connected from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.realtime import config_realtime_process, DT_MDL from openpilot.common.transformations.camera import DEVICE_CAMERAS @@ -28,13 +38,9 @@ from openpilot.common.transformations.model import get_warp_matrix from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, should_stop, smooth_value, get_curvature_from_plan from openpilot.selfdrive.modeld.parse_model_outputs import Parser -from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, nv12_copy_size, MODELD_INPUTS from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState -from openpilot.common.file_chunker import open_file_chunked -from openpilot.common.hardware.usb import CHESTNUT_USB_IDS from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, chestnut_ready, modeld_pkl_path, load_oob - +from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob, wait_for_chestnut 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 @@ -75,45 +81,14 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log. shouldStop=bool(stop)) -class ChestnutState: - # only modeld can access chestnut +class ChestnutGpuState: + # GPU metrics require modeld's GPU context def __init__(self, pm: PubMaster, big: bool): self.pm = pm self.big = big self.valid = True self.sends = 0 self.metrics = {} - self._asm_usb = None - - def _close_asm_usb(self) -> None: - if self._asm_usb is not None: - self._asm_usb.close() - self._asm_usb = None - - def _open_asm_usb(self): - context = usb1.USBContext() - for vendor_id, product_id in CHESTNUT_USB_IDS: - if (handle := context.openByVendorIDAndProductID(vendor_id, product_id, skip_on_error=True)) is not None: - return handle - context.close() - - def _read_ina(self) -> tuple[int, int, bool]: - if "AMD" in Device._opened_devices and self._asm_usb is None: - try: - raw = Device["AMD"].iface.pci_dev.usb.usb.control_read(0xC0, 5) - return struct.unpack(' int: @@ -121,8 +96,8 @@ class ChestnutState: return smu._send_msg(smu.smu_mod.PPSMC_MSG_GetPptLimit, 0, read_back_arg=True, timeout=100) def send(self) -> None: - msg = messaging.new_message('chestnutState') - state = msg.chestnutState + msg = messaging.new_message('chestnutGpuState') + state = msg.chestnutGpuState self.sends += 1 if self.big and "AMD" in Device._opened_devices and self.sends % 100 == 1: try: @@ -148,21 +123,8 @@ class ChestnutState: for k, v in self.metrics.items(): setattr(state, k, v) - asm_valid = False - try: - # ASM runs on USB-C power, these still read without a gpu - state.supplyVoltage, state.supplyCurrent, state.supplyFault = self._read_ina() - asm_valid = True - except Exception: - pass - if "AMD" in Device._opened_devices: - try: - state.pcieLtssm = Device["AMD"].iface.pci_dev.usb.read(0xB450, 1)[0] - except Exception: - pass - - msg.valid = asm_valid and (not self.big or self.valid) - self.pm.send('chestnutState', msg) + msg.valid = not self.big or (self.valid and bool(self.metrics)) + self.pm.send('chestnutGpuState', msg) class FrameMeta: @@ -175,37 +137,67 @@ class FrameMeta: self.frame_id, self.timestamp_sof, self.timestamp_eof = vipc.frame_id, vipc.timestamp_sof, vipc.timestamp_eof +def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int) -> Tensor: + view = buffer.view(math.prod(shape) * dtype.itemsize, offset).ensure_allocated() + return Tensor(UOp.from_buffer(view, dtype)).reshape(shape) + + class ModelState(ModelStateBase): prev_desire: np.ndarray # for tracking the rising edge of the pulse def __init__(self, cam_w: int, cam_h: int, chestnut: bool): ModelStateBase.__init__(self) - jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut))) - input_devices = jits['input_devices'] - self.model_device = input_devices['model'] - metadata = jits['metadata'] - self.input_shapes = metadata['input_shapes'] - self.vision_input_names = [k for k in self.input_shapes if 'img' in k] - self.output_slices = metadata['output_slices'] + jits = load_oob(modeld_pkl_path(chestnut), chestnut) + self.model_device = jits['input_specs']['new_img'][2] + self.input_shapes = {name: (shape, np.dtype(dtype)) for name, (shape, dtype, _) in jits['input_specs'].items()} + self.state_pairs = {name: f'next_{name}' for name in self.input_shapes if f'next_{name}' in jits['metadata']['output_shapes']} + self.vision_input_names = ('img', 'big_img') + self.output_slices = pickle.loads(base64.b64decode(jits['metadata']['metadata']['output_slices'])) self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32) self.chestnut = chestnut - self.frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ - self.frame_copy_size = nv12_copy_size(*get_nv12_info(cam_w, cam_h)[:3]) - self.input_queues, self.npy, self.frame_views = make_input_queues( - self.input_shapes, self.frame_skip, device=self.model_device, frame_copy_size=self.frame_copy_size) + stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) + self.frame_copy_size = stride * (y_height + uv_height) + self.pack_inputs() + with open(MODELS_DIR / f'{"big_" if chestnut else ""}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl', 'rb') as f: + self.run_warp = pickle.load(f)['run'] + self.run_warp.captured._linear = lower_and_compile(self.run_warp.captured._linear) + self.run_model = jits['run'] + self.run_model.captured._linear = lower_and_compile(self.run_model.captured._linear) + self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in jits['output_specs'].items()} + for name, next_name in self.state_pairs.items(): + state = self.input_queues[name] + self.outputs[next_name] = input_view(state._buffer(), state.shape, state.dtype, 0) self.parser = Parser() - self.run_model = jits['run_model'][(cam_w,cam_h)] + + def pack_inputs(self) -> None: + # Pack host inputs into one upload to reduce USB transfer overhead for the eGPU. + self.input_queues = {name: Tensor(np.zeros(shape, dtype=dtype), device=self.model_device).realize() + for name, (shape, dtype) in self.input_shapes.items() if name in self.state_pairs} + shapes = {'tfm': (2, 3, 3)} | {name: shape for name, (shape, _) in self.input_shapes.items() + if name not in self.state_pairs and name != 'new_img'} + npy_size = sum(round_up(math.prod(shape) * 4, 128) for shape in shapes.values()) + self.packed_input = np.zeros(npy_size + 2 * self.frame_copy_size, dtype=np.uint8) + self.input_host = Tensor(self.packed_input, device='NPY')._buffer() + self.input_device = Tensor(self.packed_input, device=self.model_device)._buffer() + self.npy = {} + offset = 0 + for name, shape in shapes.items(): + self.npy[name] = np.ndarray(shape, dtype=np.float32, buffer=self.packed_input, offset=offset) + self.input_queues[name] = input_view(self.input_device, shape, dtypes.float32, offset) + offset += round_up(self.npy[name].nbytes, 128) + self.frames = self.packed_input[npy_size:].reshape(2, self.frame_copy_size) + self.warp_inputs = {'input_frame': input_view(self.input_device, self.frames.shape, dtypes.uint8, npy_size), 'M_inv': self.input_queues.pop('tfm')} def slice_outputs(self, model_outputs: np.ndarray, output_slices: dict[str, slice]) -> dict[str, np.ndarray]: - parsed_model_outputs = {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()} - return parsed_model_outputs + return {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()} def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray], inputs: dict[str, np.ndarray], after_enqueue: Callable[[], None] | None = None) -> dict[str, np.ndarray]: - for key, buf in bufs.items(): - np.copyto(self.frame_views[key], np.frombuffer(buf.data, dtype=np.uint8, count=self.frame_copy_size)) + for i, key in enumerate(self.vision_input_names): + np.copyto(self.frames[i], np.frombuffer(bufs[key].data, dtype=np.uint8, count=self.frame_copy_size)) + self.npy['tfm'][i] = transforms[key] # Model decides when action is completed, so desire input is just a pulse triggered on rising edge inputs['desire_pulse'][0] = 0 @@ -213,17 +205,18 @@ class ModelState(ModelStateBase): self.prev_desire[:] = inputs['desire_pulse'] self.npy['traffic_convention'][:] = inputs['traffic_convention'] self.npy['action_t'][:] = inputs['action_t'] - self.npy['tfm'][:,:] = transforms['img'][:,:] - self.npy['big_tfm'][:,:] = transforms['big_img'][:,:] - outs, = self.run_model(**{k: self.input_queues[k] for k in MODELD_INPUTS}) + self.input_device.copy_from(self.input_host) + self.input_queues['new_img'] = self.run_warp(**self.warp_inputs) + self.run_model(output_buffers=self.outputs, **self.input_queues) + if self.chestnut and (err := Device[self.outputs['outputs'].device].error_state.host.view(fmt='q')[0]): + raise RuntimeError(f"chestnut failed with {err}") if after_enqueue is not None: after_enqueue() - model_output = outs.numpy()[0] + model_output = self.outputs['outputs'].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']] if SEND_RAW_PRED: outputs_dict['raw_pred'] = model_output.copy() @@ -234,35 +227,24 @@ class ModelState(ModelStateBase): 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, self.frame_views = make_input_queues( - self.input_shapes, self.frame_skip, device=self.model_device, frame_copy_size=self.frame_copy_size) + self.packed_input[:] = 0 + for key in self.state_pairs: + self.input_queues[key].assign(0).realize() self.prev_desire[:] = 0 def main(demo=False): cloudlog.warning("modeld init") - chestnut_available = chestnut_present() and chestnut_compiled() - CHESTNUT = False - if chestnut_available: - poller = messaging.Poller() - sock = messaging.sub_sock("chestnutState", poller=poller, conflate=True) - deadline = time.monotonic() + 4. / SERVICE_LIST['deviceState'].frequency - while not CHESTNUT and (remaining := deadline - time.monotonic()) > 0.: - if not poller.poll(round(remaining * 1000)): - break - msg = messaging.recv_one_or_none(sock) - CHESTNUT = msg is not None and msg.valid and chestnut_ready(msg.chestnutState) + CHESTNUT = chestnut_compiled() and (chestnut_present() or cable_connected()) if CHESTNUT: - os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' + from tinygrad.runtime.ops_amd import AMDDevice + AMDDevice.wait_timeout_ms = 3000 params = Params() params.put_bool("ChestnutLoading", CHESTNUT) - if chestnut_available and not CHESTNUT: - params.put_bool("ChestnutActive", False) - else: - params.remove("ChestnutActive") + params.remove("ChestnutActive") - config_realtime_process(7, 54) + gc.disable() # visionipc clients while True: @@ -295,6 +277,7 @@ def main(demo=False): def load_big(): nonlocal big_model try: + wait_for_chestnut() m = ModelState(vipc_client_main.width, vipc_client_main.height, True) m.warmup() big_model = m @@ -304,27 +287,32 @@ def main(demo=False): loader.start() loader.join(BIG_MODEL_TIMEOUT) model = big_model - if model is None: - params.put_bool("ChestnutModelError", True) - params.put_bool("ChestnutActive", model is not None) - if model is not None: - params.remove("ChestnutModelError") + if model is None and not params.get_bool("ChestnutRetry"): + # a crank can cut the chestnut power during the load, retry once with an onroad cycle + cloudlog.error("big model load failed, cycling onroad to retry") + params.put_bool("ChestnutRetry", True) + params.put_bool("OnroadCycleRequested", True) + else: + 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 + params.put_bool("ChestnutLoading", False) cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") + config_realtime_process(7, 54) + # messaging - pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutState"] if CHESTNUT else []) + pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutGpuState"] if CHESTNUT else []) pm = PubMaster(pub_socks) - sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) + sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", + "carControl", "controlsState", "lateralDelay"]) publish_state = PublishState() params = Params() - chestnut_state = ChestnutState(pm, model.chestnut) if CHESTNUT else None + chestnut_state = ChestnutGpuState(pm, model.chestnut) if CHESTNUT else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_RUN_FREQ) @@ -435,14 +423,13 @@ def main(demo=False): mt1 = time.perf_counter() try: send_chestnut = (chestnut_state is not None and - run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0) + run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutGpuState'].frequency) == 0) model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None) except Exception: - if not params.get_bool("ChestnutActive"): + if not model.chestnut: raise # fallback to small model cloudlog.exception("big model failed, fall back to small") - params.put_bool("ChestnutModelError", True) params.put_bool("ChestnutActive", False) assert small_model is not None model = small_model @@ -457,7 +444,10 @@ 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') + if not sm['carControl'].latActive: + prev_action.desiredCurvature = sm['controlsState'].curvature 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, @@ -469,7 +459,6 @@ def main(demo=False): 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 - mdv2sp_send = messaging.new_message('modelDataV2SP') left_edge, right_edge = RELC.update_and_fill(modelv2_send.modelV2, mdv2sp_send.modelDataV2SP, v_ego) DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob, left_edge, right_edge) modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state diff --git a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx deleted file mode 100644 index 7b51732275..0000000000 --- a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1791d5940b2c048d0639813426dd2cf1d6f2a6727ed51e17c8bcea8bbe754123 -size 765950064 diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl new file mode 100644 index 0000000000..afa4866d75 --- /dev/null +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4c7a3fcfc95813826ca871a80bea18df833aacb2eb6f5333e25a01e65b1a422 +size 799941965 diff --git a/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl new file mode 100644 index 0000000000..712457b192 --- /dev/null +++ b/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18c994e4500fe9f34daa8f33202a8606d3817e09e2c79ae8f9795e37159b5143 +size 850515 diff --git a/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl new file mode 100644 index 0000000000..81bb93726c --- /dev/null +++ b/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e30b5cada2c2a861999de6103c42053fae524850e30d63cd8f4d9e9bdd319e54 +size 850258 diff --git a/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx b/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx index 1e592e2192..1d6527cf2c 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:dd299afabe7a3e0d04cbe2bd97fdb0c93bba8ad6d3cc3663a0e0ededaf243ac2 -size 7497335 +oid sha256:dee5a294e8afaacc9295ac5d100e00733ecac278e79264b96331e40a3ede1b04 +size 7844499 diff --git a/openpilot/selfdrive/modeld/models/driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/driving_supercombo.onnx index f0672eab48..f030157ccd 100644 --- a/openpilot/selfdrive/modeld/models/driving_supercombo.onnx +++ b/openpilot/selfdrive/modeld/models/driving_supercombo.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:659727c4d4839adc4992a254409a54259a8756a743f2d567bf5fdc6579f8009b -size 60881999 +oid sha256:65a08adc31d5c456219687d99b7bf5e44d61dae2d49ea67850e76105c7248cce +size 60918562 diff --git a/openpilot/selfdrive/monitoring/policy.py b/openpilot/selfdrive/monitoring/policy.py index bd698214ac..57c83683e1 100644 --- a/openpilot/selfdrive/monitoring/policy.py +++ b/openpilot/selfdrive/monitoring/policy.py @@ -51,6 +51,7 @@ class DRIVER_MONITOR_SETTINGS: self._SG_THRESHOLD = 0.9 self._BLINK_THRESHOLD = 0.865 self._PHONE_THRESH = 0.5 + self._SLEEP_THRESH = 0.75 self._POSE_PITCH_THRESHOLD = 0.3133 self._POSE_PITCH_THRESHOLD_SLACK = 0.3237 self._POSE_PITCH_THRESHOLD_STRICT = self._POSE_PITCH_THRESHOLD @@ -138,6 +139,7 @@ class DriverMonitoring: self.pose = DriverPose(settings=self.settings) self.blink = DriverBlink() self.phone_prob = 0. + self.sleep_prob = 0. self.alert_level = AlertLevel.none self.always_on = always_on @@ -240,6 +242,7 @@ class DriverMonitoring: self.distracted_types['pose'] = bool((pitch_error > pitch_threshold) or (yaw_error > yaw_threshold)) self.distracted_types['eye'] = bool((self.blink.left + self.blink.right)*0.5 > self.settings._BLINK_THRESHOLD) self.distracted_types['phone'] = bool(self.phone_prob > self.settings._PHONE_THRESH) + self.distracted_types['sleep'] = bool(self.sleep_prob > self.settings._SLEEP_THRESH) def _update_states(self, driver_state, cal_rpy, car_speed, op_engaged, lowspeed, demo_mode=False, steering_angle_deg=0.): rhd_pred = driver_state.wheelOnRightProb @@ -277,6 +280,7 @@ class DriverMonitoring: self.blink.right = driver_data.rightBlinkProb * (driver_data.rightEyeProb > self.settings._EYE_THRESHOLD) \ * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD) self.phone_prob = driver_data.phoneProb + self.sleep_prob = driver_data.sleepProb self._get_distracted_types() self.driver_distracted = any(self.distracted_types.values()) and driver_data.faceProb > self.settings._FACE_THRESHOLD and self.pose.low_std @@ -406,6 +410,7 @@ class DriverMonitoring: dm.visionPolicyState.distractedTypes.pose = self.distracted_types['pose'] dm.visionPolicyState.distractedTypes.eye = self.distracted_types['eye'] dm.visionPolicyState.distractedTypes.phone = self.distracted_types['phone'] + dm.visionPolicyState.distractedTypes.sleep = self.distracted_types['sleep'] dm.visionPolicyState.faceDetected = self.face_detected dm.visionPolicyState.pose.pitch = self.pose.pitch dm.visionPolicyState.pose.yaw = self.pose.yaw diff --git a/openpilot/selfdrive/monitoring/test_monitoring.py b/openpilot/selfdrive/monitoring/test_monitoring.py index d9889860f5..44fc0398cd 100644 --- a/openpilot/selfdrive/monitoring/test_monitoring.py +++ b/openpilot/selfdrive/monitoring/test_monitoring.py @@ -28,6 +28,7 @@ def make_msg(face_detected, distracted=False, model_uncertain=False): ds.leftDriverData.facePositionStd = [1.*model_uncertain, 1.*model_uncertain] # TODO: test both separately when e2e is used ds.leftDriverData.phoneProb = 0. + ds.leftDriverData.sleepProb = 0. return ds diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/__init__.py b/openpilot/selfdrive/navigation/__init__.py similarity index 100% rename from openpilot/sunnypilot/selfdrive/controls/lib/dec/__init__.py rename to openpilot/selfdrive/navigation/__init__.py diff --git a/openpilot/selfdrive/navigation/helpers.py b/openpilot/selfdrive/navigation/helpers.py new file mode 100644 index 0000000000..a880c79b06 --- /dev/null +++ b/openpilot/selfdrive/navigation/helpers.py @@ -0,0 +1,9 @@ +import time + + +def latest_gps_source(sm) -> str | None: + """The service with the freshest usable GPS fix, or None.""" + now = time.monotonic() + sources = [s for s in ('gpsLocation', 'gpsLocationExternal') + if sm.valid[s] and sm[s].hasFix and 0 <= now - sm.recv_time[s] < 2] + return max(sources, key=lambda s: sm.logMonoTime[s]) if sources else None diff --git a/openpilot/selfdrive/navigation/mapsd.py b/openpilot/selfdrive/navigation/mapsd.py new file mode 100755 index 0000000000..43bd7c4f75 --- /dev/null +++ b/openpilot/selfdrive/navigation/mapsd.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +import time + +import numpy as np + +import openpilot.cereal.messaging as messaging +from msgq.visionipc import VisionIpcServer +from openpilot.cereal.visionipc import VisionStreamType +from openpilot.common.realtime import Ratekeeper +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.navigation.helpers import latest_gps_source +from openpilot.selfdrive.navigation.pmtiles import PMTiles +from openpilot.selfdrive.navigation.renderer import SIZE, MapRenderer +from openpilot.selfdrive.navigation.route import RouteTracker +from openpilot.selfdrive.navigation.tiles import RemoteTiles + +RETRY_SECONDS = 10 + + +class MapsD: + def __init__(self): + self.renderer = None + self.tracker = None + self.route = np.empty((0, 2)) + self.prefetched = False + self.retry_at = 0.0 + + def update(self, sm) -> tuple[np.ndarray, int]: + """The map frame for the latest messages: black with a zero timestamp without a usable map, never stale.""" + if sm.updated['navRoute']: + route = np.array([(c.longitude, c.latitude) for c in sm['navRoute'].coordinates], dtype=np.float64).reshape((-1, 2)) + if not np.array_equal(route, self.route): + self.route = route + self.tracker = RouteTracker(route) if len(route) >= 2 else None # progress along the route starts over + self.prefetched = False + now = time.monotonic() + source = latest_gps_source(sm) + try: + if self.tracker is not None and now >= self.retry_at: + if self.renderer is None: + self.renderer = MapRenderer(PMTiles(RemoteTiles())) + if not self.prefetched: + # TODO: fetch the tiles more intelligently on demand + # Every tile the route can show is fetched up front, so each render below is a cache hit. + self.renderer.prefetch(self.route) + self.prefetched = True + if source is not None: + gps = sm[source] + # As in training, the map is centered on the route and headed along it, not at the GPS fix. + image, valid = self.renderer.render(*self.tracker.snap(gps.longitude, gps.latitude), self.route) + if valid: + return image, sm.logMonoTime[source] + except Exception: # a failed fetch, or a corrupt cached tile + cloudlog.exception('mapsd.render_failed') + self.close() # a changed archive needs a fresh reader + self.retry_at = now + RETRY_SECONDS + return np.zeros((SIZE, SIZE), dtype=np.uint8), 0 + + def close(self): + if self.renderer is not None: + self.renderer.archive.close() + self.renderer = None + self.prefetched = False + + +def main(): + rk = Ratekeeper(20.0) + sm = messaging.SubMaster(['gpsLocation', 'gpsLocationExternal', 'navRoute']) + server = VisionIpcServer('mapsd') + server.create_buffers_with_sizes(VisionStreamType.VISION_STREAM_MAP, 4, SIZE, SIZE, SIZE * SIZE, SIZE, SIZE * SIZE) + server.start_listener() + mapsd = MapsD() + try: + while True: + sm.update(0) + image, timestamp = mapsd.update(sm) + server.send(VisionStreamType.VISION_STREAM_MAP, image.tobytes(), rk.frame, timestamp, time.monotonic_ns()) + rk.keep_time() + finally: + mapsd.close() + + +if __name__ == '__main__': + main() diff --git a/openpilot/selfdrive/navigation/navd.py b/openpilot/selfdrive/navigation/navd.py new file mode 100755 index 0000000000..a33c42558c --- /dev/null +++ b/openpilot/selfdrive/navigation/navd.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +import os + +import numpy as np +import requests + +import openpilot.cereal.messaging as messaging +from openpilot.common.api import API_HOST, Api +from openpilot.common.params import Params +from openpilot.common.realtime import Ratekeeper +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.navigation.helpers import latest_gps_source +from openpilot.selfdrive.navigation.route import along_path, local_meters + +REROUTE_DISTANCE = 25 +MANEUVER_TRANSITION_THRESHOLD = 10 +REROUTE_COUNTER_MIN = 3 +ROUTE_RESEND_PERIOD = 10 # updates (seconds); a restarted mapsd gets the route within this +RECOMPUTE_HOLD = 10 # updates (seconds) between route requests + + +def set_banner(instruction, banners, distance_to_maneuver): + """Fill the banner fields from the banner to show now: the last one still ahead of the ego, else the first.""" + if not banners: + return + banner = next((b for b in reversed(banners) if distance_to_maneuver < b['distanceAlongGeometry']), banners[0]) + instruction.showFull = distance_to_maneuver < banner['distanceAlongGeometry'] + primary = banner['primary'] + instruction.maneuverPrimaryText = primary.get('text') or '' + instruction.maneuverType = primary.get('type') or '' + instruction.maneuverModifier = primary.get('modifier') or '' + if banner.get('secondary') is not None: + instruction.maneuverSecondaryText = banner['secondary']['text'] + + +class RouteEngine: + def __init__(self, sm, pm, params=None): + self.sm = sm + self.pm = pm + self.params = params if params is not None else Params() + + self.last_position = None # (longitude, latitude) + self.last_bearing = None + + self.nav_destination = None + self.step_idx = None + self.route = None + self.route_geometry = None # an (n, 2) array of (longitude, latitude) per step + + self.recompute_hold = 0 + self.reroute_counter = 0 + + self.mapbox_token = os.environ.get("MAPBOX_TOKEN") + self.api = Api(self.params.get("DongleId")) if self.mapbox_token is None else None + self.mapbox_host = "https://api.mapbox.com" if self.mapbox_token is not None else API_HOST.rstrip('/') + '/v1/nav' + + def update(self): + self.sm.update(0) + + source = latest_gps_source(self.sm) + if source is not None: + gps = self.sm[source] + self.last_position, self.last_bearing = (gps.longitude, gps.latitude), gps.bearingDeg + route = self.route + try: + self.recompute_route(gps_ok=source is not None) + self.send_instruction() + except Exception: + cloudlog.exception("navd.failed_to_compute") + # mapsd dedupes repeats, so a periodic resend is how a restarted mapsd gets the route + if self.route is not route or self.sm.frame % ROUTE_RESEND_PERIOD == 0: + self.send_route() + + def recompute_route(self, gps_ok: bool): + if self.last_position is None: + return + + destination = self.params.get("NavDestination") + if destination is None: + self.clear_route() + self.recompute_hold = 0 + return + new_destination = (destination['longitude'], destination['latitude']) + + # Don't recompute when GPS drifts in tunnels + if not gps_ok and self.step_idx is not None: + return + + self.recompute_hold -= 1 + if (self.should_recompute() or new_destination != self.nav_destination) and self.recompute_hold <= 0: + self.recompute_hold = RECOMPUTE_HOLD + self.calculate_route(new_destination) + self.reroute_counter = 0 + + def calculate_route(self, destination): + assert self.last_position is not None + cloudlog.warning(f"Calculating route {self.last_position} -> {destination}") + self.nav_destination = destination + + lang = self.params.get('LanguageSetting') + if lang is not None: + lang = lang.replace('main_', '') + + token = self.mapbox_token + if token is None: + token = self.api.get_token() + + params = { + 'access_token': token, + 'geometries': 'geojson', + 'overview': 'full', + 'steps': 'true', + 'banner_instructions': 'true', + 'alternatives': 'false', + 'language': lang, + } + if self.last_bearing is not None: + params['bearings'] = f"{(self.last_bearing + 360) % 360:.0f},90;" + + coords_str = ';'.join(f'{lon},{lat}' for lon, lat in (self.last_position, destination)) + url = self.mapbox_host + '/directions/v5/mapbox/driving-traffic/' + coords_str + try: + resp = requests.get(url, params=params, timeout=10) + if resp.status_code != 200: + cloudlog.event("API request failed", status_code=resp.status_code, text=resp.text, error=True) + resp.raise_for_status() + + r = resp.json() + if len(r['routes']): + self.route = r['routes'][0]['legs'][0]['steps'] + self.route_geometry = [np.array(step['geometry']['coordinates'], dtype=np.float64).reshape((-1, 2)) for step in self.route] + self.step_idx = 0 + else: + cloudlog.warning("Got empty route response") + self.clear_route() + + except requests.exceptions.RequestException: + cloudlog.exception("failed to get route") + self.clear_route() + + def send_instruction(self): + msg = messaging.new_message('navInstruction', valid=True) + + if self.step_idx is None: + msg.valid = False + self.pm.send('navInstruction', msg) + return + + assert self.route is not None and self.route_geometry is not None and self.last_position is not None + step = self.route[self.step_idx] + along_geometry, _ = along_path(self.route_geometry[self.step_idx], self.last_position) + distance_to_maneuver_along_geometry = step['distance'] - along_geometry + + # Banner instructions are for the following maneuver step, don't use empty last step + banner_step = step + if not len(banner_step['bannerInstructions']) and self.step_idx == len(self.route) - 1: + banner_step = self.route[max(self.step_idx - 1, 0)] + + # Current instruction + msg.navInstruction.maneuverDistance = distance_to_maneuver_along_geometry + set_banner(msg.navInstruction, banner_step['bannerInstructions'], distance_to_maneuver_along_geometry) + + # Total remaining distance and time: the rest of this step, then every step after it + remaining = 1.0 - along_geometry / max(step['distance'], 1) + future = self.route[self.step_idx + 1:] + msg.navInstruction.distanceRemaining = step['distance'] * remaining + sum(s['distance'] for s in future) + msg.navInstruction.timeRemaining = step['duration'] * remaining + sum(s['duration'] for s in future) + + self.pm.send('navInstruction', msg) + + # Transition to next route segment + if distance_to_maneuver_along_geometry < -MANEUVER_TRANSITION_THRESHOLD: + if self.step_idx + 1 < len(self.route): + self.step_idx += 1 + self.recompute_hold = 0 + else: + cloudlog.warning("Destination reached") + + # Clear route if driving away from destination + if np.linalg.norm(local_meters(self.last_position, self.nav_destination)) > REROUTE_DISTANCE: + self.params.remove("NavDestination") + self.clear_route() + + def send_route(self): + coords = np.concatenate(self.route_geometry) if self.route_geometry is not None else np.empty((0, 2)) + msg = messaging.new_message('navRoute', valid=True) + msg.navRoute.coordinates = [{'longitude': lon, 'latitude': lat} for lon, lat in coords.tolist()] + self.pm.send('navRoute', msg) + + def clear_route(self): + self.route = None + self.route_geometry = None + self.step_idx = None + self.nav_destination = None + + def should_recompute(self): + if self.step_idx is None: + return True + + assert self.route is not None and self.route_geometry is not None and self.last_position is not None + # Don't recompute in last segment, assume destination is reached + if self.step_idx == len(self.route) - 1: + return False + + # TODO: Check for going wrong way in segment + if along_path(self.route_geometry[self.step_idx], self.last_position)[1] > REROUTE_DISTANCE: + self.reroute_counter += 1 + else: + self.reroute_counter = 0 + return self.reroute_counter > REROUTE_COUNTER_MIN + + +def main(): + pm = messaging.PubMaster(['navInstruction', 'navRoute']) + sm = messaging.SubMaster(['gpsLocation', 'gpsLocationExternal']) + + rk = Ratekeeper(1.0) + route_engine = RouteEngine(sm, pm) + while True: + route_engine.update() + rk.keep_time() + + +if __name__ == "__main__": + main() diff --git a/openpilot/selfdrive/navigation/pmtiles.py b/openpilot/selfdrive/navigation/pmtiles.py new file mode 100644 index 0000000000..1279963228 --- /dev/null +++ b/openpilot/selfdrive/navigation/pmtiles.py @@ -0,0 +1,136 @@ +"""PMTiles v3 reader for the gzip-compressed Protomaps MVT basemap, over any byte-range source.""" +import gzip +import struct +from bisect import bisect_right +from functools import lru_cache + +HEADER_LENGTH = 127 + + +def read_varint(data: bytes, offset: int) -> tuple[int, int]: + value = 0 + for shift in range(0, 70, 7): + if offset >= len(data): + raise ValueError("Truncated varint") + byte = data[offset] + offset += 1 + value |= (byte & 127) << shift + if byte < 128: + return value, offset + raise ValueError("Varint exceeds 64 bits") + + +def tile_id(z: int, x: int, y: int) -> int: + """PMTiles orders each zoom level along a Hilbert curve.""" + if not 0 <= z <= 26 or not 0 <= x < 1 << z or not 0 <= y < 1 << z: + raise ValueError("Invalid tile coordinates") + distance = 0 + size = (1 << z) // 2 + while size: + rx, ry = int(bool(x & size)), int(bool(y & size)) + distance += size * size * ((3 * rx) ^ ry) + if not ry: + if rx: + x, y = size - 1 - x, size - 1 - y + x, y = y, x + size //= 2 + return ((1 << (2 * z)) - 1) // 3 + distance + + +def decode_directory(data: bytes) -> tuple[list[int], list[tuple[int, int, int]]]: + count, offset = read_varint(data, 0) + if count > len(data): + raise ValueError("Invalid directory entry count") + columns = [] + for _ in range(4): + column = [] + for _ in range(count): + value, offset = read_varint(data, offset) + column.append(value) + columns.append(column) + ids, runs, lengths, offsets = columns + for i in range(count): + if i: + ids[i] += ids[i - 1] + if offsets[i] == 0: + if i == 0: + raise ValueError("First directory offset cannot be implicit") + offsets[i] = offsets[i - 1] + lengths[i - 1] + else: + offsets[i] -= 1 + if lengths[i] == 0 or (i and ids[i] <= ids[i - 1]): + raise ValueError("Invalid directory entry") + if offset != len(data): + raise ValueError("Trailing directory data") + return ids, list(zip(runs, lengths, offsets, strict=True)) + + +class PMTiles: + def __init__(self, source): + """source has size, read(offset, length), prefetch(ranges) and close().""" + self.source = source + self.size = source.size + try: + header = self.read(0, HEADER_LENGTH) + if header[:8] != b'PMTiles\x03': + raise ValueError("Expected a PMTiles v3 archive") + self.root_offset, self.root_length, _, _, self.leaf_offset, self.leaf_length, self.data_offset, self.data_length = struct.unpack_from('<8Q', header, 8) + compression, tile_type, self.min_zoom, self.max_zoom = header[97:99], header[99], header[100], header[101] + if compression != b'\x02\x02' or tile_type != 1: + raise ValueError("Expected gzip-compressed MVT vector tiles") + if not 0 <= self.min_zoom <= self.max_zoom <= 26: + raise ValueError("Unsupported PMTiles zoom range") + for offset, length in ((self.root_offset, self.root_length), (self.leaf_offset, self.leaf_length), (self.data_offset, self.data_length)): + if offset + length > self.size: + raise ValueError("PMTiles section outside archive") + self.directory = lru_cache(maxsize=32)(self._read_directory) + self.directory(self.root_offset, self.root_length) + except Exception: + self.source.close() + raise + + def read(self, offset: int, length: int) -> bytes: + if offset < 0 or length < 0 or offset + length > self.size: + raise ValueError("PMTiles read outside archive") + data = self.source.read(offset, length) + if len(data) != length: + raise ValueError("Truncated PMTiles archive") + return data + + def _read_directory(self, offset: int, length: int): + return decode_directory(gzip.decompress(self.read(offset, length))) + + def locate(self, z: int, x: int, y: int) -> tuple[int, int] | None: + """The byte range of a tile in the archive, or None when there is no such tile.""" + if not self.min_zoom <= z <= self.max_zoom: + return None + target = tile_id(z, x, y) + offset, length = self.root_offset, self.root_length + for _ in range(4): + ids, entries = self.directory(offset, length) + index = bisect_right(ids, target) - 1 + if index < 0: + return None + run, length, offset = entries[index] + if run: + if target >= ids[index] + run: + return None + if offset + length > self.data_length: + raise ValueError("Tile outside PMTiles data section") + return self.data_offset + offset, length + if offset + length > self.leaf_length: + raise ValueError("Directory outside PMTiles leaf section") + offset += self.leaf_offset + raise ValueError("PMTiles directory nesting exceeds four levels") + + def get(self, z: int, x: int, y: int) -> bytes | None: + located = self.locate(z, x, y) + return gzip.decompress(self.read(*located)) if located else None + + def prefetch(self, tiles): + """Cache the (z, x, y) tiles ahead of reads.""" + self.source.prefetch([located for located in (self.locate(*tile) for tile in tiles) if located]) + + def close(self): + self.directory.cache_clear() + self.source.close() diff --git a/openpilot/selfdrive/navigation/renderer.py b/openpilot/selfdrive/navigation/renderer.py new file mode 100644 index 0000000000..a0640a7cad --- /dev/null +++ b/openpilot/selfdrive/navigation/renderer.py @@ -0,0 +1,208 @@ +"""Deterministic CPU rasterization of Protomaps roads and a supplied route. + +Styled after the training map renderer: 1 m per pixel, the ego three quarters +down the frame, pure white roads whose casings are part of the stroke, bridges cutting over the roads beneath +them, a grey route and a white ego arrow. +""" +import math +from functools import lru_cache + +import numpy as np + +from openpilot.selfdrive.navigation.pmtiles import PMTiles +from openpilot.selfdrive.navigation.route import cumulative_distance, nearest_segment +from openpilot.selfdrive.navigation.vector_tile import Road, decode_roads + +SIZE = 256 +ZOOM = 15 # tile zoom; the style zoom is derived from the 1 m per pixel scale instead +FORWARD_OFFSET = 64.0 # the ego sits three quarters down the frame +EGO = np.array([SIZE / 2, SIZE / 2 + FORWARD_OFFSET]) # pixel position of the ego +EARTH_CIRCUMFERENCE = 40075016.68557849 # equatorial, for Web Mercator +BACKGROUND = 0 +ROAD_INTENSITY = 255 +ROUTE_INTENSITY = 128 +ROUTE_WIDTH = 5.0 # pixels +CUT_WIDTH = 3.0 # black separator under a bridge, added to the road width +MAX_ROAD_DISTANCE = 100.0 # meters from the ego to the nearest road centerline for a valid map +ARROW = np.array([(0, -9), (7, 7), (0, 3), (-7, 7)], dtype=np.float64) # ego marker, pixels around the ego +ARROW_OUTLINE = 2.0 # pixels + + +# The training style's road classes as (zoom, width, zoom, width) stops for the interior and for one side of the +# casing, in pixels. Casings are white there too, so they only widen the stroke. Only each class's top interval is +# kept: the style zoom at 1 m per pixel stays above 14 until 78 degrees of latitude, where the lower stops begin. +MAJOR, MEDIUM, STREET, MINOR, TRACK = range(5) +STOPS = np.array([[[5, 0.75, 18, 32], [10, 1, 18, 2]], # MAJOR: motorway, trunk, primary + [[5, 0.1, 18, 26], [10, 0.75, 18, 2]], # MEDIUM: secondary, tertiary + [[14, 2, 18, 18], [12, 0.75, 20, 2]], # STREET: residential, unclassified, living streets, major links + [[14, 0.5, 18, 12], [12, 0.75, 20, 2]], # MINOR: service roads and minor links + [[14, 1, 18, 12], [12, 0.75, 20, 2]]], dtype=np.float64) # TRACK + + +def stroke_widths(zoom: float, styles: np.ndarray, levels: np.ndarray) -> np.ndarray: + """Full stroke width of each segment: the style's exponential (base 1.5) interpolation between its stops, flat + beyond them, with the casing on both sides except for tunnels, which are drawn at their interior width.""" + z0, w0, z1, w1 = np.moveaxis(STOPS, -1, 0) + z = np.clip(zoom, z0, z1) + table = w0 + (w1 - w0) * (1.5 ** (z - z0) - 1) / (1.5 ** (z1 - z0) - 1) # (style, interior / casing) + return table[styles, 0] + np.where(levels < 0, 0, 2 * table[styles, 1]) + + +def road_style(road: Road) -> int | None: + """The style for a road, or None when hidden. Protomaps kind_detail is the underlying OSM highway type.""" + detail = road.detail + if detail in ('platform', 'pier', 'raceway'): + return None + if detail in ('motorway', 'trunk', 'primary') or (not detail and road.kind == 'highway'): + return MAJOR + if detail in ('secondary', 'tertiary') or (not detail and road.kind == 'major_road'): + return MEDIUM + if detail == 'track': + return TRACK + if detail in ('secondary_link', 'tertiary_link', 'service', 'driveway', 'parking_aisle', 'alley') or ( + road.kind == 'other' and detail != 'living_street'): + return MINOR + return STREET + + +def road_level(road: Road) -> int: + """Draw order: tunnels, then ground roads, then bridges, each cutting over the levels beneath.""" + return 1 if road.bridge else -1 if road.tunnel else 0 + + +def mercator(coordinates: np.ndarray) -> np.ndarray: + """Longitude/latitude -> normalized Web Mercator, x east / y south.""" + longitude, latitude = coordinates.T + y = (1.0 - np.arcsinh(np.tan(np.deg2rad(latitude))) / np.pi) / 2.0 + return np.column_stack(((longitude + 180.0) / 360.0, y)) + + +def project(points: np.ndarray, longitude: float, latitude: float, bearing: float) -> np.ndarray: + """Mercator points to pixels of a frame at a position, headed along a bearing in degrees clockwise from north.""" + origin = mercator(np.array([[longitude, latitude]]))[0] + delta = points - origin + delta[:, 0] = (delta[:, 0] + 0.5) % 1.0 - 0.5 # wrap across the antimeridian + delta *= EARTH_CIRCUMFERENCE * math.cos(math.radians(latitude)) # meters, which are pixels + c, s = math.cos(math.radians(bearing)), math.sin(math.radians(bearing)) + return EGO + delta @ np.array([[c, -s], [s, c]]) # x right, y down + + +def draw_segments(image: np.ndarray, starts: np.ndarray, ends: np.ndarray, radii: np.ndarray | float, intensity: int, + *, overlay: bool = False, caps: np.ndarray | None = None): + """Strokes with round joins and one pixel of analytic edge antialiasing. + + caps is (segments, 2): whether each segment's start/end gets a round cap; without it every end is round. + Max blending (the default) makes draw order irrelevant; overlay replaces the pixels beneath. + """ + radii = np.broadcast_to(np.asarray(radii, dtype=np.float64), (len(starts),)) + reach = radii[:, None] + 1 + low = np.maximum(np.floor(np.minimum(starts, ends) - reach), 0).astype(int) + high = np.minimum(np.ceil(np.maximum(starts, ends) + reach), [image.shape[1] - 1, image.shape[0] - 1]).astype(int) + # Cull offscreen segments in one pass, then loop over plain Python floats. + visible = np.flatnonzero((high >= low).all(axis=1)) + round_caps = np.ones((len(starts), 2), dtype=bool) if caps is None else caps + for i, (sx, sy), (ex, ey), (x0, y0), (x1, y1), radius in zip(visible.tolist(), starts[visible].tolist(), ends[visible].tolist(), + low[visible].tolist(), high[visible].tolist(), radii[visible].tolist(), + strict=True): + dx, dy = ex - sx, ey - sy + px = np.arange(x0, x1 + 1) + (0.5 - sx) + py = (np.arange(y0, y1 + 1) + (0.5 - sy))[:, None] + along = (px * dx + py * dy) / max(dx * dx + dy * dy, 1e-12) + t = np.clip(along, 0, 1) + alpha = np.clip(radius + 0.5 - np.hypot(px - t * dx, py - t * dy), 0, 1) + if not round_caps[i, 0]: + alpha = alpha * (along >= 0) + if not round_caps[i, 1]: + alpha = alpha * (along <= 1) + view = image[y0:y1 + 1, x0:x1 + 1] + if overlay: + view[:] = np.rint(view * (1 - alpha) + intensity * alpha).astype(np.uint8) + else: + np.maximum(view, np.rint(alpha * intensity).astype(np.uint8), out=view) + + +def draw_vehicle(image: np.ndarray): + """The training ego marker: a white arrow with a black outline, drawn over everything else.""" + arrow = ARROW + EGO + (x0, y0), (x1, y1) = (np.floor(arrow.min(axis=0)) - 2).astype(int), (np.ceil(arrow.max(axis=0)) + 2).astype(int) + px, py = np.meshgrid(np.arange(x0, x1) + 0.5, np.arange(y0, y1) + 0.5) + inside = np.zeros(px.shape, dtype=bool) + for a, b, c in ((arrow[0], arrow[1], arrow[2]), (arrow[0], arrow[2], arrow[3])): # the concave arrow as two triangles + sides = [(q[0] - p[0]) * (py - p[1]) - (q[1] - p[1]) * (px - p[0]) for p, q in ((a, b), (b, c), (c, a))] + inside |= ((sides[0] >= 0) & (sides[1] >= 0) & (sides[2] >= 0)) | ((sides[0] <= 0) & (sides[1] <= 0) & (sides[2] <= 0)) + image[y0:y1, x0:x1][inside] = 255 + draw_segments(image, arrow, np.roll(arrow, -1, axis=0), ARROW_OUTLINE / 2, 0, overlay=True) + + +class MapRenderer: + def __init__(self, archive: PMTiles): + self.archive = archive + self.zoom = min(max(ZOOM, archive.min_zoom), archive.max_zoom) + self.roads = lru_cache(maxsize=128)(self._load_roads) + + def _load_roads(self, x: int, y: int): + """A tile's visible road segments as flat arrays: mercator starts/ends, style index, level and polyline end flags.""" + tile = self.archive.get(self.zoom, x, y) + if tile is None: + return None + starts, ends, styles, levels, caps = [np.empty((0, 2))], [np.empty((0, 2))], [np.empty(0, int)], [np.empty(0, int)], [np.empty((0, 2), bool)] + for road in decode_roads(tile): + style = road_style(road) + if style is None: + continue + points = (road.points + np.array([x, y])) / (1 << self.zoom) + n = len(points) - 1 + starts.append(points[:-1]) + ends.append(points[1:]) + styles.append(np.full(n, style)) + levels.append(np.full(n, road_level(road))) + ends_of_line = np.zeros((n, 2), dtype=bool) + ends_of_line[0, 0] = ends_of_line[-1, 1] = True + caps.append(ends_of_line) + return np.concatenate(starts), np.concatenate(ends), np.concatenate(styles), np.concatenate(levels), np.concatenate(caps) + + def view_tiles(self, longitude: float, latitude: float) -> list[tuple[int, int]]: + """Tiles covering the view at a point for every heading.""" + origin = mercator(np.array([[longitude, latitude]]))[0] + # A bounding circle includes all headings, forward offset and road stroke widths. + radius = (SIZE / math.sqrt(2) + FORWARD_OFFSET + 16) / (EARTH_CIRCUMFERENCE * math.cos(math.radians(latitude))) + count = 1 << self.zoom + low = np.floor((origin - radius) * count).astype(int) + high = np.floor((origin + radius) * count).astype(int) + return [(x % count, y) for y in range(low[1], high[1] + 1) for x in range(low[0], high[0] + 1) if 0 <= y < count] + + def route_tiles(self, route: np.ndarray) -> set[tuple[int, int]]: + """Every tile the view can show while the pose is snapped onto the route, sampled every 100 m.""" + distance = cumulative_distance(route) + samples = np.column_stack([np.interp(np.arange(0, distance[-1], 100), distance, route[:, axis]) for axis in (0, 1)]) + return {tile for lon, lat in np.vstack((samples, route)).tolist() for tile in self.view_tiles(lon, lat)} + + def prefetch(self, route: np.ndarray): + self.archive.prefetch((self.zoom, x, y) for x, y in self.route_tiles(route)) + + def render(self, longitude: float, latitude: float, bearing: float, route: np.ndarray) -> tuple[np.ndarray, bool]: + """The map image at a position and bearing, and whether every tile was present with a road near the ego.""" + image = np.full((SIZE, SIZE), BACKGROUND, dtype=np.uint8) + loaded = [self.roads(x, y) for x, y in self.view_tiles(longitude, latitude)] + tiles = [tile for tile in loaded if tile is not None] + # The training renderer's latitude-dependent style zoom: the web mercator zoom with one meter per pixel + # at this latitude, under the 512-pixel tile convention. Tile zoom and style zoom are independent. + style_zoom = math.log2(math.cos(math.radians(latitude)) * EARTH_CIRCUMFERENCE / 256) - 1 + road_distance = math.inf + if tiles: + starts, ends, styles, levels, caps = (np.concatenate(column) for column in zip(*tiles, strict=True)) + starts, ends = project(starts, longitude, latitude, bearing), project(ends, longitude, latitude, bearing) + widths = stroke_widths(style_zoom, styles, levels) + if len(starts): + road_distance = nearest_segment(starts, ends, EGO)[2] + for level in np.unique(levels): + chosen = levels == level + if level > levels.min(): + # Cut the roads beneath, open at way endpoints so a bridge does not end in a black oval. + draw_segments(image, starts[chosen], ends[chosen], (widths[chosen] + CUT_WIDTH) / 2, BACKGROUND, + overlay=True, caps=~caps[chosen]) + draw_segments(image, starts[chosen], ends[chosen], widths[chosen] / 2, ROAD_INTENSITY) + pixels = project(mercator(route), longitude, latitude, bearing) + draw_segments(image, pixels[:-1], pixels[1:], ROUTE_WIDTH / 2, ROUTE_INTENSITY, overlay=True) + draw_vehicle(image) + return image, len(tiles) == len(loaded) and road_distance <= MAX_ROAD_DISTANCE diff --git a/openpilot/selfdrive/navigation/route.py b/openpilot/selfdrive/navigation/route.py new file mode 100644 index 0000000000..d1fc85aca7 --- /dev/null +++ b/openpilot/selfdrive/navigation/route.py @@ -0,0 +1,71 @@ +"""Planar geometry for routes, and route-relative map pose and progress tracking.""" +import numpy as np + +METERS_PER_DEGREE = 2 * np.pi * 6371008.8 / 360 # mean Earth radius, as Mapbox measures distances + +# Training renders the map on the route rather than at the measured position: +# the ego is projected onto the route, the frame is centered there and headed along the route's tangent. +PROJECTION_WINDOW = (-30.0, 100.0) # meters around the previous progress to search; a loop cannot capture the ego +HEADING_HALF_WINDOW = 50.0 # meters; the heading is the chord of the route this far behind and ahead + + +def local_meters(points, origin) -> np.ndarray: + """Equirectangular east/north meters of (longitude, latitude) points around an origin.""" + origin = np.asarray(origin, dtype=np.float64) + scale = np.array([METERS_PER_DEGREE * np.cos(np.radians(origin[1])), METERS_PER_DEGREE]) + return (np.asarray(points, dtype=np.float64) - origin) * scale + + +def nearest_segment(starts: np.ndarray, ends: np.ndarray, point) -> tuple[int, float, float]: + """(index, fraction along it, distance) of the closest spot on the segments, in the inputs' units.""" + ab = ends - starts + t = np.clip(((point - starts) * ab).sum(axis=1) / np.maximum((ab * ab).sum(axis=1), 1e-12), 0, 1) + d = np.linalg.norm(starts + t[:, None] * ab - point, axis=1) + i = int(d.argmin()) + return i, float(t[i]), float(d[i]) + + +def cumulative_distance(route: np.ndarray) -> np.ndarray: + """Meters along [(longitude, latitude), ...].""" + (lon1, lat1), (lon2, lat2) = route[:-1].T, route[1:].T + steps = np.hypot((lon2 - lon1) * np.cos(np.radians((lat1 + lat2) / 2)), lat2 - lat1) * METERS_PER_DEGREE + return np.r_[0.0, np.cumsum(steps)] + + +def along_path(path: np.ndarray, point) -> tuple[float, float]: + """(meters along a (longitude, latitude) path to a point, and the point's distance from the path): + the path up to the start of the nearest segment, then straight to the point.""" + local, p = local_meters(path, path[0]), local_meters(point, path[0]) + segment, _, lateral = nearest_segment(local[:-1], local[1:], p) + return float(cumulative_distance(path)[segment] + np.linalg.norm(local_meters(point, path[segment]))), lateral + + +class RouteTracker: + """Progress of the ego along a route, in meters, and the map pose at that progress.""" + + def __init__(self, route: np.ndarray): + self.route = np.asarray(route, dtype=np.float64).reshape((-1, 2)) + self.path = local_meters(self.route, self.route[0]) + self.distance = cumulative_distance(self.route) + self.progress = -np.inf # the first fix may land anywhere on the route + + def project(self, longitude: float, latitude: float) -> float: + """Advance to the nearest spot on the route within the window ahead of the previous progress.""" + near = np.flatnonzero((self.distance[1:] >= self.progress + PROJECTION_WINDOW[0]) & (self.distance[:-1] <= self.progress + PROJECTION_WINDOW[1])) + if not len(near): # only before the first fix, when the progress is still -inf + near = np.arange(len(self.path) - 1) + point = local_meters([longitude, latitude], self.route[0]) + segment, along, _ = nearest_segment(self.path[near], self.path[near + 1], point) + segment = near[segment] + self.progress = max(self.progress, self.distance[segment] + along * (self.distance[segment + 1] - self.distance[segment])) + return self.progress + + def pose(self, progress: float) -> tuple[float, float, float]: + """(longitude, latitude, bearing) on the route at a progress, headed along the +-50m chord.""" + ends = np.clip([progress - HEADING_HALF_WINDOW, progress, progress + HEADING_HALF_WINDOW], 0, self.distance[-1]) + (lon0, lon, lon1), (lat0, lat, lat1) = (np.interp(ends, self.distance, self.route[:, axis]) for axis in (0, 1)) + bearing = np.degrees(np.arctan2((lon1 - lon0) * np.cos(np.radians(lat0)), lat1 - lat0)) % 360 + return float(lon), float(lat), float(bearing) + + def snap(self, longitude: float, latitude: float) -> tuple[float, float, float]: + return self.pose(self.project(longitude, latitude)) diff --git a/openpilot/selfdrive/navigation/tests/test_navigation.py b/openpilot/selfdrive/navigation/tests/test_navigation.py new file mode 100644 index 0000000000..4db576018d --- /dev/null +++ b/openpilot/selfdrive/navigation/tests/test_navigation.py @@ -0,0 +1,485 @@ +import gzip +import re +import struct +import tempfile +import threading +import time +import unittest +from contextlib import closing, contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import numpy as np +import requests + +import openpilot.cereal.messaging as messaging +from openpilot.common.prefix import OpenpilotPrefix +from openpilot.selfdrive.navigation.mapsd import MapsD +from openpilot.selfdrive.navigation.navd import REROUTE_COUNTER_MIN, ROUTE_RESEND_PERIOD, RouteEngine, set_banner +from openpilot.selfdrive.navigation.pmtiles import PMTiles, decode_directory, tile_id +from openpilot.selfdrive.navigation.renderer import MAJOR, MEDIUM, MINOR, SIZE, STREET, TRACK, MapRenderer, draw_segments, mercator, project, stroke_widths +from openpilot.selfdrive.navigation.route import RouteTracker +from openpilot.selfdrive.navigation.tiles import RemoteTiles +from openpilot.selfdrive.navigation.vector_tile import decode_lines, decode_roads + +METER = 1 / 111319.49079327358 # degrees of latitude + + +def draw_line(image, points, intensity, width, **kwargs): + draw_segments(image, points[:-1], points[1:], width / 2, intensity, **kwargs) + + +def varint(value): + output = bytearray() + while value > 127: + output.append((value & 127) | 128) + value >>= 7 + output.append(value) + return bytes(output) + + +def field(number, value): + if isinstance(value, bytes): + return varint(number << 3 | 2) + varint(len(value)) + value + return varint(number << 3) + varint(value) + + +def road_tile(crossing=None): + # One vertical line, from (2048, 0) to (2048, 4096), and one non-road layer. With crossing='bridge' or 'tunnel', + # a horizontal line from (1024, 2048) to (3072, 2048) at that level. + geometry = b''.join(varint(v) for v in [9, 4096, 0, 10, 0, 8192]) + feature = field(2, b'\x00\x00') + field(3, 2) + field(4, geometry) + roads = field(15, 2) + field(1, b'roads') + field(2, feature) + if crossing is not None: + geometry = b''.join(varint(v) for v in [9, 2048, 4096, 10, 4096, 0]) + roads += field(2, field(2, b'\x00\x00\x01\x01') + field(3, 2) + field(4, geometry)) + roads += field(3, b'kind') + field(3, f'is_{crossing}'.encode()) + field(4, field(1, b'minor_road')) + field(4, field(7, 1)) + field(5, 4096) + return field(3, field(1, b'buildings')) + field(3, roads) + + +def make_archive(payload=None, leaf=False, run=1, max_zoom=0): + """A PMTiles archive with one zoom-zero tile, in the root directory or in a leaf directory.""" + tile = gzip.compress(road_tile() if payload is None else payload) + directory = gzip.compress(varint(1) + varint(0) + varint(run) + varint(len(tile)) + varint(1)) + child = directory if leaf else b'' + root = gzip.compress(varint(1) + varint(0) + varint(0) + varint(len(child)) + varint(1)) if leaf else directory + metadata = gzip.compress(b'{"vector_layers":[{"id":"roads"}]}') + header = bytearray(127) + header[:8] = b'PMTiles\x03' + offsets = [127, 127 + len(root), 127 + len(root) + len(metadata), 127 + len(root) + len(metadata) + len(child)] + struct.pack_into('<11Q', header, 8, offsets[0], len(root), offsets[1], len(metadata), offsets[2], len(child), offsets[3], len(tile), 1, 1, 1) + header[96:102] = bytes([1, 2, 2, 1, 0, max_zoom]) + return bytes(header + root + metadata + child + tile) + + +@contextmanager +def local_archive(data): + """A PMTiles reader over in-memory bytes, bypassing the tile server.""" + source = MagicMock(size=len(data)) + source.read.side_effect = lambda offset, length: data[offset:offset + length] + with closing(PMTiles(source)) as archive: + yield archive + + +def mapbox_response(steps): + response = MagicMock(status_code=200) + response.json.return_value = {'routes': [{'legs': [{'steps': steps}]}]} + return response + + +def route_engine(sm, pm, values): + params = MagicMock() + params.get.side_effect = values.get + params.remove.side_effect = lambda key: values.pop(key, None) + with patch('openpilot.selfdrive.navigation.navd.Api') as api: + api.return_value.get_token.return_value = 'test-token' + return RouteEngine(sm, pm, params=params) + + +class RangeHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.server.requests.append(dict(self.headers)) + if self.headers.get('If-Match', self.server.etag) != self.server.etag: + self.send_error(412) + return + if self.server.ignore_range: + self.send_response(200) + self.end_headers() + return + offset, end = map(int, re.fullmatch(r'bytes=(\d+)-(\d+)', self.headers['Range']).groups()) + data = self.server.archive[offset:end + 1] + self.send_response(206) + self.send_header('Content-Range', f'bytes {offset}-{end}/{len(self.server.archive)}') + self.send_header('Content-Length', str(len(data))) + self.send_header('ETag', self.server.etag) + self.end_headers() + self.wfile.write(data) + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 # stdlib override + pass + + +class TileServerCase(unittest.TestCase): + """Serves a leaf-directory archive over HTTP range requests, with the comma API and tile cache stubbed.""" + + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.cache = Path(self.directory.name) / 'navigation' / 'tiles' + self.server = ThreadingHTTPServer(('127.0.0.1', 0), RangeHandler) + self.server.archive = make_archive(leaf=True) + self.server.etag = '"test-version"' + self.server.ignore_range = False + self.server.requests = [] + threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True).start() + self.addCleanup(self.server.server_close) + self.addCleanup(self.server.shutdown) + self.api = MagicMock(dongle_id='test-dongle') + self.api.get_token.return_value = 'device-token' + self.url = f'http://127.0.0.1:{self.server.server_port}/planet.pmtiles?sig=secret' + self.api.get.return_value.json.return_value = {'url': self.url, 'expires': time.time() + 86400} # noqa: TID251 + for name, value in [('Api', MagicMock(return_value=self.api)), ('Params', MagicMock()), ('PC', True)]: + self.enterContext(patch(f'openpilot.selfdrive.navigation.tiles.{name}', value)) + self.enterContext(patch('openpilot.selfdrive.navigation.tiles.Paths.comma_home', return_value=self.directory.name)) + + +class TestEndToEnd(TileServerCase): + def test_navd_to_map_frames(self): + """navd routes a destination over messaging; mapsd fetches tiles over HTTP and renders map frames.""" + destination = {'latitude': 0.01, 'longitude': 0} + self.enterContext(patch('requests.get', return_value=mapbox_response([ + {'geometry': {'coordinates': [[0, -0.01], [0, 0.01]]}, 'distance': 2224, 'duration': 100, 'bannerInstructions': []}]))) + with OpenpilotPrefix(): + pm = messaging.PubMaster(['navInstruction', 'navRoute', 'gpsLocationExternal']) + sm = messaging.SubMaster(['gpsLocation', 'gpsLocationExternal', 'navRoute']) + values = {'NavDestination': dict(destination)} + navd = route_engine(messaging.SubMaster(['gpsLocation', 'gpsLocationExternal']), pm, values) + mapsd = MapsD() + self.addCleanup(mapsd.close) + + def frame(fix=True): + gps = messaging.new_message('gpsLocationExternal', valid=True) + gps.gpsLocationExternal.hasFix = fix + pm.send('gpsLocationExternal', gps) + navd.update() + sm.update(0) + return mapsd.update(sm) + + image, timestamp = frame() + # The route is centered over the white road, with the ego arrow three quarters down. + self.assertEqual((image[128, 128], image[128, 131], image[188, 128], image[195, 128]), (128, 255, 255, 0)) + self.assertEqual(timestamp, sm.logMonoTime['gpsLocationExternal']) + fetched = len(self.server.requests) + tracker = mapsd.tracker + navd.send_route() # the same route again, as a restarted mapsd would get it + frame() + self.assertIs(mapsd.tracker, tracker) # progress along it is kept + values.pop('NavDestination') + self.assertFalse(frame()[0].any()) # black without a route + values['NavDestination'] = dict(destination) + self.assertEqual(frame()[0][128, 128], 128) + self.assertEqual(len(self.server.requests), fetched) # the second route is served from the cache + image, timestamp = frame(fix=False) + self.assertFalse(image.any() or timestamp) # black, not stale, without a fix + self.api.get.assert_called_once() + self.assertTrue(all('Range' in headers for headers in self.server.requests)) + self.assertTrue(all(headers['If-Match'] == self.server.etag for headers in self.server.requests[1:])) + self.assertTrue(all(b'secret' not in p.read_bytes() for p in self.cache.iterdir())) + # The archive is replaced on the server and the cache evicted: the next fetch fails, the map goes black, + # and after a delay a fresh reader starts over on the new version. + self.server.etag = '"new-version"' + for cached in self.cache.iterdir(): + cached.unlink() + values.pop('NavDestination') + frame() + values['NavDestination'] = dict(destination) + image, timestamp = frame() + self.assertFalse(image.any() or timestamp) + fetched = len(self.server.requests) + self.assertFalse(frame()[1]) # not retried yet + self.assertEqual(len(self.server.requests), fetched) + mapsd.retry_at = 0 + self.assertEqual(frame()[0][128, 128], 128) + self.assertEqual((self.cache / 'version').read_text().split()[0], '"new-version"') + + +class TestRemoteTiles(TileServerCase): + def test_shared_cache_and_refresh(self): + with closing(PMTiles(RemoteTiles())) as archive: + archive.source.expires = 0 # the signed URL is refreshed before the next fetch + self.assertEqual(archive.get(0, 0, 0), road_tile()) + self.assertEqual(archive.get(0, 0, 0), road_tile()) + self.assertEqual(self.api.get.call_count, 2) + count = len(self.server.requests) + with closing(PMTiles(RemoteTiles())) as archive: + self.assertEqual(archive.get(0, 0, 0), road_tile()) + # A second reader revalidates only the header; directories and tiles are shared on disk. + self.assertEqual(len(self.server.requests), count + 1) + with patch('openpilot.selfdrive.navigation.tiles.CACHE_LIMIT', 1), closing(RemoteTiles()) as source: + source.read(1, 2) # an uncached range is downloaded, then the cache is trimmed to the limit + self.assertEqual([p.name for p in self.cache.iterdir()], ['version']) + + def test_prefetch_merges_nearby_ranges(self): + with patch('openpilot.selfdrive.navigation.tiles.MERGE_GAP', 16), closing(RemoteTiles()) as source: + count = len(self.server.requests) + source.prefetch([(20, 10), (0, 10), (60, 10), (20, 10)]) + self.assertEqual(len(self.server.requests), count + 2) # 0-29 in one request, 60-69 in another + self.assertEqual(self.server.requests[-2]['Range'], 'bytes=0-29') + for offset in (0, 20, 60): + self.assertEqual(source.read(offset, 10), self.server.archive[offset:offset + 10]) + self.assertEqual(len(self.server.requests), count + 2) + + def test_failures(self): + with closing(PMTiles(RemoteTiles())) as archive: + self.server.etag = '"new-version"' + with self.assertRaisesRegex(OSError, '412'): # the archive changed under an open reader + archive.get(0, 0, 0) + with closing(PMTiles(RemoteTiles())) as archive: # a new reader starts over on the new version + self.assertEqual(archive.get(0, 0, 0), road_tile()) + self.server.ignore_range = True + with self.assertRaisesRegex(OSError, '200'): + RemoteTiles() + self.api.get.return_value.raise_for_status.side_effect = requests.HTTPError(self.url) + with self.assertRaisesRegex(OSError, '^Unable to fetch map tiles$'): # the signed URL stays out of the error + RemoteTiles() + + +class TestRouteEngine(unittest.TestCase): + def setUp(self): + self.pm = MagicMock() + self.values = {'NavDestination': {'latitude': 0, 'longitude': 0.002}, 'LanguageSetting': 'main_en'} + self.sm = MagicMock() + self.sm.valid = dict.fromkeys(('gpsLocation', 'gpsLocationExternal'), True) + self.sm.logMonoTime = dict.fromkeys(self.sm.valid, time.monotonic_ns()) + self.sm.recv_time = dict.fromkeys(self.sm.valid, time.monotonic()) + self.sm.__getitem__.return_value = SimpleNamespace(hasFix=True, latitude=0, longitude=0, bearingDeg=90) + self.engine = route_engine(self.sm, self.pm, self.values) + self.engine.last_position, self.engine.last_bearing = (0, 0), 90 + distance = 111.195 # Mapbox's length of 0.001 degrees at the equator + self.response = mapbox_response([ + {'geometry': {'coordinates': [[0, 0], [0.0005, 0], [0.001, 0]]}, 'distance': distance, 'duration': 10, + 'bannerInstructions': [{'distanceAlongGeometry': 100, 'primary': {'text': 'Turn right', 'type': 'turn', 'modifier': 'right'}}]}, + {'geometry': {'coordinates': [[0.001, 0], [0.002, 0]]}, 'distance': distance, 'duration': 10, 'bannerInstructions': []}, + ]) + + def last_message(self, service): + return next(call.args[1] for call in reversed(self.pm.send.call_args_list) if call.args[0] == service) + + def load_route(self): + with patch('requests.get', return_value=self.response) as get: + self.engine.calculate_route((0.002, 0)) + self.engine.send_route() + return get + + def test_request_and_instruction(self): + get = self.load_route() + self.assertEqual(get.call_args.args[0], 'https://api.commadotai.com/v1/nav/directions/v5/mapbox/driving-traffic/0,0;0.002,0') + options = get.call_args.kwargs['params'] + self.assertEqual((options['access_token'], options['bearings'], options['language']), ('test-token', '90,90;', 'en')) + self.assertEqual(len(self.last_message('navRoute').navRoute.coordinates), 5) + self.engine.send_instruction() + message = self.last_message('navInstruction') + self.assertTrue(message.valid) + instruction = message.navInstruction.to_dict() + self.assertEqual((instruction['maneuverPrimaryText'], instruction['maneuverModifier'], instruction['timeRemaining']), ('Turn right', 'right', 20)) + self.engine.last_position = (0.0007, 0) # on the step's second segment + self.engine.send_instruction() + self.assertAlmostEqual(self.last_message('navInstruction').navInstruction.timeRemaining, 13, delta=0.05) + self.engine.last_position = (0.001, 0) # at the maneuver + self.engine.send_instruction() + self.assertAlmostEqual(self.last_message('navInstruction').navInstruction.maneuverDistance, 0, delta=0.01) + + def test_banner_selection(self): + banners = [{'distanceAlongGeometry': 100, 'primary': {'text': 'Turn right', 'type': 'turn', 'modifier': 'right'}}, + {'distanceAlongGeometry': 50, 'primary': {'text': 'Turn right'}, 'secondary': {'text': 'onto Main St'}}] + # The last banner still ahead of the ego, else the first: 120 m, 70 m and 30 m from the maneuver. + for distance, expected in [(120, ('Turn right', '', False)), (70, ('Turn right', '', True)), (30, ('Turn right', 'onto Main St', True))]: + instruction = messaging.new_message('navInstruction').navInstruction + set_banner(instruction, banners, distance) + self.assertEqual((instruction.maneuverPrimaryText, instruction.maneuverSecondaryText, instruction.showFull), expected) + + def test_route_sent_on_change_and_periodically(self): + sent = lambda: sum(call.args[0] == 'navRoute' for call in self.pm.send.call_args_list) # noqa: E731 + self.sm.frame = 1 + with patch('requests.get', return_value=self.response): + self.engine.update() # the route to a new destination is sent once + self.engine.update() + self.assertEqual(sent(), 1) + self.sm.frame = ROUTE_RESEND_PERIOD # and again periodically, for a mapsd that started after it + self.engine.update() + self.assertEqual(sent(), 2) + + def test_maneuvers_and_rerouting(self): + self.load_route() + self.engine.last_position = (0, 0.001) # 111 m off route: reroute after a few consecutive misses + for _ in range(REROUTE_COUNTER_MIN): + self.assertFalse(self.engine.should_recompute()) + self.assertTrue(self.engine.should_recompute()) + with patch('requests.get') as get: + self.engine.recompute_route(gps_ok=False) # but not while the GPS drifts in a tunnel + get.assert_not_called() + with patch('requests.get', return_value=self.response) as get: + self.engine.recompute_route(gps_ok=True) + self.engine.reroute_counter = REROUTE_COUNTER_MIN # still off route, but an immediate second recompute is held back + self.engine.recompute_route(gps_ok=True) + self.assertEqual(get.call_count, 1) + self.engine.last_position = (0.0012, 0) # past the first maneuver + self.engine.send_instruction() + self.assertEqual(self.engine.step_idx, 1) + self.engine.last_position = (0.00215, 0) # 17 m past the destination: arrived, but the route is kept nearby + self.engine.send_instruction() + self.assertIsNotNone(self.engine.route) + self.engine.last_position = (0.0024, 0) # driving away from the destination + self.engine.send_instruction() + self.assertIsNone(self.engine.route) + self.assertNotIn('NavDestination', self.values) + + def test_failed_request_clears_route(self): + self.load_route() + self.response.raise_for_status.side_effect = requests.HTTPError('test failure') + self.load_route() + self.assertIsNone(self.engine.route) + self.assertEqual(len(self.last_message('navRoute').navRoute.coordinates), 0) + self.engine.send_instruction() + self.assertFalse(self.last_message('navInstruction').valid) + + def test_gps_source(self): + fixes = {'gpsLocation': SimpleNamespace(hasFix=True, latitude=1, longitude=1, bearingDeg=180), + 'gpsLocationExternal': SimpleNamespace(hasFix=True, latitude=2, longitude=2, bearingDeg=0)} + self.sm.__getitem__.side_effect = fixes.get + self.sm.logMonoTime = {'gpsLocation': 2, 'gpsLocationExternal': 1} # replayed logs are judged by receipt time + with patch('requests.get', return_value=self.response): + self.engine.update() + self.assertEqual((self.engine.last_position, self.engine.last_bearing), ((1, 1), 180)) # the newest fix wins + self.sm.valid['gpsLocation'] = False + self.engine.update() + self.assertEqual(self.engine.last_position, (2, 2)) # unless it is invalid + fixes['gpsLocationExternal'] = SimpleNamespace(hasFix=True, latitude=3, longitude=3, bearingDeg=0) + self.sm.recv_time = dict.fromkeys(self.sm.valid, time.monotonic() - 3) # or stale + self.engine.update() + self.assertEqual(self.engine.last_position, (2, 2)) + + +class TestTiles(unittest.TestCase): + def test_pmtiles_format(self): + self.assertEqual([tile_id(1, x, y) for x, y in [(0, 0), (0, 1), (1, 1), (1, 0)]], [1, 2, 3, 4]) + self.assertEqual(sorted(tile_id(3, x, y) for x in range(8) for y in range(8)), list(range(21, 85))) + # IDs 5, 10; runs 3, 1; lengths 4, 6; the second offset follows the first blob. + data = bytes([2, 5, 5, 3, 1, 4, 6, 1, 0]) + self.assertEqual(decode_directory(data), ([5, 10], [(3, 4, 0), (1, 6, 4)])) + for invalid in (b'\x80', b'\x01\x00\x01\x01\x00', data + b'\x00'): + with self.assertRaises(ValueError): + decode_directory(invalid) + with local_archive(make_archive(run=3, max_zoom=1)) as archive: # the zoom-zero tile is also addressed by IDs 1 and 2 + self.assertEqual(archive.get(1, 0, 1), road_tile()) + self.assertIsNone(archive.get(1, 1, 1)) + with self.assertRaises(ValueError), local_archive(make_archive()[:-1]): + pass + + def test_vector_tiles(self): + roads = decode_roads(road_tile()) + self.assertEqual((len(roads), roads[0].kind), (1, 'minor_road')) + np.testing.assert_array_equal(roads[0].points, [[0.5, 0], [0.5, 1]]) + lines = decode_lines([9, 20, 20, 10, 10, 0, 9, 9, 9, 10, 0, 10]) + np.testing.assert_array_equal(lines[0], [[10, 10], [15, 10]]) + np.testing.assert_array_equal(lines[1], [[10, 5], [10, 10]]) + for data in (b'\x1a\xff', b'\x00', b'\x1a\x02\x0a\x04'): + with self.assertRaises(ValueError): + decode_roads(data) + for commands in ([10, 1, 1], [9, 0], [9, 0, 0, 15]): + with self.assertRaises(ValueError): + decode_lines(commands) + + +class TestRendering(unittest.TestCase): + def test_projection(self): + # At the equator, 64m ahead is the image center and the car is 64px below it, at 1m per pixel. + points = mercator(np.array([[0, 0], [0, 64 * METER], [64 * METER, 0]])) + np.testing.assert_allclose(project(points, 0, 0, 0), [[128, 192], [128, 128], [192, 192]], atol=1e-5) + np.testing.assert_allclose(project(points, 0, 0, 90), [[128, 192], [64, 192], [128, 128]], atol=1e-5) + pixels = project(mercator(np.array([[-179.999, 0]])), 179.999, 0, 90) # across the antimeridian + self.assertAlmostEqual(pixels[0, 1], 192 - 222.6389816, places=5) + + def test_strokes(self): + image = np.zeros((SIZE, SIZE), dtype=np.uint8) + draw_line(image, np.array([[-1000, 128], [1000, 128]]), 255, 8) + self.assertTrue(np.all(image[128] == 255)) + draw_line(image, np.array([[128, 0], [128, 256]]), 128, 5, overlay=True) # the grey route replaces the white road + self.assertEqual((image[128, 128], image[128, 131]), (128, 255)) + before = image.copy() + draw_line(image, np.array([[-1000, -1000], [-500, -500]]), 255, 5) + np.testing.assert_array_equal(image, before) + draw_line(image, np.array([[40, 40], [40, 40]]), 255, 5) # a zero-length segment is still a dot + self.assertEqual(image[40, 40], 255) + + def test_crossings(self): + route = np.array([[0.01, 0], [0.01, 0.01]]) # off to the side, out of the way of the probes + images = {} + for crossing in ('bridge', 'tunnel'): + with local_archive(make_archive(road_tile(crossing))) as archive: + images[crossing] = MapRenderer(archive).render(0, -40 * METER, 0, route)[0] # the crossing 40m ahead + # The horizontal road crosses the vertical one 40px above the ego, at row 152. A bridge severs the road beneath + # just outside its own stroke; a tunnel passes underneath untouched. The cut has butt caps, so the + # bridge's own end is not wrapped in black. + self.assertEqual((images['bridge'][146, 128], images['bridge'][152, 128], images['bridge'][152, 200], images['bridge'][140, 128]), (0, 255, 255, 255)) + self.assertEqual((images['tunnel'][146, 128], images['tunnel'][152, 200]), (255, 255)) + # The ground road cuts over the tunnel beneath it, and the tunnel is drawn without its casing. + self.assertEqual((images['tunnel'][152, 133], images['tunnel'][147, 200]), (0, 0)) + + def test_road_widths(self): + # Pinned at the style zoom of 1 m per pixel: 16.26 at the equator and 15.26 at 60 degrees. Tunnels have no casing. + styles, levels = np.array([MAJOR, MEDIUM, STREET, MINOR, TRACK, STREET]), np.array([0, 0, 0, 0, 0, -1]) + np.testing.assert_allclose(stroke_widths(16.256, styles, levels), [19.02, 15.48, 9.86, 6.70, 7.02, 7.89], atol=0.01) + np.testing.assert_allclose(stroke_widths(15.256, styles, levels), [13.52, 10.78, 6.39, 4.16, 4.58, 4.62], atol=0.01) + + def test_route_tiles(self): + route = np.array([[0, -0.01], [0, 0.01]]) # 2.2 km north along the prime meridian + with local_archive(make_archive(max_zoom=15)) as archive: + renderer = MapRenderer(archive) + tiles = renderer.route_tiles(route) + # Both columns beside the meridian, and the rows within the view radius of the route's ends. + self.assertEqual({x for x, _ in tiles}, {16383, 16384}) + self.assertEqual({y for _, y in tiles}, set(range(16382, 16386))) + + def test_validity_and_display(self): + route = np.array([[0, -0.01], [0, 0.01]]) + with local_archive(make_archive()) as archive: + renderer = MapRenderer(archive) + self.assertTrue(renderer.render(0, 0, 0, route)[1]) + self.assertFalse(renderer.render(0.01, 0, 0, route)[1]) # no road near the ego + with patch.object(archive, 'get', return_value=None): # a missing tile + image, valid = MapRenderer(archive).render(0, 0, 0, route) + self.assertFalse(valid) + self.assertEqual(image[128, 128], 128) # the route is still drawn + with local_archive(make_archive(field(3, field(1, b'buildings')))) as archive: # tiles without roads + self.assertFalse(MapRenderer(archive).render(0, 0, 0, route)[1]) + + +class TestRouteTracking(unittest.TestCase): + def test_snap_heading_and_window(self): + m = METER + tracker = RouteTracker(np.array([[0, 0], [0, 200 * m], [200 * m, 200 * m]])) # north 200m, then east 200m + longitude, latitude, bearing = tracker.snap(3 * m, 100 * m) # 3m right of the first leg + self.assertAlmostEqual(tracker.progress, 100, delta=0.5) + self.assertAlmostEqual(longitude, 0) # lateral error removed + self.assertAlmostEqual(latitude, 100 * m) + self.assertAlmostEqual(bearing, 0, places=3) + self.assertAlmostEqual(tracker.snap(0, 200 * m)[2], 45, places=2) # at the corner the +-50m chord is mid-turn + self.assertAlmostEqual(tracker.snap(0, 150 * m)[2], 45, places=2) # progress never decreases + tracker.snap(100 * m, 200 * m) # progress 300: the window ahead no longer includes the first leg + self.assertAlmostEqual(tracker.snap(150 * m, 200 * m)[0], 150 * m) # so the window's offset is applied + self.assertAlmostEqual(tracker.snap(200 * m, 200 * m)[2], 90, places=2) # the chord clips at the route's end + # Out and back along the same road: a fix near the far leg stays on the outbound leg within the window, + # while a fresh tracker searches anywhere and lands on the return leg. + route = np.array([[0, 0], [0, 1000 * m], [10 * m, 1000 * m], [10 * m, 0]]) + tracker = RouteTracker(route) + tracker.snap(1 * m, 100 * m) + self.assertAlmostEqual(tracker.snap(9 * m, 150 * m)[2], 0, places=3) + self.assertAlmostEqual(tracker.progress, 150, delta=0.5) + self.assertAlmostEqual(RouteTracker(route).snap(9 * m, 150 * m)[2], 180, places=3) + + +if __name__ == '__main__': + unittest.main() diff --git a/openpilot/selfdrive/navigation/tiles.py b/openpilot/selfdrive/navigation/tiles.py new file mode 100644 index 0000000000..c1196e3328 --- /dev/null +++ b/openpilot/selfdrive/navigation/tiles.py @@ -0,0 +1,117 @@ +"""Byte ranges of the hosted PMTiles basemap, cached on disk.""" +import re +import time +from pathlib import Path + +import requests + +from openpilot.common.api import Api +from openpilot.common.hardware import PC +from openpilot.common.hardware.hw import Paths +from openpilot.common.params import Params +from openpilot.selfdrive.navigation.pmtiles import HEADER_LENGTH + +CACHE_LIMIT = 64 * 1024 * 1024 +MERGE_GAP = 256 * 1024 # tiles this close together in the archive are fetched in one range request + + +class RemoteTiles: + def __init__(self): + self.cache = Path(Paths.comma_home() if PC else '/data') / 'navigation' / 'tiles' + self.cache.mkdir(parents=True, exist_ok=True, mode=0o700) + self.session = requests.Session() + self.api = Api(Params().get('DongleId')) + self.url = '' + self.expires = 0 + self.etag = None + self.size = 0 + version = self.cache / 'version' + try: + header = self._fetch(0, HEADER_LENGTH) # learns the archive's ETag and size + if not version.exists() or version.read_text() != f'{self.etag} {self.size}': + for cached in self.cache.iterdir(): # a new archive has new offsets + cached.unlink() + version.write_text(f'{self.etag} {self.size}') + self._store(0, header) + except Exception: + self.close() + raise + + def _fetch(self, offset: int, length: int) -> bytes: + try: + if time.time() >= self.expires - 60: # noqa: TID251 - SAS expiry is a Unix timestamp + response = self.api.get(f'v1/{self.api.dongle_id}/nav/tiles', access_token=self.api.get_token(), timeout=10) + response.raise_for_status() + metadata = response.json() + self.url, self.expires = metadata['url'], metadata['expires'] + headers = {'Range': f'bytes={offset}-{offset + length - 1}', 'Accept-Encoding': 'identity'} + if self.etag is not None: + headers['If-Match'] = self.etag + with self.session.get(self.url, headers=headers, timeout=10, stream=True) as response: + if response.status_code != 206: + raise OSError(f'Map tile range request failed ({response.status_code})') + match = re.fullmatch(r'bytes (\d+)-(\d+)/(\d+)', response.headers.get('Content-Range', '')) + if match is None or tuple(map(int, match.groups()[:2])) != (offset, offset + length - 1): + raise OSError('Invalid map tile range response') + size = int(match[3]) + etag = response.headers.get('ETag') + if not etag or (self.etag is not None and (etag != self.etag or size != self.size)): + raise OSError('Map archive changed during read') + data = bytearray() + for chunk in response.iter_content(chunk_size=min(length + 1, 65536)): + data.extend(chunk) + if len(data) > length: + raise OSError('Oversized map tile range response') + if len(data) != length: + raise OSError('Truncated map tile range response') + self.size, self.etag = size, etag + return bytes(data) + except (requests.RequestException, ValueError, KeyError, TypeError, RuntimeError): + # requests exceptions contain the signed URL; keep it out of logs. + raise OSError('Unable to fetch map tiles') from None + + def _store(self, offset: int, data: bytes): + (self.cache / f'{offset}-{len(data)}').write_bytes(data) + + def _trim(self): + files = [(cached.stat().st_mtime, cached.stat().st_size, cached) for cached in self.cache.iterdir() if re.fullmatch(r'\d+-\d+', cached.name)] + size = sum(cached_size for _, cached_size, _ in files) + for _, cached_size, cached in sorted(files): + if size <= CACHE_LIMIT: + break + cached.unlink(missing_ok=True) + size -= cached_size + + def read(self, offset: int, length: int) -> bytes: + try: + data = (self.cache / f'{offset}-{length}').read_bytes() + if len(data) == length: + return data + except FileNotFoundError: + pass + data = self._fetch(offset, length) + self._store(offset, data) + self._trim() + return data + + def prefetch(self, ranges): + """Cache (offset, length) ranges ahead of reads, fetching neighboring ones in a single request.""" + missing = sorted((offset, length) for offset, length in set(ranges) if not (self.cache / f'{offset}-{length}').exists()) + if not missing: + return + groups = [[missing[0]]] + for offset, length in missing[1:]: + last_offset, last_length = groups[-1][-1] + if offset - (last_offset + last_length) <= MERGE_GAP: + groups[-1].append((offset, length)) + else: + groups.append([(offset, length)]) + for group in groups: + start, end = group[0][0], group[-1][0] + group[-1][1] + data = self._fetch(start, end - start) + for offset, length in group: + self._store(offset, data[offset - start:offset - start + length]) + self._trim() + + def close(self): + self.session.close() diff --git a/openpilot/selfdrive/navigation/vector_tile.py b/openpilot/selfdrive/navigation/vector_tile.py new file mode 100644 index 0000000000..06439efa44 --- /dev/null +++ b/openpilot/selfdrive/navigation/vector_tile.py @@ -0,0 +1,133 @@ +"""Decode road polylines from Protomaps MVT tiles without a protobuf runtime.""" +from dataclasses import dataclass + +import numpy as np + +from openpilot.selfdrive.navigation.pmtiles import read_varint + + +@dataclass +class Road: + points: np.ndarray # fractions of the tile, x right / y down + kind: str + detail: str + tunnel: bool + bridge: bool + + +def fields(data: bytes): + offset = 0 + while offset < len(data): + tag, offset = read_varint(data, offset) + number, wire = tag >> 3, tag & 7 + if number == 0: + raise ValueError("Invalid protobuf field") + if wire == 0: + value, offset = read_varint(data, offset) + elif wire in (1, 2, 5): + if wire == 2: + length, offset = read_varint(data, offset) + else: + length = 8 if wire == 1 else 4 + if offset + length > len(data): + raise ValueError("Truncated protobuf field") + value = data[offset:offset + length] + offset += length + else: + raise ValueError(f"Unsupported protobuf wire type: {wire}") + yield number, wire, value + + +def integers(data: bytes) -> list[int]: + result = [] + offset = 0 + while offset < len(data): + value, offset = read_varint(data, offset) + result.append(value) + return result + + +def unzigzag(value: int) -> int: + return (value >> 1) ^ -(value & 1) + + +def decode_value(data: bytes): + """A string or boolean layer value. Other types are unused by the roads layer and decode to None.""" + for number, wire, value in fields(data): + if number == 1 and wire == 2: + return value.decode('utf-8') + if number == 7 and wire == 0: + return bool(value) + return None + + +def decode_lines(commands: list[int]) -> list[np.ndarray]: + lines = [] + line = [] + x = y = offset = 0 + while offset < len(commands): + command = commands[offset] + offset += 1 + kind, count = command & 7, command >> 3 + if kind not in (1, 2) or count == 0 or offset + 2 * count > len(commands): + raise ValueError("Invalid MVT line geometry") + if kind == 1 and count != 1: + raise ValueError("LineString MoveTo must contain one point") + if kind == 2 and not line: + raise ValueError("LineTo before MoveTo") + for _ in range(count): + x += unzigzag(commands[offset]) + y += unzigzag(commands[offset + 1]) + offset += 2 + if kind == 1: + if len(line) >= 2: + lines.append(np.array(line, dtype=np.float64)) + line = [] + line.append((x, y)) + if len(line) >= 2: + lines.append(np.array(line, dtype=np.float64)) + return lines + + +def decode_roads(data: bytes) -> list[Road]: + roads = [] + for number, wire, layer in fields(data): + if number != 3 or wire != 2: + continue + layer_fields = list(fields(layer)) + name = next((v.decode('utf-8') for n, w, v in layer_fields if n == 1 and w == 2), '') + if name != 'roads': + continue + version = next((v for n, w, v in layer_fields if n == 15 and w == 0), 1) + extent = next((v for n, w, v in layer_fields if n == 5 and w == 0), 4096) + if version not in (1, 2) or extent <= 0: + raise ValueError("Unsupported MVT roads layer") + keys = [v.decode('utf-8') for n, w, v in layer_fields if n == 3 and w == 2] + values = [decode_value(v) for n, w, v in layer_fields if n == 4 and w == 2] + for n, w, feature in layer_fields: + if n != 2 or w != 2: + continue + tags, geometry, geometry_type = [], [], 0 + for fn, fw, fv in fields(feature): + if fn in (2, 4): + target = tags if fn == 2 else geometry + if fw == 2: + target.extend(integers(fv)) + elif fw == 0: + target.append(fv) + elif fn == 3 and fw == 0: + geometry_type = fv + if geometry_type != 2: + continue + if len(tags) % 2: + raise ValueError("Unpaired MVT tags") + properties = {} + for i in range(0, len(tags), 2): + if tags[i] >= len(keys) or tags[i + 1] >= len(values): + raise ValueError("MVT tag index out of bounds") + properties[keys[tags[i]]] = values[tags[i + 1]] + kind = properties.get('kind', '') + if kind in ('highway', 'major_road', 'minor_road', 'other'): + roads.extend(Road(points / extent, kind, properties.get('kind_detail', ''), properties.get('is_tunnel') is True, + properties.get('is_bridge') is True) for points in decode_lines(geometry)) + return roads diff --git a/openpilot/selfdrive/pandad/__init__.py b/openpilot/selfdrive/pandad/__init__.py index 0c17e886a2..5f7a8fec86 100644 --- a/openpilot/selfdrive/pandad/__init__.py +++ b/openpilot/selfdrive/pandad/__init__.py @@ -1,3 +1 @@ -from openpilot.selfdrive.pandad.pandad_api_impl import can_list_to_can_capnp, can_capnp_to_list -assert can_list_to_can_capnp -assert can_capnp_to_list +from openpilot.selfdrive.pandad.pandad_api_impl import can_list_to_can_capnp as can_list_to_can_capnp, can_capnp_to_list as can_capnp_to_list diff --git a/openpilot/selfdrive/pandad/panda_comms.h b/openpilot/selfdrive/pandad/panda_comms.h index cdfb5019b6..77c085fdc2 100644 --- a/openpilot/selfdrive/pandad/panda_comms.h +++ b/openpilot/selfdrive/pandad/panda_comms.h @@ -8,7 +8,7 @@ #define TIMEOUT 0 -#define SPI_BUF_SIZE 2048 +#define SPI_BUF_SIZE 4096 class PandaSpiHandle { @@ -35,15 +35,18 @@ private: inline static std::recursive_mutex hw_lock; struct __attribute__((packed)) spi_header { - uint8_t sync; uint8_t endpoint; + uint32_t id; uint16_t tx_len; - uint16_t max_rx_len; + uint16_t rx_len; + }; + struct __attribute__((packed)) spi_resp_header { + uint8_t status; + uint32_t id; + uint16_t len; }; - int wait_for_ack(uint8_t ack, uint8_t tx, unsigned int timeout, unsigned int length); - int bulk_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t rx_len, unsigned int timeout); - int spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, unsigned int timeout); + int spi_transfer(uint8_t endpoint, uint32_t id, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, double deadline); int spi_transfer_retry(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, unsigned int timeout); int lltransfer(struct spi_ioc_transfer &t); diff --git a/openpilot/selfdrive/pandad/pandad.cc b/openpilot/selfdrive/pandad/pandad.cc index 23df5d6a83..8dc81606e8 100644 --- a/openpilot/selfdrive/pandad/pandad.cc +++ b/openpilot/selfdrive/pandad/pandad.cc @@ -139,6 +139,8 @@ void fill_panda_state(cereal::PandaState::Builder &ps, cereal::PandaState::Panda ps.setInterruptLoad(health.interrupt_load_pkt / 255.0f); ps.setFanPower(health.fan_power); ps.setSafetyRxChecksInvalid((health.flags_pkt & HEALTH_FLAG_SAFETY_RX_CHECKS_INVALID) != 0U); + ps.setNmiReset((health.flags_pkt & HEALTH_FLAG_NMI_RESET) != 0U); + ps.setHardfaultReset((health.flags_pkt & HEALTH_FLAG_HARDFAULT_RESET) != 0U); ps.setSpiErrorCount(health.spi_error_count_pkt); ps.setSbu1Voltage(health.sbu1_voltage_mV / 1000.0f); ps.setSbu2Voltage(health.sbu2_voltage_mV / 1000.0f); @@ -342,8 +344,8 @@ void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control, } } - // Disable IR on input timeout - if (nanos_since_boot() - last_cabin_camera_t > 1e9) { + // Disable IR on input timeout or when requested offroad. + if (nanos_since_boot() - last_cabin_camera_t > 1e9 || (!is_onroad && params.getBool("DisableDriverCameraIR"))) { ir_pwr = 0; } diff --git a/openpilot/selfdrive/pandad/spi.cc b/openpilot/selfdrive/pandad/spi.cc index 369032a533..8636861cb4 100644 --- a/openpilot/selfdrive/pandad/spi.cc +++ b/openpilot/selfdrive/pandad/spi.cc @@ -2,10 +2,12 @@ #include #include +#include #include #include #include #include +#include #include #include "common/util.h" @@ -15,26 +17,26 @@ #include "selfdrive/pandad/panda_comms.h" -#define SPI_SYNC 0x5AU -#define SPI_HACK 0x79U -#define SPI_DACK 0x85U -#define SPI_NACK 0x1FU -#define SPI_CHECKSUM_START 0xABU +// protocol, see panda/board/drivers/spi.h +#define SPI_SLAVE_READY 0x3CU +#define SPI_ACK 0xC3U +#define SPI_NACK 0x5AU +#define SPI_POLL 0xFFU // filler while reading the response, never a valid request + +#define SPI_CRC_SIZE 4U +#define SPI_XFER_SIZE (SPI_BUF_SIZE - 0x40) // bulk chunk size, leaves room for the protocol overhead +#define SPI_FIRST_READ_SIZE (1024 - sizeof(spi_resp_header) - SPI_CRC_SIZE) // reads are usually small, start small enum SpiError { NACK = -2, ACK_TIMEOUT = -3, + SILENT = -4, }; -const unsigned int SPI_ACK_TIMEOUT = 500; // milliseconds +const unsigned int SPI_MIN_TIMEOUT = 100; // milliseconds +const double SPI_SILENT_TIMEOUT = 1000; // milliseconds, longer than the slowest request takes on the panda 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: @@ -56,8 +58,8 @@ private: #define SPILOG(fn, fmt, ...) do { \ fn(fmt, ## __VA_ARGS__); \ fn(" %d / 0x%x / %d / %d / tx: %s", \ - xfer_count, header.endpoint, header.tx_len, header.max_rx_len, \ - util::hexdump(tx_buf, std::min((int)header.tx_len, 8)).c_str()); \ + xfer_count, header.endpoint, header.tx_len, header.rx_len, \ + util::hexdump(tx_buf + sizeof(header), std::min((int)header.tx_len, 8)).c_str()); \ } while (0) PandaSpiHandle::PandaSpiHandle(std::string serial) { @@ -88,7 +90,7 @@ PandaSpiHandle::PandaSpiHandle(std::string serial) { util::safe_ioctl(spi_fd, SPI_IOC_WR_BITS_PER_WORD, &spi_bits_per_word, "failed setting SPI bits per word"); // get hw UID/serial - ret = control_read(0xc3, 0, 0, uid, uid_len, 100); + ret = control_read(0xc3, 0, 0, uid, uid_len, 500); if (ret == uid_len) { std::stringstream stream; for (int i = 0; i < uid_len; i++) { @@ -146,40 +148,31 @@ int PandaSpiHandle::control_read(uint8_t request, uint16_t param1, uint16_t para } int PandaSpiHandle::bulk_write(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout) { - return bulk_transfer(endpoint, data, length, NULL, 0, timeout); -} -int PandaSpiHandle::bulk_read(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout) { - return bulk_transfer(endpoint, NULL, 0, data, length, timeout); -} - -int PandaSpiHandle::bulk_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t rx_len, unsigned int timeout) { - const int xfer_size = SPI_BUF_SIZE - 0x40; - - int ret = 0; - uint16_t length = (tx_data != NULL) ? tx_len : rx_len; - for (int i = 0; i < (int)std::ceil((float)length / xfer_size); i++) { - int d; - if (tx_data != NULL) { - int len = std::min(xfer_size, tx_len - (xfer_size * i)); - d = spi_transfer_retry(endpoint, tx_data + (xfer_size * i), len, NULL, 0, timeout); - } else { - uint16_t to_read = std::min(xfer_size, rx_len - ret); - d = spi_transfer_retry(endpoint, NULL, 0, rx_data + (xfer_size * i), to_read, timeout); - } - - if (d < 0) { - SPILOG(LOGE, "SPI: bulk transfer failed with %d", d); + for (int pos = 0; pos < length; pos += SPI_XFER_SIZE) { + int ret = spi_transfer_retry(endpoint, data + pos, std::min(SPI_XFER_SIZE, length - pos), NULL, 0, timeout); + if (ret < 0) { comms_healthy = false; - return d; + return ret; } + } + return length; +} - ret += d; - if ((rx_data != NULL) && d < xfer_size) { +int PandaSpiHandle::bulk_read(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout) { + int pos = 0; + while (pos < length) { + int to_read = std::min(pos == 0 ? (int)SPI_FIRST_READ_SIZE : SPI_XFER_SIZE, length - pos); + int ret = spi_transfer_retry(endpoint, NULL, 0, data + pos, to_read, timeout); + if (ret < 0) { + comms_healthy = false; + return ret; + } + pos += ret; + if (ret < to_read) { break; } } - - return ret; + return pos; } std::vector PandaSpiHandle::list() { @@ -192,228 +185,202 @@ std::vector PandaSpiHandle::list() { return {}; } -void add_checksum(uint8_t *data, int data_len) { - data[data_len] = SPI_CHECKSUM_START; - for (int i=0; i < data_len; i++) { - data[data_len] ^= data[i]; - } +// random, so it's never the same as another request the panda just handled, even from another client +static uint32_t random_id() { + static thread_local std::mt19937 rng{std::random_device{}()}; + return rng(); } -bool check_checksum(uint8_t *data, int data_len) { - uint8_t checksum = SPI_CHECKSUM_START; - for (uint16_t i = 0U; i < data_len; i++) { - checksum ^= data[i]; +// standard CRC-32, same as zlib's crc32() +static uint32_t crc32(const uint8_t *data, uint32_t len) { + static const auto table = [] { + std::array t{}; + for (uint32_t i = 0; i < 256; i++) { + uint32_t c = i; + for (int j = 0; j < 8; j++) { + c = (c & 1) ? (0xEDB88320U ^ (c >> 1)) : (c >> 1); + } + t[i] = c; + } + return t; + }(); + + uint32_t crc = 0xFFFFFFFFU; + for (uint32_t i = 0; i < len; i++) { + crc = table[(crc ^ data[i]) & 0xFFU] ^ (crc >> 8); } - return checksum == 0U; + return crc ^ 0xFFFFFFFFU; +} + +static void put_crc(uint8_t *data, uint32_t len) { + uint32_t crc = crc32(data, len); + memcpy(&data[len], &crc, sizeof(crc)); +} + +static bool check_crc(const uint8_t *data, uint32_t len) { + uint32_t crc; + memcpy(&crc, &data[len], sizeof(crc)); + return crc == crc32(data, len); } int PandaSpiHandle::spi_transfer_retry(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, unsigned int timeout) { + // timeout of 0 means forever + const double deadline = (timeout == 0) ? INFINITY : millis_since_boot() + std::max(timeout, SPI_MIN_TIMEOUT); + + // the id stays the same when we send the request again, so the panda never runs it twice + uint32_t id = random_id(); int ret; int nack_count = 0; - int timeout_count = 0; - bool timed_out = false; - double start_time = millis_since_boot(); - - do { - ret = spi_transfer(endpoint, tx_data, tx_len, rx_data, max_rx_len, timeout); - - if (ret < 0) { - timed_out = (timeout != 0) && (timeout_count > 5); - timeout_count += ret == SpiError::ACK_TIMEOUT; - - // give other threads a chance to run - std::this_thread::yield(); - - if (ret == SpiError::NACK) { - // prevent busy waiting while the panda is NACK'ing - // due to full TX buffers - nack_count += 1; - if (nack_count > 3) { - SPILOG(LOGD, "NACK sleep %d", nack_count); - usleep(std::clamp(nack_count*10, 200, 2000)); - } - } + while (true) { + ret = spi_transfer(endpoint, id, tx_data, tx_len, rx_data, max_rx_len, deadline); + if (ret >= 0 || !connected) { + break; } - } while (ret < 0 && connected && !timed_out); + + if (ret == SpiError::NACK) { + // the panda NACKs while its CAN TX buffers are full. that's not a comms + // failure, so keep trying regardless of the timeout, without busy waiting. + // a new id, since the panda would answer the same id with the same NACK + id = random_id(); + nack_count += 1; + if (nack_count > 3) { + usleep(std::clamp(nack_count*10, 200, 2000)); + } + } else if (ret == SpiError::SILENT) { + // the panda went quiet. let go of the bus for a bit, someone else might need it + LOGW("SPI: panda isn't responding"); + usleep(10000); + } else if (millis_since_boot() > deadline) { + break; + } + + // give other threads a chance to run + std::this_thread::yield(); + } if (ret < 0) { - SPILOG(LOGE, "transfer failed, after %d tries, %.2fms", timeout_count, millis_since_boot() - start_time); + SPILOG(LOGE, "SPI: transfer failed with %d", ret); } - return ret; } -int PandaSpiHandle::wait_for_ack(uint8_t ack, uint8_t tx, unsigned int timeout, unsigned int length) { - double start_millis = millis_since_boot(); - if (timeout == 0) { - timeout = SPI_ACK_TIMEOUT; - } - timeout = std::clamp(timeout, 20U, SPI_ACK_TIMEOUT); - - spi_ioc_transfer transfer = { - .tx_buf = (uint64_t)tx_buf, - .rx_buf = (uint64_t)rx_buf, - .len = length, - }; - memset(tx_buf, tx, length); - - while (true) { - int ret = lltransfer(transfer); - if (ret < 0) { - SPILOG(LOGE, "SPI: failed to send ACK request"); - return ret; - } - - if (rx_buf[0] == ack) { - break; - } else if (rx_buf[0] == SPI_NACK) { - SPILOG(LOGD, "SPI: got NACK, waiting for 0x%x", ack); - return SpiError::NACK; - } - - // handle timeout - if (millis_since_boot() - start_millis > timeout) { - SPILOG(LOGW, "SPI: timed out waiting for ACK, waiting for 0x%x", ack); - return SpiError::ACK_TIMEOUT; - } - } - - return 0; -} - int PandaSpiHandle::lltransfer(spi_ioc_transfer &t) { static const double err_prob = std::stod(util::getenv("SPI_ERR_PROB", "-1")); + auto chance = [](double p) { return (static_cast(rand()) / RAND_MAX) < p; }; + auto corrupt = [&](uint64_t buf) { + for (uint32_t i = 0; i < t.len; i++) { + if (chance(0.1)) ((uint8_t*)buf)[i] = (uint8_t)(rand() % 256); + } + }; - if (err_prob > 0) { - if ((static_cast(rand()) / RAND_MAX) < err_prob) { - printf("transfer len error\n"); - t.len = rand() % SPI_BUF_SIZE; - } - if ((static_cast(rand()) / RAND_MAX) < err_prob && t.tx_buf != (uint64_t)NULL) { - printf("corrupting TX\n"); - for (int i = 0; i < t.len; i++) { - if ((static_cast(rand()) / RAND_MAX) > 0.9) { - ((uint8_t*)t.tx_buf)[i] = (uint8_t)(rand() % 256); - } - } - } + if (err_prob > 0 && chance(err_prob)) { + printf("transfer len error\n"); + t.len = rand() % SPI_BUF_SIZE; + } + if (err_prob > 0 && chance(err_prob) && t.tx_buf == (uint64_t)tx_buf) { + printf("corrupting TX\n"); + corrupt(t.tx_buf); } int ret = util::safe_ioctl(spi_fd, SPI_IOC_MESSAGE(1), &t); - - if (err_prob > 0) { - if ((static_cast(rand()) / RAND_MAX) < err_prob && t.rx_buf != (uint64_t)NULL) { - printf("corrupting RX\n"); - for (int i = 0; i < t.len; i++) { - if ((static_cast(rand()) / RAND_MAX) > 0.9) { - ((uint8_t*)t.rx_buf)[i] = (uint8_t)(rand() % 256); - } - } - } + if (err_prob > 0 && chance(err_prob)) { + printf("ioctl error\n"); + ret = -1; } + if (err_prob > 0 && chance(err_prob)) { + printf("corrupting RX\n"); + corrupt(t.rx_buf); + } return ret; } -int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, unsigned int timeout) { - int ret; - uint16_t rx_data_len; +// One request and its response. Resends until the panda got the request and we got its response, +// or the deadline. Every transfer either sees all of what the panda queued from its first byte, or +// nothing it can mistake for a response. Returns the response length, or a negative SpiError +int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint32_t id, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, double deadline) { + static const auto poll = [] { + std::array p; + p.fill(SPI_POLL); + return p; + }(); + LockEx lock(spi_fd, hw_lock); - // needs to be less, since we need to have space for the checksum - assert(tx_len < SPI_BUF_SIZE); - assert(max_rx_len < SPI_BUF_SIZE); - - wait_for_spi_turnaround(spi_last_bus_activity_ns); + // the response is always read in one transfer of rx_len + const uint32_t rx_len = sizeof(spi_resp_header) + max_rx_len + SPI_CRC_SIZE; + const uint32_t req_len = sizeof(header) + tx_len; + assert(req_len + SPI_CRC_SIZE <= SPI_BUF_SIZE); + assert(rx_len <= SPI_BUF_SIZE); xfer_count++; header = { - .sync = SPI_SYNC, .endpoint = endpoint, + .id = id, .tx_len = tx_len, - .max_rx_len = max_rx_len + .rx_len = (uint16_t)rx_len, }; - spi_ioc_transfer transfer = { - .tx_buf = (uint64_t)tx_buf, - .rx_buf = (uint64_t)rx_buf + auto xfer = [&](const uint8_t *tx, uint32_t len) { + spi_ioc_transfer t = {.tx_buf = (uint64_t)tx, .rx_buf = (uint64_t)rx_buf, .len = len}; + int ret = lltransfer(t); + if (ret < 0) { + SPILOG(LOGE, "SPI: transfer failed"); + } + return ret; }; - // Send header - memcpy(tx_buf, &header, sizeof(header)); - add_checksum(tx_buf, sizeof(header)); - transfer.len = sizeof(header) + 1; - ret = lltransfer(transfer); - if (ret < 0) { - SPILOG(LOGE, "SPI: failed to send header"); - goto fail; - } + // when the panda last showed it's there. while it's quiet, we let go of the bus + double alive = millis_since_boot(); + while (true) { + // send the request, until the panda was ready for it. it's rebuilt every time, + // since SPI_ERR_PROB corrupts tx_buf + memcpy(tx_buf, &header, sizeof(header)); + if (tx_data != NULL) { + memcpy(tx_buf + sizeof(header), tx_data, tx_len); + } + put_crc(tx_buf, req_len); + // a failed transfer is sent again like any other, keeping the bus, so nothing can run in between + if (xfer(tx_buf, req_len + SPI_CRC_SIZE) >= 0 && rx_buf[0] == SPI_SLAVE_READY) { + alive = millis_since_boot(); + // read until the response. if the panda is ready again, it never got the request + do { + if (xfer(poll.data(), rx_len) < 0) { + break; + } + if (rx_buf[0] == SPI_SLAVE_READY) { + alive = millis_since_boot(); + break; + } - // Wait for (N)ACK - ret = wait_for_ack(SPI_HACK, 0x11, timeout, 1); - if (ret < 0) { - goto fail; - } - wait_for_spi_turnaround(nanos_since_boot()); + // a response that got cut off or corrupted isn't one, keep reading + spi_resp_header resp; + memcpy(&resp, rx_buf, sizeof(resp)); + if ((resp.status == SPI_ACK || resp.status == SPI_NACK) && resp.len <= max_rx_len && check_crc(rx_buf, sizeof(resp) + resp.len)) { + // otherwise it's the response to an old request, and we send ours again + if (resp.id == header.id) { + if (resp.status == SPI_NACK) { + SPILOG(LOGD, "SPI: got NACK"); + return SpiError::NACK; + } + if (rx_data != NULL) { + memcpy(rx_data, rx_buf + sizeof(resp), resp.len); + } + return resp.len; + } + alive = millis_since_boot(); + break; + } + } while (millis_since_boot() < std::min(deadline, alive + SPI_SILENT_TIMEOUT)); + } - // Send data - if (tx_data != NULL) { - memcpy(tx_buf, tx_data, tx_len); - } - add_checksum(tx_buf, tx_len); - transfer.len = tx_len + 1; - ret = lltransfer(transfer); - if (ret < 0) { - SPILOG(LOGE, "SPI: failed to send data"); - goto fail; - } - - // Wait for (N)ACK - ret = wait_for_ack(SPI_DACK, 0x13, timeout, 3); - if (ret < 0) { - goto fail; - } - - // Read data - rx_data_len = *(uint16_t *)(rx_buf+1); - if (rx_data_len >= SPI_BUF_SIZE) { - SPILOG(LOGE, "SPI: RX data len larger than buf size %d", rx_data_len); - goto fail; - } - - transfer.len = rx_data_len + 1; - transfer.rx_buf = (uint64_t)(rx_buf + 2 + 1); - ret = lltransfer(transfer); - if (ret < 0) { - SPILOG(LOGE, "SPI: failed to read rx data"); - goto fail; - } - if (!check_checksum(rx_buf, rx_data_len + 4)) { - SPILOG(LOGE, "SPI: bad checksum"); - goto fail; - } - - if (rx_data != NULL) { - memcpy(rx_data, rx_buf + 3, rx_data_len); - } - - spi_last_bus_activity_ns = nanos_since_boot(); - return rx_data_len; - -fail: - // ensure slave is in a consistent state - // and ready for the next transfer - int nack_cnt = 0; - while (nack_cnt < 3) { - if (wait_for_ack(SPI_NACK, 0x14, 1, SPI_BUF_SIZE/2) == 0) { - nack_cnt += 1; - } else { - nack_cnt = 0; + if (millis_since_boot() > deadline) { + SPILOG(LOGW, "SPI: timed out"); + return SpiError::ACK_TIMEOUT; + } + if (millis_since_boot() > alive + SPI_SILENT_TIMEOUT) { + return SpiError::SILENT; } } - - spi_last_bus_activity_ns = nanos_since_boot(); - if (ret >= 0) ret = -1; - return ret; } diff --git a/openpilot/selfdrive/pandad/tests/test_pandad_spi.py b/openpilot/selfdrive/pandad/tests/test_pandad_spi.py index 8ca39445a2..2818654282 100755 --- a/openpilot/selfdrive/pandad/tests/test_pandad_spi.py +++ b/openpilot/selfdrive/pandad/tests/test_pandad_spi.py @@ -45,9 +45,10 @@ class TestBoarddSpi(OpenpilotTestCase): st = time.monotonic() ts = {s: [] for s in socks.keys()} - for _ in range(int(os.getenv("TEST_TIME", "20"))): - # send some CAN messages - if not JUNGLE_SPAM: + test_time = int(os.getenv("TEST_TIME", "20")) + for i in range(test_time + 2): + # send some CAN messages, then give the last ones time to come back + if not JUNGLE_SPAM and i < test_time: sent = send_random_can_messages(sendcan, random.randrange(2, 20)) for k, v in sent.items(): sent_msgs[k].extend(list(v)) @@ -74,8 +75,7 @@ class TestBoarddSpi(OpenpilotTestCase): continue key = (msg.address, msg.dat) assert key in sent_msgs[msg.src], f"got unexpected msg: {msg.src=} {msg.address=} {msg.dat=}" - # TODO: enable this - #sent_msgs[msg.src].remove(key) + sent_msgs[msg.src].remove(key) total_recv_count += 1 elif service == "pandaStates": assert len(m.pandaStates) == 1 @@ -107,6 +107,8 @@ class TestBoarddSpi(OpenpilotTestCase): 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 + # every message came back exactly once, despite the injected errors + assert all(len(v) == 0 for v in sent_msgs.values()) if __name__ == "__main__": diff --git a/openpilot/selfdrive/selfdrived/alerts_offroad.json b/openpilot/selfdrive/selfdrived/alerts_offroad.json index b3b5077823..d3f5683606 100644 --- a/openpilot/selfdrive/selfdrived/alerts_offroad.json +++ b/openpilot/selfdrive/selfdrived/alerts_offroad.json @@ -21,22 +21,22 @@ "text": "Chestnut detected! Switch to the %1 branch to use chestnut-class models.", "severity": -1 }, + "Offroad_ChestnutModelError": { + "text": "Chestnut model error. Restart the car to retry.", + "severity": 0 + }, "Offroad_ChestnutNotDetected": { - "text": "Chestnut not detected. Check USB and 12V connections.", + "text": "Chestnut not detected. Check USB and 12V connections, then restart the car.", "severity": 0 }, "Offroad_ChestnutOverheated": { - "text": "Chestnut overheated. Ensure good airflow. Current GPU temperature is %1.", + "text": "Chestnut overheated. Ensure good airflow.", "severity": 0 }, "Offroad_ChestnutPcieUnavailable": { "text": "%1", "severity": 0 }, - "Offroad_ChestnutUncompiled": { - "text": "Chestnut model not compiled. Keep ignition on and reboot the comma.", - "severity": 0 - }, "Offroad_ChestnutUpdateFailed": { "text": "Chestnut update failed. Check the USB cable.", "severity": 0 @@ -49,6 +49,10 @@ "text": "Failed to register with comma.ai backend. It will not connect or upload to comma.ai servers, and receives no support from comma.ai. If this is a device purchased at comma.ai/shop, open a ticket at https://comma.ai/support.", "severity": 1 }, + "Offroad_UploadsIgnored": { + "text": "Uploads ignored due to a potential detected safety policy violation. Contact comma.ai/support if you believe this was made in error.", + "severity": 1 + }, "Offroad_CarUnrecognized": { "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 @@ -74,5 +78,10 @@ "text": "Unsupported branch detected - The current version of %1 branch is no longer supported on the comma three. Please go to [Device > Software] and install a supported branch with -tici in the branch name for the comma three.", "severity": 1, "_comment": "Set extra field to the current branch name." + }, + "Offroad_Pairing": { + "text": "Finish setup. Pair your device with comma connect to view recent drives and bookmarked routes.", + "severity": -2, + "icon": "icons_mici/settings/device/green_settings.png" } } diff --git a/openpilot/selfdrive/selfdrived/events.py b/openpilot/selfdrive/selfdrived/events.py index 8a8271b123..389c185621 100755 --- a/openpilot/selfdrive/selfdrived/events.py +++ b/openpilot/selfdrive/selfdrived/events.py @@ -194,7 +194,7 @@ def longitudinal_maneuver_alert(CP: car.CarParams, CS: car.CarState, sm: messagi def personality_changed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: personality = str(personality).title() - return NormalPermanentAlert(f"Driving Personality: {personality}", duration=1.5) + return NormalPermanentAlert(f"Following Distance: {personality}", duration=1.5) def invalid_lkas_setting_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: @@ -833,6 +833,10 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { EventName.userBookmark: { ET.PERMANENT: NormalPermanentAlert("Bookmark Saved", duration=1.5), }, + + EventName.userBookmarkNotPaired: { + ET.PERMANENT: NormalPermanentAlert("Bookmark Saved", "pair to connect\nto view", duration=1.5), + }, } diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index dfaa94e013..85bbb73576 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -29,7 +29,6 @@ from openpilot.common.hardware import HARDWARE from openpilot.sunnypilot.mads.mads import ModularAssistiveDrivingSystem 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 @@ -55,7 +54,7 @@ TurnDirection = custom.ModelDataV2SP.TurnDirection IGNORED_SAFETY_MODES = (SafetyModel.silent, SafetyModel.noOutput) -class SelfdriveD(CruiseHelper): +class SelfdriveD: def __init__(self, CP=None, CP_SP=None): self.params = Params() @@ -123,8 +122,6 @@ class SelfdriveD(CruiseHelper): # cleanup old params if not self.CP.alphaLongitudinalAvailable: self.params.remove("AlphaLongitudinalEnabled") - if not self.CP.openpilotLongitudinalControl: - self.params.remove("ExperimentalMode") self.CS_prev = car.CarState.new_message() self.AM = AlertManager() @@ -141,7 +138,6 @@ class SelfdriveD(CruiseHelper): self.events_prev = [] self.logged_comm_issue = None self.not_running_prev = None - self.experimental_mode = False self.personality = get_sanitize_int_param( "LongitudinalPersonality", min(log.LongitudinalPersonality.schema.enumerants.values()), @@ -182,7 +178,6 @@ 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): @@ -209,6 +204,7 @@ class SelfdriveD(CruiseHelper): 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_ready_t = time.monotonic() self.big_model_failed = big_failed # soft disable if the big model fails @@ -236,7 +232,9 @@ class SelfdriveD(CruiseHelper): # Check for user bookmark press if self.sm.updated['userBookmark']: - self.events.add(EventName.userBookmark) + prime_type = self.params.get("PrimeType") + paired = prime_type is not None and int(prime_type) >= 0 + self.events.add(EventName.userBookmark if paired else EventName.userBookmarkNotPaired) # Don't add any more events while in dashcam mode if self.CP.passive: @@ -453,11 +451,12 @@ class SelfdriveD(CruiseHelper): self.logged_comm_issue = None 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: + # the defaults of a message that was never received are not a localizer failure + if self.sm.seen['deviceMotion'] and not self.sm['deviceMotion'].posenetOK: self.events.add(EventName.posenetInvalid) - if not self.sm['deviceMotion'].inputsOK: + if self.sm.seen['deviceMotion'] and not self.sm['deviceMotion'].inputsOK: self.events.add(EventName.locationdTemporaryError) - if (not self.sm['vehicleParameters'].valid and cal_status == log.ExtrinsicsCalibration.Status.calibrated and + if (self.sm.seen['vehicleParameters'] and 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) @@ -505,23 +504,19 @@ class SelfdriveD(CruiseHelper): # TODO: fix simulator if not SIMULATION or REPLAY: - if self.sm['modelV2'].frameDropPerc > 1: + if self.sm['modelV2'].frameDropPerc > 1 and not big_model_settling: self.events.add(EventName.modeldLagging) # mute canBusMissing event if in Park, as it sometimes may trigger a false alarm with MADS in Paused state if CS.gearShifter == car.CarState.GearShifter.park and self.mads.enabled: self.events.remove(EventName.canBusMissing) - CruiseHelper.update(self, CS, self.events_sp, self.experimental_mode) - - # decrement personality on distance button press + # Decrement personality on distance button press if self.CP.openpilotLongitudinalControl: if any(not be.pressed and be.type == ButtonType.gapAdjustCruise for be in CS.buttonEvents): - if not self.experimental_mode_switched: - self.personality = (self.personality - 1) % 3 - self.params.put('LongitudinalPersonality', self.personality) - self.events.add(EventName.personalityChanged) - self.experimental_mode_switched = False + self.personality = (self.personality - 1) % 3 + self.params.put('LongitudinalPersonality', self.personality) + self.events.add(EventName.personalityChanged) self.icbm.run(CS, self.sm['carControl'], self.sm['longitudinalPlanSP'], self.is_metric) @@ -576,7 +571,7 @@ class SelfdriveD(CruiseHelper): clear_event_types = set() if ET.WARNING not in self.state_machine.current_alert_types: clear_event_types.add(ET.WARNING) - if self.enabled: + if self.enabled or (ET.NO_ENTRY not in (c := self.state_machine.current_alert_types) and (ET.ENABLE in c or ET.USER_DISABLE in c)): clear_event_types.add(ET.NO_ENTRY) pers = LONGITUDINAL_PERSONALITY_MAP[self.personality] @@ -598,7 +593,6 @@ class SelfdriveD(CruiseHelper): ss.active = self.active ss.state = self.state_machine.state ss.engageable = not self.events.contains(ET.NO_ENTRY) - ss.experimentalMode = self.experimental_mode ss.personality = self.personality ss.alertText1 = self.AM.current_alert.alert_text_1 @@ -665,7 +659,6 @@ class SelfdriveD(CruiseHelper): self.is_metric = self.params.get_bool("IsMetric") self.is_ldw_enabled = self.params.get_bool("IsLdwEnabled") self.disengage_on_accelerator = self.params.get_bool("DisengageOnAccelerator") - self.experimental_mode = self.params.get_bool("ExperimentalMode") and self.CP.openpilotLongitudinalControl self.personality = self.params.get("LongitudinalPersonality", return_default=True) self.mads.read_params() diff --git a/openpilot/selfdrive/test/chestnut.sh b/openpilot/selfdrive/test/chestnut.sh new file mode 100755 index 0000000000..6fc645ee69 --- /dev/null +++ b/openpilot/selfdrive/test/chestnut.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -e + +sudo python3 openpilot/system/hardware/chestnut/flash.py + +SCONSFLAGS="-j4" ./openpilot/system/manager/build.py diff --git a/openpilot/selfdrive/test/helpers.py b/openpilot/selfdrive/test/helpers.py index 71cdcee5e7..aa949edf70 100644 --- a/openpilot/selfdrive/test/helpers.py +++ b/openpilot/selfdrive/test/helpers.py @@ -105,17 +105,6 @@ def with_processes(processes, init_time=0, ignore_stopped=None): return wrapper -def noop(*args, **kwargs): - pass - - -def read_segment_list(segment_list_path): - with open(segment_list_path) as f: - seg_list = f.read().splitlines() - - return [(platform[2:], segment) for platform, segment in zip(seg_list[::2], seg_list[1::2], strict=True)] - - @contextlib.contextmanager def http_server_context(handler, setup=None): host = '127.0.0.1' @@ -133,32 +122,3 @@ def http_server_context(handler, setup=None): server.shutdown() server.server_close() t.join() - - -def with_http_server(func, handler=http.server.BaseHTTPRequestHandler, setup=None): - @wraps(func) - def inner(*args, **kwargs): - with http_server_context(handler, setup) as (host, port): - return func(*args, f"http://{host}:{port}", **kwargs) - return inner - - -def DirectoryHttpServer(directory) -> type[http.server.SimpleHTTPRequestHandler]: - # creates an http server that serves files from directory - class Handler(http.server.SimpleHTTPRequestHandler): - API_NO_RESPONSE = False - API_BAD_RESPONSE = False - - def do_GET(self): - if self.API_NO_RESPONSE: - return - - if self.API_BAD_RESPONSE: - self.send_response(500, "") - return - super().do_GET() - - def __init__(self, *args, **kwargs): - super().__init__(*args, directory=str(directory), **kwargs) - - return Handler diff --git a/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py b/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py index 99a7d8d690..f745f0b2d8 100644 --- a/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py +++ b/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py @@ -12,16 +12,12 @@ class Maneuver: self.breakpoints = kwargs.get("breakpoints", [0.0, duration]) self.speed_lead_values = kwargs.get("speed_lead_values", [0.0 for i in range(len(self.breakpoints))]) self.prob_lead_values = kwargs.get("prob_lead_values", [1.0 for i in range(len(self.breakpoints))]) - self.prob_throttle_values = kwargs.get("prob_throttle_values", [1.0 for i in range(len(self.breakpoints))]) self.cruise_values = kwargs.get("cruise_values", [50.0 for i in range(len(self.breakpoints))]) - self.pitch_values = kwargs.get("pitch_values", [0.0 for i in range(len(self.breakpoints))]) self.only_lead2 = kwargs.get("only_lead2", False) self.only_radar = kwargs.get("only_radar", False) self.ensure_start = kwargs.get("ensure_start", False) - self.ensure_slowdown = kwargs.get("ensure_slowdown", False) self.enabled = kwargs.get("enabled", True) - self.e2e = kwargs.get("e2e", False) self.personality = kwargs.get("personality", 0) self.force_decel = kwargs.get("force_decel", False) @@ -36,7 +32,6 @@ class Maneuver: enabled=self.enabled, only_lead2=self.only_lead2, only_radar=self.only_radar, - e2e=self.e2e, personality=self.personality, force_decel=self.force_decel, ) @@ -48,9 +43,7 @@ class Maneuver: 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) cruise = np.interp(plant.current_time, self.breakpoints, self.cruise_values) - pitch = np.interp(plant.current_time, self.breakpoints, self.pitch_values) - prob_throttle = np.interp(plant.current_time, self.breakpoints, self.prob_throttle_values) - log = plant.step(speed_lead, prob_lead, cruise, pitch, prob_throttle) + log = plant.step(speed_lead, prob_lead, cruise) d_rel = log['distance_lead'] - log['distance'] if self.lead_relevancy else 200. v_rel = speed_lead - log['speed'] if self.lead_relevancy else 0. @@ -77,10 +70,6 @@ class Maneuver: else: not_starting_t = 0.0 - if self.ensure_slowdown and log['speed'] > 5.5: - print('LongitudinalPlanner not slowing down!') - valid = False - if self.force_decel and log['speed'] > 1e-1 and log['acceleration'] > -0.04: print('Not stopping with force decel') valid = False diff --git a/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py b/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py index b4e8d76d6d..8a8e6e78ad 100755 --- a/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py +++ b/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py @@ -15,7 +15,7 @@ class Plant: messaging_initialized = False def __init__(self, lead_relevancy=False, speed=0.0, distance_lead=2.0, - enabled=True, only_lead2=False, only_radar=False, e2e=False, personality=0, force_decel=False): + enabled=True, only_lead2=False, only_radar=False, personality=0, force_decel=False): self.rate = 1. / DT_MDL if not Plant.messaging_initialized: @@ -39,7 +39,6 @@ class Plant: self.enabled = enabled self.only_lead2 = only_lead2 self.only_radar = only_radar - self.e2e = e2e self.personality = personality self.force_decel = force_decel @@ -59,19 +58,18 @@ class Plant: def current_time(self): return float(self.rk.frame) / self.rate - def step(self, v_lead=0.0, prob_lead=1.0, v_cruise=50., pitch=0.0, prob_throttle=1.0): + def step(self, v_lead=0.0, prob_lead=1.0, v_cruise=50.): # ******** publish a fake model going straight and fake calibration ******** # note that this is worst case for MPC, since model will delay long mpc by one time step radar = messaging.new_message('radarState') control = messaging.new_message('controlsState') ss = messaging.new_message('selfdriveState') car_state = messaging.new_message('carState') - lp = messaging.new_message('vehicleParameters') - car_control = messaging.new_message('carControl') model = messaging.new_message('modelV2') car_state_sp = messaging.new_message('carStateSP') live_map_data_sp = messaging.new_message('liveMapDataSP') gps_data = messaging.new_message('gpsLocation') + car_control = messaging.new_message('carControl') a_lead = (v_lead - self.v_lead_prev)/self.ts self.v_lead_prev = v_lead @@ -108,7 +106,7 @@ class Plant: # Simulate model predicting slightly faster speed # this is to ensure lead policy is effective when model - # does not predict slowdown in e2e mode + # does not predict slowdown 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 @@ -120,16 +118,13 @@ class Plant: acceleration = log.XYZTData.new_message() acceleration.x = [float(x) for x in np.zeros_like(ModelConstants.T_IDXS)] model.modelV2.acceleration = acceleration - model.modelV2.meta.disengagePredictions.gasPressProbs = [float(prob_throttle) for _ in range(6)] control.controlsState.longControlState = LongCtrlState.pid if self.enabled else LongCtrlState.off - ss.selfdriveState.experimentalMode = self.e2e ss.selfdriveState.personality = self.personality control.controlsState.forceDecel = self.force_decel car_state.carState.vEgo = float(self.speed) car_state.carState.standstill = bool(self.speed < 0.01) car_state.carState.vCruise = float(v_cruise * 3.6) - car_control.carControl.orientationNED = [0., float(pitch), 0.] # ******** get controlsState messages for plotting *** sm = {'radarState': radar.radarState, @@ -137,7 +132,6 @@ class Plant: 'carControl': car_control.carControl, 'controlsState': control.controlsState, 'selfdriveState': ss.selfdriveState, - '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 c8694b6ac2..226c1fa7ef 100644 --- a/openpilot/selfdrive/test/longitudinal_maneuvers/test_longitudinal.py +++ b/openpilot/selfdrive/test/longitudinal_maneuvers/test_longitudinal.py @@ -1,4 +1,3 @@ -import itertools from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized_class @@ -83,32 +82,6 @@ def create_maneuvers(kwargs): breakpoints=[0.0, 2., 2.01], **kwargs, ), - Maneuver( - "approach stopped car at 20m/s, with prob_throttle_values and pitch = -0.1", - duration=30., - initial_speed=20., - lead_relevancy=True, - initial_distance_lead=120., - speed_lead_values=[0.0, 0., 0.], - prob_throttle_values=[1., 0., 0.], - cruise_values=[20., 20., 20.], - pitch_values=[0., -0.1, -0.1], - breakpoints=[0.0, 2., 2.01], - **kwargs, - ), - Maneuver( - "approach stopped car at 20m/s, with prob_throttle_values and pitch = +0.1", - duration=30., - initial_speed=20., - lead_relevancy=True, - initial_distance_lead=120., - speed_lead_values=[0.0, 0., 0.], - prob_throttle_values=[1., 0., 0.], - cruise_values=[20., 20., 20.], - pitch_values=[0., 0.1, 0.1], - breakpoints=[0.0, 2., 2.01], - **kwargs, - ), Maneuver( "approach slower cut-in car at 20m/s", duration=20., @@ -152,19 +125,6 @@ def create_maneuvers(kwargs): **kwargs, ), ] - if not kwargs['e2e']: - maneuvers.append(Maneuver( - "slow to 5m/s with allow_throttle = False and pitch = +0.1", - duration=30., - initial_speed=20., - lead_relevancy=False, - prob_throttle_values=[1., 0., 0.], - cruise_values=[20., 20., 20.], - pitch_values=[0., 0.1, 0.1], - 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( @@ -181,14 +141,13 @@ def create_maneuvers(kwargs): return maneuvers -@parameterized_class(("e2e", "force_decel"), itertools.product([True, False], repeat=2)) +@parameterized_class("force_decel", [True, False]) class TestLongitudinalControl(OpenpilotTestCase): - e2e: bool force_decel: bool def test_maneuver(self, subtests): - for maneuver in create_maneuvers({"e2e": self.e2e, "force_decel": self.force_decel}): - with subtests.test(title=maneuver.title, e2e=maneuver.e2e, force_decel=maneuver.force_decel): - print(maneuver.title, f'in {"e2e" if maneuver.e2e else "acc"} mode') + for maneuver in create_maneuvers({"force_decel": self.force_decel}): + with subtests.test(title=maneuver.title, force_decel=maneuver.force_decel): + print(maneuver.title) valid, _ = maneuver.evaluate() assert valid diff --git a/openpilot/selfdrive/test/process_replay/migration.py b/openpilot/selfdrive/test/process_replay/migration.py index 1809331c15..d4c3872e59 100644 --- a/openpilot/selfdrive/test/process_replay/migration.py +++ b/openpilot/selfdrive/test/process_replay/migration.py @@ -4,6 +4,8 @@ from typing import cast import capnp import functools import traceback +import sys +import argparse from openpilot.cereal import messaging, log from opendbc.car.structs import car @@ -17,7 +19,7 @@ from openpilot.selfdrive.modeld.fill_model_msg import fill_xyz_poly, fill_lane_l 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, should_stop from openpilot.system.manager.process_config import managed_processes -from openpilot.tools.lib.logreader import LogIterable +from openpilot.tools.lib.logreader import LogIterable, LogReader, save_log MessageWithIndex = tuple[int, capnp.lib.capnp._DynamicStructReader] MigrationOps = tuple[list[tuple[int, capnp.lib.capnp._DynamicStructReader]], list[capnp.lib.capnp._DynamicStructReader], list[int]] @@ -231,8 +233,7 @@ def migrate_controlsState(msgs): m.logMonoTime = msg.logMonoTime ss = m.selfdriveState for field in ("enabled", "active", "state", "engageable", "alertText1", "alertText2", - "alertStatus", "alertSize", "alertType", "experimentalMode", - "personality"): + "alertStatus", "alertSize", "alertType", "personality"): setattr(ss, field, getattr(msg.controlsState.deprecated, field)) add_ops.append(as_reader(m)) return [], add_ops, [] @@ -390,7 +391,7 @@ def migrate_cameraStates(msgs): encode_id = frame_to_encode_id[msg.which()].get(camera_state.frameId) if encode_id is None: - print(f"Missing encoded frame for camera feed {msg.which()} with frameId: {camera_state.frameId}") + print(f"Missing encoded frame for camera feed {msg.which()} with frameId: {camera_state.frameId}", file=sys.stderr) if len(frame_to_encode_id[msg.which()]) != 0: del_ops.append(index) continue @@ -398,7 +399,7 @@ def migrate_cameraStates(msgs): # 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}") + print(f"Faking encodeId to {encode_id} for camera feed {msg.which()} with frameId: {camera_state.frameId}", file=sys.stderr) new_msg = messaging.new_message(msg.which()) new_camera_state = getattr(new_msg, new_msg.which()) @@ -424,6 +425,13 @@ def migrate_carParams(msgs): ops = [] for index, msg in msgs: CP = msg.as_builder() + # Normalize by mass after https://github.com/commaai/opendbc/pull/3858 + mass = CP.carParams.deprecated.mass + if mass > 0: + CP.carParams.rotationalInertiaPerMass /= mass + CP.carParams.tireStiffnessFrontPerMass /= mass + CP.carParams.tireStiffnessRearPerMass /= mass + CP.carParams.deprecated.mass = 0 CP.carParams.carFingerprint = MIGRATION.get(CP.carParams.carFingerprint, CP.carParams.carFingerprint) for car_fw in CP.carParams.carFw: car_fw.brand = CP.carParams.brand @@ -530,3 +538,14 @@ def migrate_driverMonitoringState(msgs): ops.append((index, as_reader(new_msg))) return ops, [], [] + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description="Migrate logs") + parser.add_argument("input_path", help="Segment identifier or path to file") + parser.add_argument("output_path", help="Path to output file") + args = parser.parse_args() + + mlr = migrate_all(LogReader(args.input_path)) + print(f"Saving migrated log to {args.output_path}", file=sys.stderr) + save_log(args.output_path , mlr) diff --git a/openpilot/selfdrive/test/process_replay/model_replay.py b/openpilot/selfdrive/test/process_replay/model_replay.py index 927c9b38f1..2ebb6f086f 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -20,11 +20,13 @@ from openpilot.tools.lib.framereader import FrameReader from openpilot.tools.lib.logreader import LogReader, save_log from openpilot.tools.lib.github_utils import GithubUtils -TEST_ROUTE = "8494c69d3c710e81|000001d4--2648a9a404" -SEGMENT = 4 +TEST_ROUTE = "98395b7c5b27882e|0000002b--2686b5a2d0" +SEGMENT = 1 START_FRAME = 0 END_FRAME = 60 +CHESTNUT = "--chestnut" in sys.argv + SEND_EXTRA_INPUTS = bool(int(os.getenv("SEND_EXTRA_INPUTS", "0"))) DATA_TOKEN = os.getenv("CI_ARTIFACTS_TOKEN","") @@ -39,7 +41,7 @@ EXEC_TIMINGS = [ ] def get_log_fn(test_route, ref="master"): - return f"{test_route}_model_tici_{ref}.zst" + return f"{test_route}_model_{'chestnut' if CHESTNUT else 'tici'}_{ref}.zst" def plot(proposed, master, title, tmp): proposed = list(proposed) @@ -78,11 +80,12 @@ def generate_report(proposed, master, tmp, commit): (lambda x: get_idx_if_non_empty(x.leftDriverData.faceOrientation, 0), "leftDriverData.faceOrientation0"), (lambda x: get_idx_if_non_empty(x.leftDriverData.leftBlinkProb), "leftDriverData.leftBlinkProb"), (lambda x: get_idx_if_non_empty(x.leftDriverData.phoneProb), "leftDriverData.phoneProb"), + (lambda x: get_idx_if_non_empty(x.leftDriverData.sleepProb), "leftDriverData.sleepProb"), (lambda x: get_idx_if_non_empty(x.rightDriverData.faceProb), "rightDriverData.faceProb"), ], "driverStateV2") return [plot(map(v[0], get_event(proposed, event)), \ - map(v[0], get_event(master, event)), f"{v[1]}_{commit[:7]}", tmp) \ + map(v[0], get_event(master, event)), f"{v[1]}_{'chestnut' if CHESTNUT else 'tici'}_{commit[:7]}", tmp) \ for v,event in ([*ModelV2_Plots] + [*DriverStateV2_Plots])] def create_table(title, files, link, open_table=False): @@ -102,7 +105,8 @@ def create_table(title, files, link, open_table=False): def comment_replay_report(proposed, master, full_logs): with tempfile.TemporaryDirectory() as tmp: PR_BRANCH = os.getenv("GIT_BRANCH","") - DATA_BUCKET = f"model_replay_{PR_BRANCH}" + model_type = "chestnut" if CHESTNUT else "tici" + DATA_BUCKET = f"model_replay_{PR_BRANCH}_{model_type}" try: GITHUB.get_pr_number(PR_BRANCH) @@ -123,8 +127,9 @@ def comment_replay_report(proposed, master, full_logs): link = GITHUB.get_bucket_link(DATA_BUCKET) diff_plots = create_table("Model Replay Differences", diff_files, link, open_table=True) all_plots = create_table("All Model Replay Plots", files, link) - comment = f"ref for commit {commit}: {link}/{log_name}" + diff_plots + all_plots - GITHUB.comment_on_pr(comment, PR_BRANCH, "commaci-public", True) + model_title = "Big model (Chestnut)" if CHESTNUT else "Small model" + comment = f"

Model Replay: {model_title}

ref for commit {commit}: {link}/{log_name}" + diff_plots + all_plots + GITHUB.comment_on_pr(comment, PR_BRANCH, "commaci-public", True, comment_marker=f"_model_{model_type}_") def trim_logs(logs, start_frame, end_frame, frs_types, include_all_types): all_msgs = [] @@ -148,7 +153,7 @@ def model_replay(lr, frs): # modeld is using frame pairs camera_states = {"narrowRoadCameraState", "wideRoadCameraState"} modeld_logs = trim_logs(lr, START_FRAME, END_FRAME, camera_states, - {"narrowRoadEncodeIdx", "wideRoadEncodeIdx", "carParams", "carState", "carControl", "can"}) + {"narrowRoadEncodeIdx", "wideRoadEncodeIdx", "carParams", "carState", "carControl", "controlsState", "can"}) dmodeld_logs = trim_logs(lr, START_FRAME, END_FRAME, {"cabinCameraState"}, {"cabinEncodeIdx", "carParams", "can"}) if not SEND_EXTRA_INPUTS: @@ -170,6 +175,8 @@ def model_replay(lr, frs): msgs = modeld_msgs + dmonitoringmodeld_msgs chestnut = any(m.modelV2.big for m in modeld_msgs if m.which() == "modelV2") + if CHESTNUT: + assert chestnut and all(m.modelV2.big for m in modeld_msgs if m.which() == "modelV2"), "Chestnut replay must run the big model without fallback" header = ['model', 'max instant', 'max instant allowed', 'average', 'max average allowed', 'test result'] rows = [] diff --git a/openpilot/selfdrive/test/process_replay/process_replay.py b/openpilot/selfdrive/test/process_replay/process_replay.py index 8e5d224ea8..af2190797a 100755 --- a/openpilot/selfdrive/test/process_replay/process_replay.py +++ b/openpilot/selfdrive/test/process_replay/process_replay.py @@ -17,7 +17,7 @@ from openpilot.common.hardware.hw import Paths import openpilot.cereal.messaging as messaging from opendbc.car.structs import car from openpilot.cereal.services import SERVICE_LIST -from msgq.visionipc import VisionIpcServer, get_endpoint_name as vipc_get_endpoint_name +from msgq.visionipc import VisionIpcClient, VisionIpcServer, get_endpoint_name as vipc_get_endpoint_name from opendbc.car.can_definitions import CanData from opendbc.car.car_helpers import get_car, interfaces from openpilot.common.params import Params @@ -28,7 +28,6 @@ from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from openpilot.system.manager.process_config import managed_processes from openpilot.selfdrive.car.card import convert_to_capnp from openpilot.selfdrive.test.process_replay.vision_meta import meta_from_camera_state, available_streams -from openpilot.selfdrive.test.process_replay.migration import migrate_all from openpilot.selfdrive.test.process_replay.capture import ProcessOutputCapture from openpilot.tools.lib.logreader import LogIterable from openpilot.tools.lib.framereader import FrameReader @@ -210,6 +209,7 @@ class ProcessContainer: stride, y_height, _, yuv_size = get_nv12_info(frame_size[0], frame_size[1]) vipc_server.create_buffers_with_sizes(meta.stream, 2, frame_size[0], frame_size[1], yuv_size, stride, stride * y_height) vipc_server.start_listener() + VisionIpcClient.available_streams("camerad", block=True) self.vipc_server = vipc_server self.cfg.vision_pubs = [meta.camera_state for meta in streams_metas if meta.camera_state in self.cfg.vision_pubs] @@ -483,7 +483,7 @@ CONFIGS = [ ), ProcessConfig( proc_name="plannerd", - pubs=["modelV2", "carControl", "carState", "controlsState", "vehicleParameters", "radarState", "selfdriveState"], + pubs=["modelV2", "carControl", "carState", "controlsState", "radarState", "selfdriveState"], subs=["longitudinalPlan", "driverAssistance"], ignore=["logMonoTime", "longitudinalPlan.processingDelay", "longitudinalPlan.solverExecutionTime"], init_callback=get_car_params_callback, @@ -554,7 +554,7 @@ CONFIGS = [ ProcessConfig( proc_name="modeld", pubs=["deviceState", "narrowRoadCameraState", "wideRoadCameraState", "extrinsicsCalibration", "lateralDelay", - "driverMonitoringState", "carState", "carControl"], + "driverMonitoringState", "carState", "carControl", "controlsState"], subs=["modelV2", "drivingModelData", "cameraOdometry"], ignore=["logMonoTime", "modelV2.frameDropPerc", "modelV2.modelExecutionTime", "drivingModelData.frameDropPerc", "drivingModelData.modelExecutionTime"], should_recv_callback=ModeldCameraSyncRcvCallback(), @@ -640,6 +640,8 @@ def replay_process( else: cfgs = list(cfg) + from openpilot.selfdrive.test.process_replay.migration import migrate_all + all_msgs = migrate_all(lr, manager_states=True, panda_states=any("pandaStates" in cfg.pubs for cfg in cfgs), diff --git a/openpilot/selfdrive/test/process_replay/test_processes.py b/openpilot/selfdrive/test/process_replay/test_processes.py index 5b72f7b712..0de9a953f4 100755 --- a/openpilot/selfdrive/test/process_replay/test_processes.py +++ b/openpilot/selfdrive/test/process_replay/test_processes.py @@ -36,7 +36,7 @@ source_segments = [ ("MAZDA", "bd6a637565e91581|2021-10-30--15-14-53--4"), # MAZDA.MAZDA_CX9_2021 ("FORD", "54827bf84c38b14f|2023-01-26--21-59-07--4"), # FORD.FORD_BRONCO_SPORT_MK1 ("RIVIAN", "bc095dc92e101734|000000db--ee9fe46e57--1"), # RIVIAN.RIVIAN_R1_GEN1 - ("TESLA", "2c912ca5de3b1ee9|0000025d--6eb6bcbca4--4"), # TESLA.TESLA_MODEL_Y + ("TESLA", "aebd8f1d4ea16066|000002d8--5850d3ea5c--2"), # TESLA.TESLA_MODEL_Y # Enable when port is tested and dashcamOnly is no longer set #("VOLKSWAGEN2", "3cfdec54aa035f3f|2022-07-19--23-45-10--2"), # VOLKSWAGEN.VOLKSWAGEN_PASSAT_NMS @@ -60,7 +60,7 @@ segments = [ ("MAZDA", "regenACF84CCF482|2024-08-30--03-21-55--0"), ("FORD", "regen755D8CB1E1F|2025-04-08--23-13-43--0"), ("RIVIAN", "regen5FCAC896BBE|2025-04-08--23-13-35--0"), - ("TESLA", "2c912ca5de3b1ee9|0000025d--6eb6bcbca4--4"), + ("TESLA", "aebd8f1d4ea16066|000002d8--5850d3ea5c--2"), ] # dashcamOnly makes don't need to be tested until a full port is done @@ -74,11 +74,19 @@ EXCLUDED_PROCS = {"modeld", "dmonitoringmodeld"} def run_test_process(data): segment, cfg, args, cur_log_fn, ref_log_path, lr_dat = data - ref_log_msgs = list(LogReader(ref_log_path)) + ref_error = None + try: + ref_log_msgs = list(LogReader(ref_log_path)) + except Exception: + # New routes have no ref yet, still replay and publish new refs. + ref_log_msgs = [] + ref_error = f"Failed to load reference {ref_log_path}:\n{traceback.format_exc()}" lr = LogReader.from_bytes(lr_dat) res, log_msgs = test_process(cfg, lr, segment, ref_log_msgs, cur_log_fn, args.ignore_fields, args.ignore_msgs) # save logs so we can update refs save_log(cur_log_fn, log_msgs) + if ref_error is not None: + return (segment, cfg.proc_name, ref_error, ref_error) try: diff_data = diff_process(cfg, ref_log_msgs, log_msgs) except Exception: diff --git a/openpilot/selfdrive/test/scons_build_test.sh b/openpilot/selfdrive/test/scons_build_test.sh index 6f6eafcad1..dfdaf6189f 100755 --- a/openpilot/selfdrive/test/scons_build_test.sh +++ b/openpilot/selfdrive/test/scons_build_test.sh @@ -3,7 +3,7 @@ set -e SCRIPT_DIR=$(dirname "$0") BASEDIR=$(realpath "$SCRIPT_DIR/../../../") -cd $BASEDIR +cd "$BASEDIR" # tests that our build system's dependencies are configured properly, # needs a machine with lots of cores @@ -11,7 +11,7 @@ cd $BASEDIR # helpful commands: # scons -Q --tree=derived -cd $BASEDIR/opendbc_repo/ +cd "$BASEDIR/opendbc_repo/" scons --clean scons --no-cache --random if ! scons -q; then diff --git a/openpilot/selfdrive/test/setup_device_ci.sh b/openpilot/selfdrive/test/setup_device_ci.sh index 60631ce037..e3ee1fc3db 100755 --- a/openpilot/selfdrive/test/setup_device_ci.sh +++ b/openpilot/selfdrive/test/setup_device_ci.sh @@ -29,7 +29,7 @@ if [ -d /data/safe_staging/ ]; then fi CONTINUE_PATH="/data/continue.sh" -tee $CONTINUE_PATH << EOF +tee "$CONTINUE_PATH" << EOF #!/usr/bin/env bash sudo abctl --set_success @@ -54,13 +54,18 @@ done sleep infinity EOF -chmod +x $CONTINUE_PATH +chmod +x "$CONTINUE_PATH" export GIT_LFS_SKIP_SMUDGE=1 pull_lfs() { - # The big driving model is not used on these devices yet. Keep its pointer in - # the worktree, but don't download or copy the 1.8 GB LFS object. - LFS_EXCLUDE="openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" + if [ -n "${CHESTNUT:-}" ] + then + git lfs pull --exclude='' + return + fi + + # Keep the precompiled big model as a pointer on devices without Chestnut. + LFS_EXCLUDE="openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl" git config --local lfs.fetchexclude "$LFS_EXCLUDE" git lfs pull --exclude="$LFS_EXCLUDE" @@ -81,16 +86,16 @@ pull_lfs() { safe_checkout() { # completely clean TEST_DIR - cd $SOURCE_DIR + cd "$SOURCE_DIR" # cleanup orphaned locks find .git -type f -name "*.lock" -exec rm {} + git reset --hard - git fetch --no-tags --no-recurse-submodules -j4 --verbose --depth 1 origin $GIT_COMMIT + git fetch --no-tags --no-recurse-submodules -j4 --verbose --depth 1 origin "$GIT_COMMIT" find . -maxdepth 1 -not -path './.git' -not -name '.' -not -name '..' -exec rm -rf '{}' \; - git reset --hard $GIT_COMMIT - git checkout $GIT_COMMIT + git reset --hard "$GIT_COMMIT" + git checkout "$GIT_COMMIT" git clean -xdff git submodule sync git submodule foreach --recursive "git reset --hard && git clean -xdff" @@ -100,22 +105,22 @@ safe_checkout() { pull_lfs echo "git checkout done, t=$SECONDS" - du -hs $SOURCE_DIR $SOURCE_DIR/.git + du -hs "$SOURCE_DIR" "$SOURCE_DIR/.git" - rsync -a --delete $SOURCE_DIR $TEST_DIR + rsync -a --delete "$SOURCE_DIR" "$TEST_DIR" } unsafe_checkout() {( set -e # checkout directly in test dir, leave old build products - cd $TEST_DIR + cd "$TEST_DIR" # cleanup orphaned locks find .git -type f -name "*.lock" -exec rm {} + - git fetch --no-tags --no-recurse-submodules -j8 --verbose --depth 1 origin $GIT_COMMIT - git checkout --force --no-recurse-submodules $GIT_COMMIT - git reset --hard $GIT_COMMIT + git fetch --no-tags --no-recurse-submodules -j8 --verbose --depth 1 origin "$GIT_COMMIT" + git checkout --force --no-recurse-submodules "$GIT_COMMIT" + git reset --hard "$GIT_COMMIT" git clean -dff git submodule sync git submodule foreach --recursive "git reset --hard && git clean -df" @@ -129,7 +134,7 @@ export GIT_PACK_THREADS=8 # set up environment if [ ! -d "$SOURCE_DIR" ]; then - git clone https://github.com/commaai/openpilot.git $SOURCE_DIR + git clone https://github.com/commaai/openpilot.git "$SOURCE_DIR" fi if [ ! -z "$UNSAFE" ]; then @@ -146,7 +151,7 @@ else fi # submodule package symlinks for PYTHONPATH imports on device (same as launch_chffrplus.sh) -cd $TEST_DIR +cd "$TEST_DIR" ln -sfn msgq_repo/msgq msgq ln -sfn opendbc_repo/opendbc opendbc ln -sfn rednose_repo/rednose rednose diff --git a/openpilot/selfdrive/test/test_onroad.py b/openpilot/selfdrive/test/test_onroad.py index f524046abe..e0d55432d2 100755 --- a/openpilot/selfdrive/test/test_onroad.py +++ b/openpilot/selfdrive/test/test_onroad.py @@ -11,7 +11,7 @@ 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.common.utils import sudo_write, tabulate from openpilot.cereal import log import openpilot.cereal.messaging as messaging @@ -20,8 +20,12 @@ from openpilot.common.basedir import BASEDIR from openpilot.common.timeout import Timeout from openpilot.common.params import Params from openpilot.selfdrive.selfdrived.events import EVENTS, ET -from openpilot.selfdrive.test.helpers import set_params_enabled, release_only +from openpilot.selfdrive.test.helpers import set_params_enabled, release_only, processes_context, log_collector +from openpilot.common.hardware import HARDWARE from openpilot.common.hardware.hw import Paths +from openpilot.common.mock import mock_messages +from opendbc.car.car_helpers import get_demo_car_params +from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled from openpilot.tools.lib.logreader import LogReader from openpilot.tools.lib.log_time_series import msgs_to_time_series @@ -457,5 +461,66 @@ class TestOnroad(OpenpilotTestCase): f"Not engageable for whole segment:\n- selfdriveState.engageable: {Counter(eng)}\n- No entry events: {no_entries}" +CHESTNUT_VBUS = "/sys/kernel/debug/regulator/smb2-vbus/enable" + +@unittest.skipUnless(HARDWARE.get_device_type() == "mici", "requires MICI") +class TestChestnutOnroad(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True + + @mock_messages(['deviceMotion']) + def test_big_model(self, subtests): + assert chestnut_present() and chestnut_compiled() + Params().put("CarParams", get_demo_car_params().to_bytes(), block=True) + services = ['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState', 'modelV2', 'driverStateV2'] + sm = messaging.SubMaster(services) + pm = messaging.PubMaster(['deviceState']) + device_state = messaging.new_message('deviceState') + device_state.deviceState.deviceType = HARDWARE.get_device_type() + device_state_bytes = device_state.to_bytes() + with processes_context(['camerad', 'calibrationd', 'modeld', 'dmonitoringmodeld']): + with Timeout(60, "camera models didn't start"): + while not all(sm.seen.values()) or not sm.valid['modelV2']: + pm.send('deviceState', device_state_bytes) + sm.update(1000) + # test big model and camera timings + with log_collector(services) as (logs, _): + time.sleep(TEST_DURATION) + + # test small model fallback by cutting VBUS which simulates usb disconnect + try: + sudo_write("0", CHESTNUT_VBUS) + with Timeout(10, "modeld didn't fall back to the small model"): + while sm['modelV2'].big: + sm.update(1000) + finally: + sudo_write("1", CHESTNUT_VBUS) + + # test small model keeps running + with log_collector(['modelV2']) as (small_model_logs, _): + time.sleep(3) + + with Timeout(10, "chestnut did not enumerate"): + while not chestnut_present(): + time.sleep(0.5) + + msgs = {s: [m for m in logs if m.which() == s] for s in services} + for service, messages in msgs.items(): + with subtests.test(service=service): + expected = TEST_DURATION * SERVICE_LIST[service].frequency + assert np.isclose(len(messages), expected, rtol=0.05, atol=2), f"{service}: expected {expected}, got {len(messages)}" + assert all(m.valid for m in messages) + frame_ids = [getattr(m, service).frameId for m in messages] + assert np.all(np.diff(frame_ids) > 0), f"{service}: repeated or reordered frames" + + camera_frames = {m.narrowRoadCameraState.frameId for m in msgs['narrowRoadCameraState']} + model_frames = {m.modelV2.frameId for m in msgs['modelV2']} + assert len(camera_frames & model_frames) >= TEST_DURATION * SERVICE_LIST['modelV2'].frequency * 0.9 + assert all(m.modelV2.big for m in msgs['modelV2']), "chestnut fell back to the small model" + assert all(np.isfinite(m.modelV2.position.x).all() for m in msgs['modelV2']) + assert Params().get("ChestnutActive") is False + expected = 3 * SERVICE_LIST['modelV2'].frequency + assert np.isclose(len(small_model_logs), expected, rtol=0.05, atol=2), f"small model: expected {expected}, got {len(small_model_logs)}" + + if __name__ == "__main__": unittest.main() diff --git a/openpilot/selfdrive/ui/layouts/home.py b/openpilot/selfdrive/ui/layouts/home.py index 1badf66116..6879ccaaef 100644 --- a/openpilot/selfdrive/ui/layouts/home.py +++ b/openpilot/selfdrive/ui/layouts/home.py @@ -1,10 +1,8 @@ import time import pyray as rl -from collections.abc import Callable from enum import IntEnum from openpilot.common.params import Params from openpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAlert -from openpilot.selfdrive.ui.widgets.exp_mode_button import ExperimentalModeButton from openpilot.selfdrive.ui.widgets.prime import PrimeWidget from openpilot.selfdrive.ui.widgets.setup import SetupWidget from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -39,7 +37,6 @@ class HomeLayout(Widget): self.current_state = HomeLayoutState.HOME self.last_refresh = 0 - self.settings_callback: Callable[[], None] | None = None self.update_available = False self.alert_count = 0 @@ -58,29 +55,20 @@ class HomeLayout(Widget): self._prime_widget = PrimeWidget() self._setup_widget = SetupWidget() - self._exp_mode_button = ExperimentalModeButton() self._setup_callbacks() def show_event(self): super().show_event() - self._exp_mode_button.show_event() self.last_refresh = time.monotonic() self._refresh() def _setup_callbacks(self): self.update_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME)) self.offroad_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME)) - self._exp_mode_button.set_click_callback(lambda: self.settings_callback() if self.settings_callback else None) - - def set_settings_callback(self, callback: Callable): - self.settings_callback = callback def _set_state(self, state: HomeLayoutState): # propagate show/hide events if state != self.current_state: - if state == HomeLayoutState.HOME: - self._exp_mode_button.show_event() - if state in self._layout_widgets: self._layout_widgets[state].show_event() if self.current_state in self._layout_widgets: @@ -194,19 +182,7 @@ class HomeLayout(Widget): self._prime_widget.render(self.left_column_rect) def _render_right_column(self): - exp_height = 125 - exp_rect = rl.Rectangle( - self.right_column_rect.x, self.right_column_rect.y, self.right_column_rect.width, exp_height - ) - self._exp_mode_button.render(exp_rect) - - setup_rect = rl.Rectangle( - self.right_column_rect.x, - self.right_column_rect.y + exp_height + SPACING, - self.right_column_rect.width, - self.right_column_rect.height - exp_height - SPACING, - ) - self._setup_widget.render(setup_rect) + self._setup_widget.render(self.right_column_rect) def _refresh(self): self._version_text = self._get_version_text() diff --git a/openpilot/selfdrive/ui/layouts/main.py b/openpilot/selfdrive/ui/layouts/main.py index c4224d6467..2e002b0318 100644 --- a/openpilot/selfdrive/ui/layouts/main.py +++ b/openpilot/selfdrive/ui/layouts/main.py @@ -63,7 +63,6 @@ class MainLayout(Widget): on_flag=self._on_bookmark_clicked, open_settings=lambda: self.open_settings(PanelType.TOGGLES)) self._layouts[MainState.HOME]._setup_widget.set_open_settings_callback(lambda: self.open_settings(PanelType.FIREHOSE)) - self._layouts[MainState.HOME].set_settings_callback(lambda: self.open_settings(PanelType.TOGGLES)) self._layouts[MainState.SETTINGS].set_callbacks(on_close=self._set_mode_for_state) for layout in (self._layouts[MainState.ONROAD], self._home_body_layout): diff --git a/openpilot/selfdrive/ui/layouts/settings/developer.py b/openpilot/selfdrive/ui/layouts/settings/developer.py index ef9c7f9e03..5c3f1427f3 100644 --- a/openpilot/selfdrive/ui/layouts/settings/developer.py +++ b/openpilot/selfdrive/ui/layouts/settings/developer.py @@ -182,7 +182,6 @@ class DeveloperLayout(Widget): def _on_lat_maneuver_mode(self, state: bool): self._params.put_bool("LateralManeuverMode", state, block=True) - self._params.put_bool("ExperimentalMode", False, block=True) self._params.put_bool("JoystickDebugMode", False, block=True) self._joystick_toggle.action_item.set_state(False) self._params.put_bool("LongitudinalManeuverMode", False, block=True) diff --git a/openpilot/selfdrive/ui/layouts/settings/firehose.py b/openpilot/selfdrive/ui/layouts/settings/firehose.py index 8ac6fe3d23..b06840e4d8 100644 --- a/openpilot/selfdrive/ui/layouts/settings/firehose.py +++ b/openpilot/selfdrive/ui/layouts/settings/firehose.py @@ -11,7 +11,7 @@ TITLE = tr_noop("Firehose Mode") DESCRIPTION = tr_noop( "sunnypilot learns to drive by watching humans, like you, drive.\n\n" + "Firehose Mode allows you to maximize your training data uploads to improve " - + "openpilot's driving models. More data means bigger models, which means better Experimental Mode." + + "openpilot's driving models. More data means bigger models, which means better driving." ) INSTRUCTIONS = tr_noop( "For maximum effectiveness, bring your device inside and connect to a good USB-C adapter and Wi-Fi weekly.\n\n" diff --git a/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py b/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py new file mode 100644 index 0000000000..42ece18eb5 --- /dev/null +++ b/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py @@ -0,0 +1,90 @@ +import time +import pyray as rl + +from openpilot.common.constants import CV +from openpilot.selfdrive.car.cruise import V_CRUISE_INITIAL, V_CRUISE_MAX +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import FontWeight, TextAlignment +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets.button import Button, ButtonStyle +from openpilot.system.ui.widgets.label import gui_label +from openpilot.system.ui.widgets.list_view import ItemAction, BUTTON_HEIGHT, BUTTON_BORDER_RADIUS + +SPEED_STEP = 5 +STEP_BUTTON_WIDTH = 160 +VALUE_FONT_SIZE = 50 +REPEAT_DELAY = 0.5 # seconds held before repeating +REPEAT_INTERVAL = 0.2 # seconds + + +def _speed_conversion() -> float: + return 1 if ui_state.is_metric else CV.KPH_TO_MPH + + +def get_speed_options() -> tuple[list[int], int]: + """Initial cruise speeds in display units, and the index of the nearest to the saved one""" + conversion = _speed_conversion() + options = list(range(round(V_CRUISE_INITIAL * conversion), round(V_CRUISE_MAX * conversion) + 1, SPEED_STEP)) + idx = round((ui_state.initial_cruise_speed * conversion - options[0]) / SPEED_STEP) + return options, max(0, min(len(options) - 1, idx)) + + +def set_initial_cruise_speed(speed: int) -> None: + """Save an initial cruise speed in display units""" + ui_state.params.put("InitialCruiseSpeedKph", round(speed / _speed_conversion()), block=True) + ui_state.update_params() + + +class RepeatButton(Button): + def __init__(self, text, callback, font_size): + super().__init__(text, callback, font_size=font_size, button_style=ButtonStyle.PILL, border_radius=BUTTON_BORDER_RADIUS, text_padding=0) + self._repeat_at: float | None = None + self._repeated = False + + def _handle_mouse_press(self, mouse_pos): + super()._handle_mouse_press(mouse_pos) + self._repeat_at = time.monotonic() + REPEAT_DELAY + self._repeated = False + + def _handle_mouse_release(self, mouse_pos): + if not self._repeated: + super()._handle_mouse_release(mouse_pos) + + def _process_mouse_events(self): + super()._process_mouse_events() + # step repeatedly while held. once it has repeated, the release doesn't step again + if not self.is_pressed or not self._touch_valid(): + self._repeat_at = None + elif self._repeat_at is not None and time.monotonic() >= self._repeat_at: + self._repeated = True + self._repeat_at = time.monotonic() + REPEAT_INTERVAL + if self._click_callback: + self._click_callback() + + +class InitialCruiseSpeedControl(ItemAction): + def __init__(self): + super().__init__(width=580) + self._options, self._selected_idx = get_speed_options() + + self._decrease = self._child(RepeatButton("-", lambda: self._adjust_speed(-1), VALUE_FONT_SIZE)) + self._increase = self._child(RepeatButton("+", lambda: self._adjust_speed(1), VALUE_FONT_SIZE)) + self._decrease.set_enabled(lambda: self.enabled and self._selected_idx > 0) + self._increase.set_enabled(lambda: self.enabled and self._selected_idx < len(self._options) - 1) + self._decrease.set_touch_valid_callback(self._touch_valid) + self._increase.set_touch_valid_callback(self._touch_valid) + + def refresh(self): + self._options, self._selected_idx = get_speed_options() + + def _adjust_speed(self, step: int): + self._selected_idx += step + set_initial_cruise_speed(self._options[self._selected_idx]) + + def _render(self, rect): + y = rect.y + (rect.height - BUTTON_HEIGHT) / 2 + self._decrease.render(rl.Rectangle(rect.x, y, STEP_BUTTON_WIDTH, BUTTON_HEIGHT)) + self._increase.render(rl.Rectangle(rect.x + rect.width - STEP_BUTTON_WIDTH, y, STEP_BUTTON_WIDTH, BUTTON_HEIGHT)) + text = f"{self._options[self._selected_idx]} " + (tr("km/h") if ui_state.is_metric else tr("mph")) + gui_label(rl.Rectangle(rect.x + STEP_BUTTON_WIDTH, y, rect.width - 2 * STEP_BUTTON_WIDTH, BUTTON_HEIGHT), text, VALUE_FONT_SIZE, + color=rl.WHITE if self.enabled else rl.GRAY, font_weight=FontWeight.MEDIUM, alignment=TextAlignment.CENTER) diff --git a/openpilot/selfdrive/ui/layouts/settings/toggles.py b/openpilot/selfdrive/ui/layouts/settings/toggles.py index ee76b7e4cc..ccc496adb5 100644 --- a/openpilot/selfdrive/ui/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -1,17 +1,18 @@ from openpilot.cereal import log from openpilot.common.params import Params, UnknownKeyName from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.list_view import multiple_button_item, toggle_item +from openpilot.system.ui.widgets.list_view import ListItem, multiple_button_item, toggle_item from openpilot.system.ui.widgets.scroller_tici import Scroller -from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets import DialogResult from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.layouts.settings.initial_cruise_speed import InitialCruiseSpeedControl if gui_app.sunnypilot_ui(): from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp as toggle_item from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp as multiple_button_item + from openpilot.selfdrive.ui.sunnypilot.layouts.settings.initial_cruise_speed import initial_cruise_speed_item_sp as ListItem + from openpilot.selfdrive.ui.sunnypilot.layouts.settings.initial_cruise_speed import InitialCruiseSpeedControlSP as InitialCruiseSpeedControl PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants @@ -42,7 +43,6 @@ class TogglesLayout(Widget): def __init__(self): super().__init__() self._params = Params() - self._is_release = False # self._params.get_bool("IsReleaseBranch") # param, title, desc, icon, needs_restart self._toggle_defs = { @@ -52,12 +52,6 @@ class TogglesLayout(Widget): "chffr_wheel.png", True, ), - "ExperimentalMode": ( - lambda: tr("Experimental Mode"), - "", - "experimental_white.png", - False, - ), "DisengageOnAccelerator": ( lambda: tr("Disengage on Accelerator Pedal"), DESCRIPTIONS["DisengageOnAccelerator"], @@ -97,7 +91,7 @@ class TogglesLayout(Widget): } self._long_personality_setting = multiple_button_item( - lambda: tr("Driving Personality"), + lambda: tr("Following Distance"), lambda: tr(DESCRIPTIONS["LongitudinalPersonality"]), buttons=[lambda: tr("Aggressive"), lambda: tr("Standard"), lambda: tr("Relaxed")], button_width=300, @@ -107,6 +101,14 @@ class TogglesLayout(Widget): ) self._toggles = {} + self._initial_cruise_speed = ListItem( + title=lambda: tr("Initial Max Speed"), + description=lambda: tr("Max speed when engaging openpilot. If you are already driving faster, your current speed is used. " + + "Resume restores the previous max speed. Changes apply the next time you engage."), + action_item=InitialCruiseSpeedControl(), + icon="speed_limit.png", + ) + self._initial_cruise_speed.set_visible(lambda: ui_state.CP is not None and ui_state.has_longitudinal_control and not ui_state.CP.pcmCruise) self._locked_toggles = set() for param, (title, desc, icon, needs_restart) in self._toggle_defs.items(): toggle = toggle_item( @@ -138,8 +140,8 @@ class TogglesLayout(Widget): # insert longitudinal personality after NDOG toggle if param == "DisengageOnAccelerator": self._toggles["LongitudinalPersonality"] = self._long_personality_setting + self._toggles["InitialCruiseSpeedKph"] = self._initial_cruise_speed - self._update_experimental_mode_icon() self._scroller = Scroller(list(self._toggles.values()), line_separator=True, spacing=0) ui_state.add_engaged_transition_callback(self._update_toggles) @@ -158,46 +160,10 @@ class TogglesLayout(Widget): def _update_toggles(self): ui_state.update_params() - - e2e_description = tr( - "sunnypilot defaults to driving in chill mode. Experimental mode enables alpha-level features that aren't ready for chill mode. " + - "Experimental features are listed below:
" + - "

End-to-End Longitudinal Control


" + - "Let the driving model control the gas and brakes. sunnypilot will drive as it thinks a human would, including stopping for red lights and stop signs. " + - "Since the driving model decides the speed to drive, the set speed will only act as an upper bound. This is an alpha quality feature; " + - "mistakes should be expected.
" + - "

New Driving Visualization


" + - "The driving visualization will transition to the road-facing wide-angle camera at low speeds to better show some turns. " + - "The Experimental mode logo will also be shown in the top right corner." - ) + self._initial_cruise_speed.action_item.refresh() if ui_state.CP is not None: - if ui_state.has_longitudinal_control: - self._toggles["ExperimentalMode"].action_item.set_enabled(True) - self._toggles["ExperimentalMode"].set_description(e2e_description) - self._long_personality_setting.action_item.set_enabled(True) - else: - # no long for now - self._toggles["ExperimentalMode"].action_item.set_enabled(False) - self._toggles["ExperimentalMode"].action_item.set_state(False) - self._long_personality_setting.action_item.set_enabled(False) - self._params.remove("ExperimentalMode") - - unavailable = tr("Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control.") - - long_desc = unavailable + " " + tr("sunnypilot longitudinal control may come in a future update.") - if ui_state.CP.alphaLongitudinalAvailable: - if self._is_release: - long_desc = unavailable + " " + tr("An alpha version of sunnypilot longitudinal control can be tested, along with " + - "Experimental mode, on non-release branches.") - else: - long_desc = tr("Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode.") - - self._toggles["ExperimentalMode"].set_description("" + long_desc + "

" + e2e_description) - else: - self._toggles["ExperimentalMode"].set_description(e2e_description) - - self._update_experimental_mode_icon() + self._long_personality_setting.action_item.set_enabled(ui_state.has_longitudinal_control) # TODO: make a param control list item so we don't need to manage internal state as much here # refresh toggles from params to mirror external changes @@ -212,36 +178,11 @@ class TogglesLayout(Widget): def _render(self, rect): self._scroller.render(rect) - def _update_experimental_mode_icon(self): - icon = "experimental.png" if self._toggles["ExperimentalMode"].action_item.get_state() else "experimental_white.png" - self._toggles["ExperimentalMode"].set_icon(icon) - - def _handle_experimental_mode_toggle(self, state: bool): - confirmed = self._params.get_bool("ExperimentalModeConfirmed") - if state and not confirmed: - def confirm_callback(result: DialogResult): - if result == DialogResult.CONFIRM: - self._params.put_bool("ExperimentalMode", True, block=True) - self._params.put_bool("ExperimentalModeConfirmed", True, block=True) - else: - self._toggles["ExperimentalMode"].action_item.set_state(False) - self._update_experimental_mode_icon() - - # show confirmation dialog - content = (f"

{self._toggles['ExperimentalMode'].title}


" + - f"

{self._toggles['ExperimentalMode'].description}

") - dlg = ConfirmDialog(content, tr("Enable"), rich=True, callback=confirm_callback) - gui_app.push_widget(dlg) - else: - self._update_experimental_mode_icon() - self._params.put_bool("ExperimentalMode", state, block=True) - def _toggle_callback(self, state: bool, param: str): - if param == "ExperimentalMode": - self._handle_experimental_mode_toggle(state) - return - self._params.put_bool(param, state, block=True) + if param == "IsMetric": + self._update_toggles() + if self._toggle_defs[param][3]: self._params.put_bool("OnroadCycleRequested", True, block=True) diff --git a/openpilot/selfdrive/ui/lib/prime_state.py b/openpilot/selfdrive/ui/lib/prime_state.py index b57380ac57..88de7c21ff 100644 --- a/openpilot/selfdrive/ui/lib/prime_state.py +++ b/openpilot/selfdrive/ui/lib/prime_state.py @@ -10,6 +10,7 @@ from openpilot.common.realtime import drop_realtime from openpilot.common.swaglog import cloudlog from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID from openpilot.selfdrive.ui.lib.api_helpers import get_token +from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert class PrimeType(IntEnum): @@ -22,6 +23,10 @@ class PrimeType(IntEnum): MAGENTA_NEW = 4 PURPLE = 5 +class Provider(str): + GOOGLE = "google" + GITHUB = "github" + APPLE = "apple" class PrimeState: FETCH_INTERVAL = 5.0 # seconds between API calls @@ -33,6 +38,13 @@ class PrimeState: self._lock = threading.Lock() self._session = requests.Session() # reuse session to reduce SSL handshake overhead self.prime_type: PrimeType = self._load_initial_state() + self._prime_trial_available = False + pairing_provider = os.getenv("PAIRING_PROVIDER") or self._params.get("PairingProvider") + self._pairing_provider: Provider | None = Provider(pairing_provider) if pairing_provider is not None else None + self._pairing_email: str | None = self._params.get("PairingEmail") + self._commacare = False + + set_offroad_alert("Offroad_Pairing", self.prime_type <= PrimeType.UNPAIRED) self._running = False self._thread = None @@ -56,18 +68,81 @@ class PrimeState: response = api_get(f"v1.1/devices/{dongle_id}", timeout=self.API_TIMEOUT, access_token=identity_token, session=self._session) if response.status_code == 200: data = response.json() + set_offroad_alert("Offroad_UploadsIgnored", bool(data.get("ignore_uploads"))) + is_paired = data.get("is_paired", False) prime_type = data.get("prime_type", 0) self.set_type(PrimeType(prime_type) if is_paired else PrimeType.UNPAIRED) + if not is_paired: + self.set_provider(None, None) + + prime_trial_available = data.get("trial_claimed") is False and data.get("eligible_features", {}).get("prime", False) + self.set_prime_trial_available(prime_trial_available) + + commacare = data.get("commacare", False) + self.set_commacare(commacare) except Exception as e: cloudlog.error(f"Failed to fetch prime status: {e}") + def _fetch_pairing_provider(self) -> None: + dongle_id = self._params.get("DongleId") + if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID: + return + + try: + identity_token = get_token(dongle_id) + response = api_get(f"v1/devices/{dongle_id}/owner", timeout=self.API_TIMEOUT, access_token=identity_token, session=self._session) + if response.status_code == 200: + data = response.json() + user_id = data.get("user_id", "") + provider = Provider(user_id.partition("_")[0]) + email = data.get("email") + self.set_provider(provider, email) + except Exception as e: + cloudlog.error(f"Failed to fetch pairing provider: {e}") + def set_type(self, prime_type: PrimeType) -> None: with self._lock: if prime_type != self.prime_type: self.prime_type = prime_type self._params.put("PrimeType", int(prime_type)) cloudlog.info(f"Prime type updated to {prime_type}") + set_offroad_alert("Offroad_Pairing", self.prime_type <= PrimeType.UNPAIRED) + + def set_provider(self, provider: Provider | None, email: str | None): + with self._lock: + if self.prime_type <= PrimeType.UNPAIRED: + provider = None + email = None + email = email or None # if data.get(email) returns "" instead of None + + if self._pairing_provider != provider: + self._pairing_provider = provider + if provider is None: + self._params.remove("PairingProvider") + else: + self._params.put("PairingProvider", str(provider)) + + if self._pairing_email != email: + self._pairing_email = email + if email is None: + self._params.remove("PairingEmail") + else: + self._params.put("PairingEmail", email) + + def set_commacare(self, has_commacare: bool): + with self._lock: + if self.prime_type <= PrimeType.UNPAIRED: + self._commacare = False + else: + self._commacare = has_commacare + + def set_prime_trial_available(self, prime_trail_available: bool): + with self._lock: + if self.prime_type <= PrimeType.UNPAIRED: + self._prime_trial_available = False + else: + self._prime_trial_available = prime_trail_available def _worker_thread(self) -> None: drop_realtime() @@ -75,6 +150,7 @@ class PrimeState: while self._running: if not ui_state.started and device._awake: self._fetch_prime_status() + self._fetch_pairing_provider() for _ in range(int(self.FETCH_INTERVAL / self.SLEEP_INTERVAL)): if not self._running: @@ -101,9 +177,33 @@ class PrimeState: with self._lock: return bool(self.prime_type > PrimeType.NONE) + def is_full_prime(self) -> bool: + with self._lock: + return self.prime_type > PrimeType.NONE and self.prime_type != PrimeType.LITE + def is_paired(self) -> bool: with self._lock: return self.prime_type > PrimeType.UNPAIRED + def can_claim_prime_trial(self) -> bool: + with self._lock: + return self._prime_trial_available + + def has_commacare(self) -> bool: + with self._lock: + return self._commacare + + def get_pairing_provider(self) -> str | None: + with self._lock: + return self._pairing_provider + + def get_pairing_account(self) -> str: + with self._lock: + if not self._pairing_provider: + return "unknown" + elif self._pairing_provider == Provider.GITHUB or not self._pairing_email: + return f"{self._pairing_provider} account" + return self._pairing_email + def __del__(self): self.stop() diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 847cd42ed7..3e90a7d1a5 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -1,6 +1,7 @@ +from __future__ import annotations + import datetime import math -import time from openpilot.cereal import log import pyray as rl @@ -39,16 +40,13 @@ class AlertsPill(Widget): self.set_rect(rl.Rectangle(0, 0, 104, 52)) self._pill_bg_txt = gui_app.texture("icons_mici/alerts_pill.png", 104, 52) - self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", 36, 36) - self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", 36, 36) - self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 36, 36) self._alert_count_callback: Callable[[], int] | None = None - self._max_severity_callback: Callable[[], int | None] | None = None + self._alert_icon_callback: Callable[[], rl.Texture | None] | None = None def set_alert_count_callback(self, callback: Callable[[], int] | None, - severity_callback: Callable[[], int | None] | None = None): + icon_callback: Callable[[], rl.Texture | None] | None = None): self._alert_count_callback = callback - self._max_severity_callback = severity_callback + self._alert_icon_callback = icon_callback def _render(self, _): alert_count = self._alert_count_callback() if self._alert_count_callback else 0 @@ -56,17 +54,12 @@ class AlertsPill(Widget): pill_w, pill_h = self._pill_bg_txt.width, self._pill_bg_txt.height rl.draw_texture_ex(self._pill_bg_txt, rl.Vector2(self.rect.x, self.rect.y), 0.0, 1.0, rl.WHITE) - severity = self._max_severity_callback() if self._max_severity_callback else None - if severity == -1: - warning_txt = self._icon_green - elif severity is not None and severity > 0: - warning_txt = self._icon_red - else: - warning_txt = self._icon_orange - - warn_x = self.rect.x + self.ICON_OFFSET - warn_y = self.rect.y + (pill_h - warning_txt.height) / 2 - rl.draw_texture_ex(warning_txt, rl.Vector2(warn_x, warn_y), 0.0, 1.0, rl.WHITE) + warning_txt = self._alert_icon_callback() if self._alert_icon_callback else None + if warning_txt is not None: + scale = 36 / max(warning_txt.width, warning_txt.height) + warn_x = self.rect.x + self.ICON_OFFSET + warn_y = self.rect.y + (pill_h - warning_txt.height * scale) / 2 + rl.draw_texture_ex(warning_txt, rl.Vector2(warn_x, warn_y), 0.0, scale, rl.WHITE) count_rect = rl.Rectangle(self.rect.x + self.COUNT_OFFSET, self.rect.y, pill_w - self.COUNT_OFFSET, pill_h) gui_label(count_rect, str(alert_count), font_size=36, @@ -77,21 +70,21 @@ class AlertsPill(Widget): class NetworkIcon(Widget): def __init__(self): super().__init__() - self.set_rect(rl.Rectangle(0, 0, 54, 44)) # max size of all icons + self.set_rect(rl.Rectangle(0, 0, 60, 47)) # max size of all icons self._net_type = NetworkType.none self._net_strength = 0 - self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 50, 44) - self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 50, 37) - self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 50, 37) - self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 50, 37) - self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 50, 37) + self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 54, 47) + self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 54, 40) + self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 54, 40) + self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 54, 40) + self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 54, 40) - self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 54, 36) - self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 54, 36) - self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 54, 36) - self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 54, 36) - self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 54, 36) + self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 60, 40) + self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 60, 40) + self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 60, 40) + self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 60, 40) + self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 60, 40) def _update_state(self): device_state = ui_state.sm['deviceState'] @@ -133,15 +126,10 @@ class MiciHomeLayout(Widget): self._on_alerts_click: Callable | None = None self._alert_count_callback: Callable[[], int] | None = None - self._mouse_down_t: None | float = None - self._did_long_press = False - self._is_pressed_prev = False - self._version_text = self._get_version_text() - self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) self._usb_icon = IconWidget("icons_mici/usb.png", (62, 40)) - self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (68, 40)) + self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (54, 40)) self._chestnut_loading_icon = IconWidget("icons_mici/chestnut.png", (68, 40)) self._chestnut_failed_icon = IconWidget("icons_mici/chestnut_orange.png", (68, 40)) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) @@ -152,7 +140,6 @@ class MiciHomeLayout(Widget): self._status_bar_layout = HBoxLayout([ IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9), NetworkIcon(), - self._experimental_icon, self._usb_icon, self._chestnut_icon, self._chestnut_loading_icon, @@ -168,41 +155,22 @@ class MiciHomeLayout(Widget): self._branch_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, scroll=True) self._version_commit_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) - def _update_state(self): - if self.is_pressed and not self._is_pressed_prev: - self._mouse_down_t = time.monotonic() - elif not self.is_pressed and self._is_pressed_prev: - self._mouse_down_t = None - self._did_long_press = False - self._is_pressed_prev = self.is_pressed - - 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 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 - self._did_long_press = True - def set_callbacks(self, on_settings: Callable | None = None, on_alerts: Callable | None = None, alert_count_callback: Callable[[], int] | None = None, - max_severity_callback: Callable[[], int | None] | None = None): + alert_icon_callback: Callable[[], rl.Texture | None] | None = None): self._on_settings_click = on_settings self._on_alerts_click = on_alerts self._alert_count_callback = alert_count_callback - self._alerts_pill.set_alert_count_callback(alert_count_callback, max_severity_callback) + self._alerts_pill.set_alert_count_callback(alert_count_callback, alert_icon_callback) def _handle_mouse_release(self, mouse_pos: MousePos): - if not self._did_long_press: - relative_x = mouse_pos.x - self.rect.x - has_alerts = self._alert_count_callback and self._alert_count_callback() > 0 - if has_alerts and relative_x > self.rect.width - ALERTS_ZONE_WIDTH: - if self._on_alerts_click: - self._on_alerts_click() - elif self._on_settings_click: - self._on_settings_click() - self._did_long_press = False + relative_x = mouse_pos.x - self.rect.x + has_alerts = self._alert_count_callback and self._alert_count_callback() > 0 + if has_alerts and relative_x > self.rect.width - ALERTS_ZONE_WIDTH: + if self._on_alerts_click: + self._on_alerts_click() + elif self._on_settings_click: + self._on_settings_click() def _get_version_text(self) -> tuple[str, str, str, str] | None: version = ui_state.params.get("Version") @@ -255,7 +223,6 @@ class MiciHomeLayout(Widget): usb_connected = ui_state.usb_connected usb_unknown = ui_state.usb_unknown chestnut_state = ui_state.chestnut_state - self._experimental_icon.set_visible(ui_state.experimental_mode) if gui_app.sunnypilot_ui(): self._set_chestnut_visibility() else: diff --git a/openpilot/selfdrive/ui/mici/layouts/main.py b/openpilot/selfdrive/ui/mici/layouts/main.py index 7b96366894..306feb28c5 100644 --- a/openpilot/selfdrive/ui/mici/layouts/main.py +++ b/openpilot/selfdrive/ui/mici/layouts/main.py @@ -73,11 +73,13 @@ class MiciMainLayout(Scroller): return self._body_onroad_layout if ui_state.is_body else self._car_onroad_layout def _setup_callbacks(self): + self._alerts_layout.set_enabled(lambda: self.enabled) + self._alerts_layout.set_pairing_callback(self._settings_layout.show_pairing) self._home_layout.set_callbacks( on_settings=lambda: gui_app.push_widget(self._settings_layout), on_alerts=lambda: self._scroll_to(self._alerts_layout), alert_count_callback=self._alerts_layout.active_alerts, - max_severity_callback=self._alerts_layout.max_severity, + alert_icon_callback=self._alerts_layout.highest_severity_icon, ) for layout in (self._car_onroad_layout, self._body_onroad_layout): layout.set_click_callback(lambda: self._scroll_to(self._home_layout)) diff --git a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py index de8286cae5..5cdb0ebd7e 100644 --- a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -1,7 +1,10 @@ +from __future__ import annotations + import pyray as rl import re import threading import time +from collections.abc import Callable from dataclasses import dataclass from enum import IntEnum from openpilot.common.params import Params @@ -29,6 +32,7 @@ class AlertData: text: str severity: int visible: bool = False + icon: str | None = None class AlertItem(Widget): @@ -56,10 +60,20 @@ class AlertItem(Widget): self._bg_big = gui_app.texture("icons_mici/offroad_alerts/big_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG) self._bg_big_pressed = gui_app.texture("icons_mici/offroad_alerts/big_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG) - # Load warning icons + # Load alert icons self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", self.ICON_SIZE, self.ICON_SIZE) self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", self.ICON_SIZE, self.ICON_SIZE) self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE) + self._custom_icon = gui_app.texture(alert_data.icon, self.ICON_SIZE, self.ICON_SIZE) if alert_data.icon else None + + if self._custom_icon is not None: + self._icon = self._custom_icon + elif alert_data.severity == -1: + self._icon = self._icon_green + elif alert_data.severity > 0: + self._icon = self._icon_red + else: + self._icon = self._icon_orange self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR, alignment=TextAlignment.LEFT, @@ -75,6 +89,10 @@ class AlertItem(Widget): self._update_content() + @property + def icon(self) -> rl.Texture: + return self._icon + def _split_text(self, text: str) -> tuple[str, str]: """Split text into title (first sentence) and body (remaining text).""" # Find the end of the first sentence (period, exclamation, or question mark followed by space or end) @@ -176,16 +194,9 @@ class AlertItem(Widget): self._body_label.render(body_rect) # Draw warning icon on the right side - # Use green icon for update alerts (severity = -1), red for high severity, orange for low severity - if self.alert_data.severity == -1: - icon_texture = self._icon_green - elif self.alert_data.severity > 0: - icon_texture = self._icon_red - else: - icon_texture = self._icon_orange icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE icon_y = self._rect.y + self.ALERT_PADDING - rl.draw_texture_ex(icon_texture, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) + rl.draw_texture_ex(self._icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) class MiciOffroadAlerts(Scroller): @@ -214,12 +225,19 @@ class MiciOffroadAlerts(Scroller): def active_alerts(self) -> int: return sum(alert.visible for alert in self.sorted_alerts) - def max_severity(self) -> int | None: - return max((alert.severity for alert in self.sorted_alerts if alert.visible), default=None) + def highest_severity_icon(self) -> rl.Texture | None: + item = max((item for item in self.alert_items if item.alert_data.visible), + key=lambda item: (item.alert_data.severity, bool(item.alert_data.icon)), default=None) + return item.icon if item is not None else None def scrolling(self): return self._scroller.scroll_panel.is_touch_valid() + def set_pairing_callback(self, callback: Callable[[], None]): + for alert_item in self.alert_items: + if alert_item.alert_data.key == "Offroad_Pairing": + alert_item.set_click_callback(callback) + def _build_alerts(self): """Build sorted list of alerts from OFFROAD_ALERTS.""" self.sorted_alerts = [] @@ -235,7 +253,7 @@ class MiciOffroadAlerts(Scroller): # Add regular alerts sorted by severity for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True): severity = config.get("severity", 0) - alert_data = AlertData(key=key, text="", severity=severity) + alert_data = AlertData(key=key, text="", severity=severity, icon=config.get("icon")) self.sorted_alerts.append(alert_data) # Create alert item widget diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py index 2d7dc0899d..3c11219538 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py @@ -23,8 +23,6 @@ class AlphaLongConfirmPage(NavScroller): GreyBigButton("", "WARNING: alpha longitudinal control may disable Automatic Emergency Braking (AEB)"), GreyBigButton("", "On this car, openpilot defaults to the stock system's built-in ACC."), GreyBigButton("", "Enabling this will switch to openpilot longitudinal control."), - GreyBigButton("", "Using Experimental mode is recommended with openpilot longitudinal control alpha."), - GreyBigButton("", "Changing this setting will restart openpilot if the car is powered on."), accept, ]) @@ -63,28 +61,33 @@ class DeveloperLayoutMici(NavScroller): txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 56, 64) github_username = ui_state.params.get("GithubUsername") or "" - self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh) + self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh, + description="Grant SSH access to all public keys in your GitHub settings. Only enter your own username.") self._ssh_keys_btn.set_click_callback(ssh_keys_callback) # adb, ssh, ssh keys, debug mode, joystick debug mode, longitudinal maneuver mode, ip address # ******** Main Scroller ******** - self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12)) - self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12)) - self._joystick_toggle = BigToggle("joystick debug mode", - initial_state=ui_state.params.get_bool("JoystickDebugMode"), - toggle_callback=self._on_joystick_debug_mode) - self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", - initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"), - toggle_callback=self._on_long_maneuver_mode) - self._lat_maneuver_toggle = BigToggle("lateral maneuver mode", - initial_state=ui_state.params.get_bool("LateralManeuverMode"), - toggle_callback=self._on_lat_maneuver_mode) - self._alpha_long_toggle = BigToggle("alpha longitudinal", - initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), - toggle_callback=self._on_alpha_long_enabled) + self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12), + description="Use Android Debug Bridge (ADB) over USB or the network.", title="enable ADB") + self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12), + description="Access the device remotely using your SSH keys.", title="enable SSH") + self._joystick_toggle = BigToggle("joystick debug\nmode", initial_state=ui_state.params.get_bool("JoystickDebugMode"), + toggle_callback=self._on_joystick_debug_mode, description="Control the car with a joystick for debugging.") + self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"), + toggle_callback=self._on_long_maneuver_mode, + description="Run longitudinal maneuvers for testing gas and brake control.") + self._lat_maneuver_toggle = BigToggle("lateral maneuver mode", initial_state=ui_state.params.get_bool("LateralManeuverMode"), + toggle_callback=self._on_lat_maneuver_mode, + description="Run lateral maneuvers for testing steering control.") + self._alpha_long_toggle = BigToggle("alpha longitudinal", initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), + toggle_callback=self._on_alpha_long_enabled, + description="Use alpha openpilot longitudinal control instead of stock ACC. This may disable Automatic Emergency " + + "Braking (AEB).") + self._navigation_toggle = BigParamControl("navigation (alpha)", "NavigationEnabled", + description="Turn-by-turn navigation with a route map. Set a destination from comma connect.") self._debug_mode_toggle = BigParamControl("ui debug mode", "ShowDebugInfo", - toggle_callback=lambda checked: (gui_app.set_show_touches(checked), - gui_app.set_show_fps(checked))) + toggle_callback=lambda checked: (gui_app.set_show_touches(checked), gui_app.set_show_fps(checked)), + description="Show touch locations and the UI frame rate.") self._scroller.add_widgets([ self._adb_toggle, @@ -95,6 +98,7 @@ class DeveloperLayoutMici(NavScroller): self._lat_maneuver_toggle, self._alpha_long_toggle, self._debug_mode_toggle, + self._navigation_toggle, ]) # Toggle lists @@ -106,9 +110,10 @@ class DeveloperLayoutMici(NavScroller): ("LateralManeuverMode", self._lat_maneuver_toggle), ("AlphaLongitudinalEnabled", self._alpha_long_toggle), ("ShowDebugInfo", self._debug_mode_toggle), + ("NavigationEnabled", self._navigation_toggle), ) onroad_blocked_toggles = (self._adb_toggle, self._joystick_toggle) - release_blocked_toggles = (self._joystick_toggle, self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle) + release_blocked_toggles = (self._joystick_toggle, self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle, self._navigation_toggle) engaged_blocked_toggles = (self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle) # Hide non-release toggles on release builds @@ -179,7 +184,6 @@ class DeveloperLayoutMici(NavScroller): def _on_lat_maneuver_mode(self, state: bool): ui_state.params.put_bool("LateralManeuverMode", state, block=True) - ui_state.params.put_bool("ExperimentalMode", False, block=True) ui_state.params.put_bool("JoystickDebugMode", False, block=True) self._joystick_toggle.set_checked(False) ui_state.params.put_bool("LongitudinalManeuverMode", False, block=True) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/device/__init__.py similarity index 100% rename from openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/__init__.py rename to openpilot/selfdrive/ui/mici/layouts/settings/device/__init__.py diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py similarity index 64% rename from openpilot/selfdrive/ui/mici/layouts/settings/device.py rename to openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py index 6038ff7d35..20b0523444 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py @@ -1,21 +1,21 @@ import os import pyray as rl from collections.abc import Callable +from typing import Union from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.time_helpers import system_time_valid +from openpilot.selfdrive.ui.mici.layouts.settings.device.prime import PrimeScroller from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller +from openpilot.selfdrive.ui.mici.widgets.info import InfoLayoutMici 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.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.application import gui_app, 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 HtmlRenderer from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID @@ -77,82 +77,69 @@ def _engaged_confirmation_click(callback: Callable, action_text: str, icon: rl.T class EngagedConfirmationCircleButton(BigCircleButton): def __init__(self, title: str, icon: rl.Texture, callback: Callable[[], None], exit_on_confirm: bool = True, - red: bool = False, icon_offset: tuple[int, int] = (0, 0)): - super().__init__(icon, red, icon_offset) + red: bool = False, icon_offset: tuple[int, int] = (0, 0), *, description: str = ""): + super().__init__(icon, red, icon_offset, description=description, title=title) self.set_click_callback(lambda: _engaged_confirmation_click(callback, title, icon, exit_on_confirm=exit_on_confirm, red=red)) class EngagedConfirmationButton(BigButton): def __init__(self, text: str, action_text: str, icon: rl.Texture, callback: Callable[[], None], - exit_on_confirm: bool = True, red: bool = False): - super().__init__(text, "", icon) + exit_on_confirm: bool = True, red: bool = False, *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(text, "", icon, description=description, description_icon=description_icon) self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red)) -class DeviceInfoLayoutMici(Widget): +class DeviceInfoLayoutMici(InfoLayoutMici): def __init__(self): - super().__init__() - - self.set_rect(rl.Rectangle(0, 0, 360, 180)) - params = Params() - subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) - max_width = int(self._rect.width - 20) - self._dongle_id_label = UnifiedLabel("device ID", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self._dongle_id_text_label = UnifiedLabel(params.get("DongleId") or 'N/A', 32, max_width=max_width, text_color=subheader_color, - font_weight=FontWeight.ROMAN, wrap_text=False) - - self._serial_number_label = UnifiedLabel("serial", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self._serial_number_text_label = UnifiedLabel(params.get("HardwareSerial") or 'N/A', 32, max_width=max_width, text_color=subheader_color, - font_weight=FontWeight.ROMAN, wrap_text=False) - - def _render(self, _): - self._dongle_id_label.set_position(self._rect.x + 20, self._rect.y - 10) - self._dongle_id_label.render() - - self._dongle_id_text_label.set_position(self._rect.x + 20, self._rect.y + 68 - 25) - self._dongle_id_text_label.render() - - self._serial_number_label.set_position(self._rect.x + 20, self._rect.y + 114 - 30) - self._serial_number_label.render() - - self._serial_number_text_label.set_position(self._rect.x + 20, self._rect.y + 161 - 25) - self._serial_number_text_label.render() + super().__init__("device ID", params.get("DongleId") or 'N/A', "serial", params.get("HardwareSerial") or 'N/A', width=380) class PairBigButton(BigButton): def __init__(self): - super().__init__("pair", "connect.comma.ai", gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60)) - - def _get_label_font_size(self): - return 64 + self._commacare_badge = gui_app.texture("icons_mici/settings/device/commacare.png", 27, 32) + self._show_commacare = False + self._comma_icon = gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60) + self._provider_icons = {provider: gui_app.texture(f"icons_mici/settings/device/paired_{provider}.png", 64, 64) + for provider in ("github", "google", "apple")} + super().__init__("pair to connect", "connect.comma.ai", self._comma_icon) def _update_state(self): super()._update_state() + self._show_commacare = (ui_state.prime_state.is_paired() and ui_state.prime_state.is_prime() + and ui_state.prime_state.has_commacare()) if ui_state.prime_state.is_paired(): + self.set_icon(self._provider_icons.get(ui_state.prime_state.get_pairing_provider(), self._comma_icon)) self.set_text("paired") if ui_state.prime_state.is_prime(): - self.set_value("subscribed") + self.set_value("prime" if ui_state.prime_state.is_full_prime() else "prime lite") else: - self.set_value("upgrade to prime") + self.set_value("claim prime trial" if ui_state.prime_state.can_claim_prime_trial() else "upgrade to prime") else: - self.set_text("pair") + self.set_icon(self._comma_icon) + self.set_text("pair to connect") self.set_value("connect.comma.ai") + def _draw_subtitle(self, rect: rl.Rectangle): + if self._show_commacare: + y = rect.y + rect.height - self._commacare_badge.height + rl.draw_texture_v(self._commacare_badge, rl.Vector2(rect.x, y), rl.WHITE) + offset = self._commacare_badge.width + 14 + rect = rl.Rectangle(rect.x + offset, rect.y, rect.width - offset, rect.height) + super()._draw_subtitle(rect) + def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) - # TODO: show ad dialog when clicked if not prime - if ui_state.prime_state.is_paired(): - return - dlg: BigDialog | PairingDialog + dlg: BigDialog | PrimeScroller if not system_time_valid(): dlg = BigDialog("", tr("Please connect to Wi-Fi to complete initial pairing.")) elif UNREGISTERED_DONGLE_ID == (ui_state.params.get("DongleId") or UNREGISTERED_DONGLE_ID): dlg = BigDialog("", tr("Device must be registered with the comma.ai backend to pair.")) else: - dlg = PairingDialog() + dlg = PrimeScroller() gui_app.push_widget(dlg) @@ -161,6 +148,8 @@ class DeviceLayoutMici(NavScroller): super().__init__() self._fcc_dialog: MiciFccModal | None = None + self._pairing_button = PairBigButton() + self._pending_pairing_grow_animation = False def power_off_callback(): ui_state.params.put_bool("DoShutdown", True, block=True) @@ -177,7 +166,9 @@ class DeviceLayoutMici(NavScroller): params.put_bool("OnroadCycleRequested", True, block=True) reset_calibration_btn = EngagedConfirmationButton("reset calibration", "reset", gui_app.texture("icons_mici/settings/device/lkas.png", 122, 64), - reset_calibration_callback) + reset_calibration_callback, + description="Mount the device within 4° left or right and 5° up or 9° down. openpilot calibrates " + + "continuously; resetting is rarely needed. Resetting clears learned calibration.") reboot_btn = EngagedConfirmationCircleButton("reboot", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70), reboot_callback, exit_on_confirm=False) @@ -189,7 +180,8 @@ 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) - cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64)) + cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64), + description="Preview the cabin camera to check driver monitoring visibility. The vehicle must be off.") cabin_cam_btn.set_click_callback(lambda: gui_app.push_widget(CabinCameraDialog())) cabin_cam_btn.set_enabled(lambda: ui_state.is_offroad()) @@ -202,7 +194,7 @@ class DeviceLayoutMici(NavScroller): self._scroller.add_widgets([ DeviceInfoLayoutMici(), - PairBigButton(), + self._pairing_button, review_training_guide_btn, cabin_cam_btn, terms_btn, @@ -212,6 +204,31 @@ class DeviceLayoutMici(NavScroller): self._power_off_btn, ]) + def _update_state(self): + super()._update_state() + if self._pending_pairing_grow_animation: + btn_right = self._pairing_button.rect.x + self._pairing_button.rect.width + visible_right = self._rect.x + self._rect.width + if btn_right < visible_right + 50: + self._pending_pairing_grow_animation = False + self._pairing_button.trigger_grow_animation() + + def show_event(self): + super().show_event() + if not ui_state.prime_state.is_paired(): + self.set_shown_callback(self._pairing_button.trigger_grow_animation) + + def hide_event(self): + super().hide_event() + self.set_shown_callback(None) + self._pending_pairing_grow_animation = False + + def scroll_to_pairing(self): + self._scroller._layout() + offset = (self._pairing_button.rect.x + self._pairing_button.rect.width / 2) - (self._rect.x + self._rect.width / 2) + self._scroller.scroll_to(offset, smooth=True, block_interrupt=True, block_widget_interaction=True) + self._pending_pairing_grow_animation = True + def _on_regulatory(self): if not self._fcc_dialog: self._fcc_dialog = MiciFccModal(os.path.join(BASEDIR, "openpilot/selfdrive/assets/offroad/mici_fcc.html")) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py b/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py new file mode 100644 index 0000000000..9a914dff4b --- /dev/null +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py @@ -0,0 +1,171 @@ +import pyray as rl +import time + +from openpilot.common.api import Api +from openpilot.common.swaglog import cloudlog +from openpilot.common.params import Params +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, GreyBigButton +from openpilot.selfdrive.ui.mici.widgets.info import InfoLayoutMici +from openpilot.selfdrive.ui.mici.widgets.qr import QR +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets.scroller import NavScroller + + +class PairingInfoLayout(InfoLayoutMici): + def __init__(self): + super().__init__("account", "", "status", "") + self._commacare_badge = gui_app.texture("icons_mici/settings/device/commacare.png", 27, 32) + self._provider_icons = {provider: gui_app.texture(f"icons_mici/settings/device/{provider}.png", 32, 32) + for provider in ("github", "google", "apple")} + + def _update_state(self): + super()._update_state() + self.subtext1.set_text(ui_state.prime_state.get_pairing_account()) + self.subtext2.set_text(self._get_prime_status()) + self._provider_icon = self._provider_icons.get(ui_state.prime_state.get_pairing_provider()) + self._show_commacare = ui_state.prime_state.has_commacare() + + @staticmethod + def _get_prime_status() -> str: + if ui_state.prime_state.is_prime(): + return "prime" if ui_state.prime_state.is_full_prime() else "prime lite" + return "not subscribed" + + def _layout(self): + super()._layout() + provider_offset = self._provider_icon.width + 14 if self._provider_icon else 0 + commacare_offset = self._commacare_badge.width + 14 if self._show_commacare else 0 + self.subtext1.set_position(self.subtext1.rect.x + provider_offset, self.subtext1.rect.y) + self.subtext1.set_max_width(int(self._rect.width - provider_offset - (40 if self._provider_icon else 20))) + self.subtext2.set_position(self.subtext2.rect.x + commacare_offset, self.subtext2.rect.y) + self.subtext2.set_max_width(int(self._rect.width - commacare_offset - (40 if self._show_commacare else 20))) + + def _render(self, rect: rl.Rectangle): + super()._render(rect) + + if self._provider_icon: + icon_pos = rl.Vector2(self._rect.x + 20, self.subtext1.rect.y + (self.subtext1.rect.height - self._provider_icon.height) / 2) + rl.draw_texture_v(self._provider_icon, icon_pos, self._subheader_color) + if self._show_commacare: + icon_pos = rl.Vector2(self._rect.x + 20, self.subtext2.rect.y + (self.subtext2.rect.height - self._commacare_badge.height) / 2 + 4) + rl.draw_texture_v(self._commacare_badge, icon_pos, rl.WHITE) + + +class PrimeManagementScroller(NavScroller): + """Prime subscription management and upgrade information.""" + + def __init__(self): + super().__init__() + self._params = Params() + + can_claim_trial = ui_state.prime_state.can_claim_prime_trial() + + self._prime_icon = gui_app.texture("icons_mici/settings/device/green_cell.png", 64, 64) + self._phone_icon = gui_app.texture("icons_mici/settings/device/phone.png", 85, 64) + self._prime_adverts = [ + QR(self._get_prime_url()), + GreyBigButton( + "try prime for\n30 days" if can_claim_trial else "upgrade to prime", + "scan to claim trial" if can_claim_trial else "scan to manage\nprime status", + self._prime_icon + ), + GreyBigButton("", "prime adds 24/7 LTE to device and 1 year of cloud storage in connect.",), + GreyBigButton("", "prime lets you view live video and GPS location remotely in connect.",), + GreyBigButton("", "prime also includes commacare extended device warranty.",), + ] + self._prime_management = [ + QR(self._get_prime_url()), + GreyBigButton( + "manage prime", + "scan to open\ndevice prime settings", + self._phone_icon + ), + ] + self._scroller.add_widgets(self._prime_management if ui_state.prime_state.is_prime() else self._prime_adverts) + + def _get_prime_url(self) -> str: + if dongle_id := self._params.get("DongleId"): + return f"https://connect.comma.ai/{dongle_id}/prime" + return "https://connect.comma.ai" + + def _update_state(self): + super()._update_state() + if not ui_state.prime_state.is_paired(): + self.dismiss() + + +class PrimeScroller(NavScroller): + """Pairing flow or paired account information with access to prime management.""" + + QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds + + def __init__(self): + super().__init__() + self._params = Params() + self.initial_is_paired = ui_state.prime_state.is_paired() + self._last_pairing_qr_generation = float("-inf") + + self._manage_icon = gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60) + if not ui_state.prime_state.is_prime(): + title = "claim\nprime trial" if ui_state.prime_state.can_claim_prime_trial() else "upgrade\nto prime" + self._manage_prime = BigButton(title, icon=self._manage_icon) + else: + self._manage_prime = BigButton("manage prime", icon=self._manage_icon) + self._manage_prime.set_click_callback(lambda: gui_app.push_widget(PrimeManagementScroller())) + + if self.initial_is_paired: + self._scroller.add_widgets([ + PairingInfoLayout(), + self._manage_prime, + ]) + else: + self._qr = QR(self._get_pairing_url()) + self._scroller.scroll_indicator_start_after = self._qr + self._scroller.add_widgets([ + self._qr, + GreyBigButton("finish setup", "scan QR code or visit connect.comma.ai", + gui_app.texture("icons_mici/settings/device/green_settings.png", 64, 64)), + GreyBigButton("", "connect lets you review recent driving footage and bookmark events."), + ]) + + def _get_pairing_url(self) -> str: + try: + dongle_id = self._params.get("DongleId") or "" + token = Api(dongle_id).get_token({'pair': True}) + except Exception as e: + cloudlog.warning(f"Failed to get pairing token: {e}") + token = "" + return f"https://connect.comma.ai/?pair={token}" + + def _update_layout_rects(self): + super()._update_layout_rects() + if not self.initial_is_paired: + self._qr.set_rect(rl.Rectangle(self._qr.rect.x, self._qr.rect.y, self._rect.height, self._rect.height)) + + def _render(self, rect: rl.Rectangle): + if not self.initial_is_paired: + current_time = time.monotonic() + if current_time - self._last_pairing_qr_generation >= self.QR_REFRESH_INTERVAL: + self._qr._url = self._get_pairing_url() + if self._qr._texture and self._qr._texture.id != 0: + rl.unload_texture(self._qr._texture) + self._qr._texture = self._qr._generate_qr_code() + self._last_pairing_qr_generation = current_time + super()._render(rect) + + def _update_state(self): + super()._update_state() + if self.initial_is_paired != ui_state.prime_state.is_paired() and gui_app.get_active_widget() is self: + self.dismiss() + + +if __name__ == "__main__": + gui_app.init_window("pairing device") + pairing = PrimeScroller() + gui_app.push_widget(pairing) + try: + for _ in gui_app.render(): + pass + finally: + del pairing diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/firehose.py b/openpilot/selfdrive/ui/mici/layouts/settings/firehose.py index 5e3b6da2cc..ab8ce3b514 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/firehose.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/firehose.py @@ -21,7 +21,7 @@ TITLE = tr_noop("Firehose Mode") DESCRIPTION = tr_noop( "sunnypilot learns to drive by watching humans, like you, drive.\n\n" + "Firehose Mode allows you to maximize your training data uploads to improve " - + "openpilot's driving models. More data means bigger models, which means better Experimental Mode." + + "openpilot's driving models. More data means bigger models, which means better driving." ) INSTRUCTIONS_INTRO = tr_noop( "For maximum effectiveness, bring your device inside and connect to a good USB-C adapter and Wi-Fi weekly.\n\n" diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py b/openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py new file mode 100644 index 0000000000..e366738893 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py @@ -0,0 +1,160 @@ +import math +import pyray as rl +from collections.abc import Callable + +from openpilot.selfdrive.ui.layouts.settings.initial_cruise_speed import get_speed_options, set_initial_cruise_speed +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialogBase +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, FONT_SCALE +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.scroll_panel2 import AUTO_SCROLL_TC, GuiScrollPanel2, ScrollState +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel + +ITEM_DRAG_DISTANCE = 120 # px of drag per value +ITEM_GAP = 20 # px between adjacent values +VALUE_CENTER_Y = 102 +VISIBLE_VALUES = 3 # on each side of center value + +# value styles +CENTER_FONT_SIZE = 120 +CENTER_LETTER_SPACING = -0.03 +SIDE_FONT_SIZE = 96 +SIDE_LETTER_SPACING = -0.01 + + +def lerp(start: float, end: float, t: float) -> float: + return start + (end - start) * t + + +class SpeedScroller(Widget): + def __init__(self, change_callback: Callable[[], None]): + super().__init__() + self._change_callback = change_callback + + self._center_font = gui_app.font(FontWeight.BOLD) + self._side_font = gui_app.font(FontWeight.ROMAN) + + self._scroll_panel = GuiScrollPanel2(horizontal=True) + self._scroll_panel.set_enabled(lambda: self.enabled) + self.set_touch_valid_callback(self._scroll_panel.is_touch_valid) + self.set_click_callback(self._on_tap) + + self._options: list[int] = [] + self._center_widths: list[float] = [] + self._side_widths: list[float] = [] + self._locked_idx: int | None = None # value the scroller is easing onto, None while the user drags + + def show_event(self): + super().show_event() + self._options, self._locked_idx = get_speed_options() + self._center_widths = [measure_text_cached(self._center_font, str(option), CENTER_FONT_SIZE, + CENTER_LETTER_SPACING * CENTER_FONT_SIZE * FONT_SCALE).x for option in self._options] + self._side_widths = [measure_text_cached(self._side_font, str(option), SIDE_FONT_SIZE, + SIDE_LETTER_SPACING * SIDE_FONT_SIZE * FONT_SCALE).x for option in self._options] + + self._scroll_panel.set_offset(-self._locked_idx * ITEM_DRAG_DISTANCE) + + def _clamp_index(self, idx: int) -> int: + return max(0, min(len(self._options) - 1, idx)) + + def _lock(self, idx: int): + self._locked_idx = self._clamp_index(idx) + set_initial_cruise_speed(self._options[self._locked_idx]) + self._change_callback() + + def _update_state(self): + super()._update_state() + + # when the finger releases, lock the value where the scroll will stop + if self._scroll_panel.state == ScrollState.MANUAL_SCROLL: + self._locked_idx = None + elif self._locked_idx is None and self._scroll_panel.state != ScrollState.PRESSED: + projected_offset = self._scroll_panel.get_offset() + self._scroll_panel.velocity * AUTO_SCROLL_TC + self._lock(round(-projected_offset / ITEM_DRAG_DISTANCE)) + + snap_target = -self._locked_idx * ITEM_DRAG_DISTANCE if self._locked_idx is not None else None + content_size = (len(self._options) - 1) * ITEM_DRAG_DISTANCE + self._rect.width + self._scroll_panel.update(self._rect, content_size, snap_target=snap_target) + + def _on_tap(self): + if self._locked_idx is None: + return + + # a tap on the left or the right moves one value + tap_x = gui_app.last_mouse_event.pos.x + center_x = self._rect.x + self._rect.width / 2 + tap_zone = self._center_widths[self._locked_idx] / 2 + ITEM_GAP / 2 + if tap_x > center_x + tap_zone: + self._lock(self._locked_idx + 1) + elif tap_x < center_x - tap_zone: + self._lock(self._locked_idx - 1) + + def _render(self, _): + center_x = self._rect.x + self._rect.width / 2 + current = -self._scroll_panel.get_offset() / ITEM_DRAG_DISTANCE + + # the style of each value changes from the center style to the side style over a distance of one value + blends = [min(abs(idx - current), 1.0) for idx in range(len(self._options))] + widths = [lerp(center, side, t) for center, side, t in zip(self._center_widths, self._side_widths, blends, strict=True)] + + # lay values out with a constant gap, then shift so the scroll position sits at the center + centers = [0.0] + for idx in range(1, len(widths)): + centers.append(centers[-1] + widths[idx - 1] / 2 + ITEM_GAP + widths[idx] / 2) + left_idx = max(0, min(len(widths) - 2, math.floor(current))) + shift = center_x - lerp(centers[left_idx], centers[left_idx + 1], current - left_idx) + + # only the values near the center can be on the screen. the nearest value uses the bold font + closest_idx = self._clamp_index(round(current)) + for idx in range(max(0, closest_idx - VISIBLE_VALUES), min(len(self._options), closest_idx + VISIBLE_VALUES + 1)): + t = blends[idx] + font_size = lerp(CENTER_FONT_SIZE, SIDE_FONT_SIZE, t) + spacing = lerp(CENTER_LETTER_SPACING, SIDE_LETTER_SPACING, t) * font_size * FONT_SCALE + color = rl.Color(255, 255, 255, int(255 * lerp(0.9, 0.45, t))) + # use the width of the font that draws the value. this keeps the value at its center when the font changes + if idx == closest_idx: + font, width = self._center_font, self._center_widths[idx] * font_size / CENTER_FONT_SIZE + else: + font, width = self._side_font, self._side_widths[idx] * font_size / SIDE_FONT_SIZE + position = rl.Vector2(centers[idx] + shift - width / 2, self._rect.y + VALUE_CENTER_Y - font_size * FONT_SCALE / 2) + rl.draw_text_ex(font, str(self._options[idx]), position, font_size, spacing, color) + + # gradient on left and right + rl.draw_rectangle_gradient_h(int(self._rect.x), int(self._rect.y), 100, int(self._rect.height), rl.BLACK, rl.BLANK) + rl.draw_rectangle_gradient_h(int(self._rect.x + self._rect.width - 100), int(self._rect.y), 100, int(self._rect.height), rl.BLANK, rl.BLACK) + + +class InitialCruiseSpeedPicker(BigDialogBase): + def __init__(self, change_callback: Callable[[], None]): + super().__init__() + self._unit_label = self._child(UnifiedLabel("", 36, FontWeight.BOLD, LABEL_COLOR, alignment=TextAlignment.CENTER, letter_spacing=-0.03)) + self._speed_scroller = self._child(SpeedScroller(change_callback)) + self._speed_scroller.set_enabled(lambda: self.enabled and not self.is_dismissing) + + def show_event(self): + super().show_event() + self._unit_label.set_text(tr("KPH") if ui_state.is_metric else tr("MPH")) + + def _render(self, _): + self._speed_scroller.render(self._rect) + self._unit_label.render(rl.Rectangle(self._rect.x, self._rect.y + 166, self._rect.width, self._rect.height - 166)) + + +class InitialCruiseSpeedButton(BigButton): + def __init__(self): + super().__init__("initial max speed", " ", description="Max speed when engaging openpilot.\n" + + "If you are already driving faster, your current speed is used.\n" + + "Resume restores the previous max speed.") + self._picker = InitialCruiseSpeedPicker(self.refresh) + self.set_click_callback(lambda: gui_app.push_widget(self._picker)) + self.refresh() + + def _get_label_font_size(self): + return 42 + + def refresh(self): + options, idx = get_speed_options() + self.set_value(f"{options[idx]} " + (tr("kph") if ui_state.is_metric else tr("mph"))) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py index ddbab4b478..bc297ed0e0 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -1,13 +1,60 @@ import pyray as rl +from openpilot.cereal import log from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon from openpilot.selfdrive.ui.mici.widgets.button import BigButton +from openpilot.common.hardware import HARDWARE from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid +NetworkStrength = log.DeviceState.NetworkStrength +NetworkType = log.DeviceState.NetworkType + + +class EsimNetworkButton(BigButton): + def __init__(self, cellular_manager: CellularManager, *, description: str = ""): + self._cellular_manager = cellular_manager + self._cell_icons = { + NetworkStrength.unknown: gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 64, 47), + NetworkStrength.poor: gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 64, 47), + NetworkStrength.moderate: gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 64, 47), + NetworkStrength.good: gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 64, 47), + NetworkStrength.great: gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 64, 47), + } + super().__init__("esim", "loading...", self._cell_icons[NetworkStrength.unknown], scroll=True, description=description) + + def _update_state(self): + super()._update_state() + self.set_enabled(self._cellular_manager.is_euicc is not False) + text, value, icon = self._compute_state() + self.set_text(text) + self.set_value(value) + self.set_icon(icon) + + def _compute_state(self): + cm = self._cellular_manager + none_icon = self._cell_icons[NetworkStrength.unknown] + ip = cm.modem_state.get("ip_address") or "connecting..." + if cm.is_euicc is False: + iccid = cm.modem_state.get("iccid") or "" + if not iccid: + return "sim", "no sim", none_icon + return f"sim (...{iccid[-4:]})", ip, self._cell_icon() + + active = cm.active_profile + if active is None: + return "esim", "loading...", none_icon + return active.display_name, ip, self._cell_icon() + + def _cell_icon(self): + # read directly from HARDWARE so it reflects modem state even when wifi is the active connection + strength = HARDWARE.get_network_strength(NetworkType.cell4G) + return self._cell_icons.get(strength, self._cell_icons[NetworkStrength.unknown]) + class WifiNetworkButton(BigButton): - def __init__(self, wifi_manager: WifiManager): + def __init__(self, wifi_manager: WifiManager, *, description: str = ""): self._wifi_manager = wifi_manager self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 28, 36) self._draw_lock = False @@ -17,7 +64,7 @@ class WifiNetworkButton(BigButton): self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 64, 47) self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 64, 47) - super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True) + super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True, description=description) def _update_state(self): super()._update_state() diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py new file mode 100644 index 0000000000..b54edec427 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -0,0 +1,412 @@ +import threading + +import numpy as np +import pyray as rl +from collections.abc import Callable +from openpilot.cereal import log +from openpilot.cereal.visionipc import VisionStreamType + +from openpilot.common import qrcode +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraView +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets.nav_widget import NavWidget + +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigConfirmationDialog +from openpilot.common.esim.base import Profile +from openpilot.common.esim.lpa import parse_lpa_activation_code +from openpilot.system.ui.lib.application import DEFAULT_TEXT_COLOR, FontWeight, MousePos, TextAlignment, gui_app +from openpilot.system.ui.lib.cellular_manager import CellularManager +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label +from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller + + +class ProfileActionButton(Widget): + SIZE = 68 + MARGIN = 10 + HORIZONTAL_MARGIN = 4 + + def __init__(self, callback: Callable, delete: bool = False): + super().__init__() + self.set_click_callback(callback) + self._delete = delete + self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 25, 30) if delete else None + + self._bg_txt = gui_app.texture("icons_mici/buttons/button_circle.png", self.SIZE, self.SIZE) + self._bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_pressed.png", self.SIZE, self.SIZE) + self.set_rect(rl.Rectangle(0, 0, self.SIZE + self.HORIZONTAL_MARGIN * 2, self.SIZE + self.MARGIN * 2)) + + def _render(self, _): + bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt + rl.draw_texture_ex(bg_txt, (self._rect.x + (self._rect.width - self._bg_txt.width) / 2, + self._rect.y + (self._rect.height - self._bg_txt.height) / 2), 0, 1.0, rl.WHITE) + color = rl.Color(255, 105, 115, 255) if self._delete else DEFAULT_TEXT_COLOR + if not self.enabled: + color = rl.Color(color.r, color.g, color.b, 90) + if self._trash_txt: + rl.draw_texture_ex(self._trash_txt, (self._rect.x + (self._rect.width - self._trash_txt.width) / 2, + self._rect.y + (self._rect.height - self._trash_txt.height) / 2), 0, 1.0, color) + else: + gui_label(self._rect, "Aa", 30, color=color, alignment=TextAlignment.CENTER) + + +class QRScannerDialog(NavWidget): + SCAN_INTERVAL_S = 0.25 + INVALID_CODE_DURATION_S = 1.0 + + def __init__(self, on_qr_detected: Callable[[str], None]): + super().__init__() + self._on_qr_detected = on_qr_detected + self._camera_view = CabinCameraView("camerad", VisionStreamType.VISION_STREAM_CABIN) + self._detected = False + self._last_scan_time = 0.0 + self._invalid_code_until = 0.0 + self._scan_thread: threading.Thread | None = None + self._scan_result: str | None = None + self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + + def show_event(self): + super().show_event() + ui_state.params.put_bool("DisableDriverCameraIR", True) + ui_state.params.put_bool("IsDriverViewEnabled", True) + + def hide_event(self): + super().hide_event() + ui_state.params.put_bool("IsDriverViewEnabled", False) + ui_state.params.put_bool("DisableDriverCameraIR", False) + + def __del__(self): + self._camera_view.close() + + def _update_state(self): + super()._update_state() + + now = rl.get_time() + if self._detected or not self._camera_view.frame or now < self._invalid_code_until: + return + + if self._scan_thread is not None: + if self._scan_thread.is_alive(): + return + self._scan_thread = None + data = self._scan_result + if data is not None: + try: + parse_lpa_activation_code(data) + except ValueError: + self._invalid_code_until = now + self.INVALID_CODE_DURATION_S + self._last_scan_time = self._invalid_code_until + else: + self._detected = True + self.dismiss(lambda: self._on_qr_detected(data)) + return + + if now - self._last_scan_time < self.SCAN_INTERVAL_S: + return + self._last_scan_time = now + + frame = self._camera_view.frame + y = np.frombuffer(frame.data, dtype=np.uint8, count=frame.height * frame.stride).reshape(frame.height, frame.stride) + gray = y[:, :frame.width].copy() # the vision buffer is recycled under the scan thread + self._scan_thread = threading.Thread(target=self._scan, args=(gray,), daemon=True) + self._scan_thread.start() + + def _scan(self, gray: np.ndarray): + self._scan_result = qrcode.decode(gray) + + def _render(self, rect): + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + self._camera_view._render(rect) + + if not self._camera_view.frame: + gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD, + alignment=TextAlignment.CENTER) + else: + label_y = rect.y + rect.height * 3 / 4 + label_rect = rl.Rectangle(rect.x, label_y + (rect.height - label_y) / 2 - 20, rect.width, 40) + text = "not an LPA code" if rl.get_time() < self._invalid_code_until else "hold QR code to camera" + gui_label(label_rect, text, font_size=32, font_weight=FontWeight.MEDIUM, + alignment=TextAlignment.CENTER, + color=rl.Color(255, 255, 255, int(255 * 0.9))) + + rl.end_scissor_mode() + + +class InstallingProfileDialog(BigDialog): + DOT_STEP = 0.6 + + def __init__(self): + super().__init__("installing profile", "please wait...") + self._show_time = 0.0 + + def show_event(self): + super().show_event() + self._nav_bar._alpha = 0.0 + self._show_time = rl.get_time() + + def _back_enabled(self) -> bool: + return False + + def _render(self, _): + t = (rl.get_time() - self._show_time) % (self.DOT_STEP * 2) + dots = "." * min(int(t / (self.DOT_STEP / 4)), 3) + self._card.set_value(f"please wait{dots}") + super()._render(_) + + +class EsimProfileButton(BigButton): + SUB_LABEL_DISABLED = rl.Color(255, 255, 255, int(255 * 0.585)) + CHECK_ICON_COLOR = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) + LABEL_PADDING = 98 + LABEL_WIDTH = 402 - 98 - 28 + SUB_LABEL_WIDTH = 402 - BigButton.LABEL_HORIZONTAL_PADDING * 2 + + def __init__(self, profile: Profile, cellular_manager: CellularManager, profiles_enabled: Callable[[], bool]): + self._cellular_manager = cellular_manager + self._profiles_enabled = profiles_enabled + super().__init__(profile.display_name, scroll=True) + + self._profile = profile + + self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 48, 36) + self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 48, 36) + self._check_txt = gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 32, 32) + self._comma_txt = gui_app.texture("icons_mici/settings/comma_icon.png", 36, 36) if profile.is_comma else None + + self._delete_btn = ProfileActionButton(self._on_delete, delete=True) + self._rename_btn = ProfileActionButton(self._on_rename) if not profile.is_comma else None + self._delete_btn.set_enabled(lambda: not self._locked and not self._cellular_manager.busy and self._show_delete_btn) + if self._rename_btn: + self._rename_btn.set_enabled(lambda: not self._locked and not self._cellular_manager.busy) + self.set_enabled(lambda: not self._profile.enabled and self._profiles_enabled() and not self._cellular_manager.busy) + self.update_profile(profile) + + @property + def profile(self) -> Profile: + return self._profile + + def update_profile(self, profile: Profile): + self._profile = profile + active = profile.enabled + self.set_text(profile.display_name) + self.set_value("active" if active else "switch") + + def _update_state(self): + super()._update_state() + self._sub_label.set_color(DEFAULT_TEXT_COLOR if self.enabled else self.SUB_LABEL_DISABLED) + self._sub_label.set_font_weight(FontWeight.SEMI_BOLD if self.enabled else FontWeight.ROMAN) + + @property + def _locked(self) -> bool: + return not self._profile.is_comma and not self._profiles_enabled() + + @property + def _show_delete_btn(self) -> bool: + return not self._profile.enabled and not self._profile.is_comma + + def _on_rename(self): + current = self._profile.nickname or "" + dlg = BigInputDialog("nickname", default_text=current, confirm_callback=self._on_nickname_entered, + text_validator=lambda text: bool(text.strip())) + gui_app.push_widget(dlg) + + def _on_delete(self): + icon = gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64) + gui_app.push_widget(BigConfirmationDialog("slide to delete", icon, self._delete_profile, red=True)) + + def _delete_profile(self): + if not self._locked and not self._cellular_manager.busy and self._show_delete_btn: + if ui_state.sm["deviceState"].networkType == log.DeviceState.NetworkType.none: + gui_app.push_widget(BigDialog("", tr("Ensure you're connected to the internet and try again."))) + return + self._cellular_manager.delete_profile(self._profile.iccid) + + def _on_nickname_entered(self, nickname: str): + if not self._locked and not self._cellular_manager.busy: + self._cellular_manager.nickname_profile(self._profile.iccid, nickname.strip()) + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._show_delete_btn and rl.check_collision_point_rec(mouse_pos, self._delete_btn.rect): + return + if self._rename_btn is not None and rl.check_collision_point_rec(mouse_pos, self._rename_btn.rect): + return + super()._handle_mouse_release(mouse_pos) + + def _get_label_font_size(self): + return 48 + + def _draw_content(self, btn_y: float): + self._label.set_color(self.SUB_LABEL_DISABLED if self._locked else LABEL_COLOR) + label_rect = rl.Rectangle(self._rect.x + self.LABEL_PADDING, btn_y + self.LABEL_VERTICAL_PADDING, + self.LABEL_WIDTH, self._rect.height - self.LABEL_VERTICAL_PADDING * 2) + self._label.render(label_rect) + + active = self._profile.enabled + + if self.value: + sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING + label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING + action_w = self._rename_btn.rect.width if self._rename_btn is not None else 0 + action_w += self._delete_btn.rect.width if self._show_delete_btn else 0 + sub_label_w = self.SUB_LABEL_WIDTH - action_w + sub_label_height = self._sub_label.get_content_height(sub_label_w) + + if active: + check_y = int(label_y - sub_label_height + (sub_label_height - self._check_txt.height) / 2) + rl.draw_texture_ex(self._check_txt, rl.Vector2(sub_label_x, check_y), 0.0, 1.0, self.CHECK_ICON_COLOR) + sub_label_x += self._check_txt.width + 14 + + sub_label_rect = rl.Rectangle(sub_label_x, label_y - sub_label_height, sub_label_w, sub_label_height) + self._sub_label.render(sub_label_rect) + + if self._comma_txt: + rl.draw_texture_ex(self._comma_txt, (self._rect.x + 36, btn_y + 38), 0.0, 1.0, rl.WHITE) + else: + cell_icon = self._cell_full_txt if active else self._cell_none_txt + rl.draw_texture_ex(cell_icon, (self._rect.x + 30, btn_y + 38), 0.0, 1.0, rl.WHITE) + + btn_x = self._rect.x + self._rect.width - (ProfileActionButton.MARGIN - ProfileActionButton.HORIZONTAL_MARGIN) + btn_bottom = btn_y + self._rect.height + if self._show_delete_btn: + btn_x -= self._delete_btn.rect.width + self._delete_btn.render(rl.Rectangle( + btn_x, btn_bottom - self._delete_btn.rect.height, + self._delete_btn.rect.width, self._delete_btn.rect.height, + )) + if self._rename_btn is not None: + btn_x -= self._rename_btn.rect.width + self._rename_btn.render(rl.Rectangle( + btn_x, btn_bottom - self._rename_btn.rect.height, + self._rename_btn.rect.width, self._rename_btn.rect.height, + )) + + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + def action_pressed() -> bool: + return self._delete_btn.is_pressed or (self._rename_btn is not None and self._rename_btn.is_pressed) + super().set_touch_valid_callback(lambda: touch_callback() and not action_pressed()) + self._delete_btn.set_touch_valid_callback(touch_callback) + if self._rename_btn: + self._rename_btn.set_touch_valid_callback(touch_callback) + + +class EsimErrorDialog(NavRawScrollPanel): + def __init__(self, error: str): + super().__init__() + self._title = UnifiedLabel("esim error", font_size=64, font_weight=FontWeight.BOLD) + self._error = UnifiedLabel(error, font_size=36, elide=False) + + def _render(self, rect: rl.Rectangle): + width = int(rect.width - 80) + title_height = self._title.get_content_height(width) + error_height = self._error.get_content_height(width) + offset = self._scroll_panel.update(rect, title_height + error_height + 100) + y = rect.y + 40 + offset + + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + self._title.render(rl.Rectangle(rect.x + 40, y, width, title_height)) + self._error.render(rl.Rectangle(rect.x + 40, y + title_height + 20, width, error_height)) + rl.end_scissor_mode() + + +class EsimUI(NavScroller): + def __init__(self, cellular_manager: CellularManager, profiles_enabled: Callable[[], bool]): + super().__init__() + + self._cellular_manager = cellular_manager + self._profiles_enabled = profiles_enabled + + self._add_profile_btn = BigButton("add profile", "scan QR code") + self._add_profile_btn.set_click_callback(self._on_add_profile) + self._scroller.add_widget(self._add_profile_btn) + self._installing_dialog: InstallingProfileDialog | None = None + + self._cellular_manager.on_profiles_updated = self._on_profiles_updated + self._cellular_manager.on_operation_error = self._on_error + + def show_event(self): + super().show_event() + self._update_buttons(re_sort=True) + self._cellular_manager.refresh_profiles() + + def _on_profiles_updated(self): + if self._installing_dialog: + existing = {btn.profile.iccid for btn in self._scroller.items if isinstance(btn, EsimProfileButton)} + added = [profile for profile in self._cellular_manager.profiles if profile.iccid not in existing] + # Start the normal tap-to-activate flow once the profile list is visible again. + self._installing_dialog.dismiss(lambda: self._on_profile_clicked(added[0]) if len(added) == 1 else None) + self._installing_dialog = None + + self._update_buttons() + + def _update_buttons(self, re_sort: bool = False): + existing = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, EsimProfileButton)} + buttons = [] + for profile in self._cellular_manager.profiles: + btn = existing.get(profile.iccid) + if btn is None: + btn = EsimProfileButton(profile, self._cellular_manager, self._profiles_enabled) + btn.set_click_callback(lambda btn=btn: self._on_profile_clicked(btn.profile)) + self._scroller.add_widget(btn) + else: + btn.update_profile(profile) + buttons.append(btn) + + if re_sort: + self._scroller.items[:] = sorted(buttons, key=lambda b: not b.profile.enabled) + else: + self._scroller.items[:] = [btn for btn in self._scroller.items if btn in buttons] + + self._scroller.items.append(self._add_profile_btn) + + def _move_profile_to_front(self, iccid: str | None, scroll: bool = False): + front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) + if isinstance(btn, EsimProfileButton) and btn.profile.iccid == iccid), None) if iccid else None + + if front_btn_idx is not None and front_btn_idx > 0: + self._scroller.move_item(front_btn_idx, 0) + + if scroll: + self._scroller.scroll_to(self._scroller.scroll_panel.get_offset(), smooth=True) + + def _update_state(self): + super()._update_state() + + self._add_profile_btn.set_enabled(not self._cellular_manager.busy and self._profiles_enabled()) + active = self._cellular_manager.active_profile + self._move_profile_to_front(active.iccid if active else None) + + def _on_add_profile(self): + if self._cellular_manager.busy or not self._profiles_enabled(): + return + if ui_state.sm["deviceState"].networkType == log.DeviceState.NetworkType.none: + gui_app.push_widget(BigDialog("", tr("Ensure you're connected to the internet and try again."))) + return + gui_app.push_widget(QRScannerDialog(on_qr_detected=self._on_qr_scanned)) + + def _on_qr_scanned(self, lpa_code: str): + dlg = BigInputDialog("enter a nickname...", text_validator=lambda text: bool(text.strip()), + confirm_callback=lambda nickname: self._download_profile(lpa_code, nickname)) + gui_app.push_widget(dlg) + + def _download_profile(self, lpa_code: str, nickname: str): + self._installing_dialog = InstallingProfileDialog() + gui_app.push_widget(self._installing_dialog) + self._cellular_manager.download_profile(lpa_code, nickname.strip()) + + def _on_error(self, error: str): + cloudlog.error("eSIM error: %s", error) + dlg = EsimErrorDialog(error) + if self._installing_dialog: + self._installing_dialog.dismiss(lambda: gui_app.push_widget(dlg)) + self._installing_dialog = None + else: + gui_app.push_widget(dlg) + + def _on_profile_clicked(self, profile: Profile): + if self._cellular_manager.busy or not self._profiles_enabled(): + return + self._cellular_manager.switch_profile(profile.iccid) + self._move_profile_to_front(profile.iccid, scroll=True) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 58b3d0c77d..d38d640bdd 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -1,12 +1,13 @@ -from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiNetworkButton +from openpilot.selfdrive.ui.mici.layouts.settings.network import EsimNetworkButton, WifiNetworkButton +from openpilot.selfdrive.ui.mici.layouts.settings.network.esim_ui import EsimUI from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigParamControl, BigToggle from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.selfdrive.ui.lib.prime_state import PrimeType from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType +from openpilot.system.ui.widgets.scroller import NavScroller class NetworkLayoutMici(NavScroller): @@ -28,7 +29,8 @@ class NetworkLayoutMici(NavScroller): self._network_metered_btn.set_enabled(False) self._wifi_manager.set_tethering_active(checked) - self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback) + self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback, + description="Share the device's internet connection through a Wi-Fi hotspot.") def tethering_password_callback(password: str): if password: @@ -58,26 +60,39 @@ class NetworkLayoutMici(NavScroller): # TODO: signal for current network metered type when changing networks, this is wrong until you press it once # TODO: disable when not connected - self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback) + self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback, + description="Metered prevents large uploads on this Wi-Fi connection. Default uses the network's detected " + + "setting.") self._network_metered_btn.set_enabled(False) self._wifi_button = WifiNetworkButton(self._wifi_manager) self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui)) + # ******** eSIM ******** + self._cellular_manager = CellularManager() + self._esim_ui = EsimUI( + self._cellular_manager, + lambda: not ui_state.prime_state.is_full_prime(), + ) + self._esim_button = EsimNetworkButton(self._cellular_manager) + self._esim_button.set_click_callback(lambda: gui_app.push_widget(self._esim_ui)) + # ******** Advanced settings ******** # ******** Roaming toggle ******** - self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming") + self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming", description="Allow cellular data roaming.") # ******** APN settings ******** - self._apn_btn = BigButton("apn settings", "edit") + self._apn_btn = BigButton("apn settings", "edit", + description="Set the access point name required by your cellular carrier. Leave blank for automatic configuration.") self._apn_btn.set_click_callback(self._edit_apn) # ******** Cellular metered toggle ******** - self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered") + self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered", description="Prevent large uploads over the cellular connection.") # Main scroller ---------------------------------- self._scroller.add_widgets([ self._wifi_button, + self._esim_button, self._network_metered_btn, self._tethering_toggle_btn, self._tethering_password_btn, @@ -91,8 +106,7 @@ class NetworkLayoutMici(NavScroller): def _update_state(self): super()._update_state() - # 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 = not ui_state.prime_state.is_full_prime() 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) @@ -102,14 +116,16 @@ class NetworkLayoutMici(NavScroller): super().show_event() self._wifi_manager.set_active(True) - # Process wifi callbacks while at any point in the nav stack + # Process wifi and esim callbacks while at any point in the nav stack gui_app.add_nav_stack_tick(self._wifi_manager.process_callbacks) + gui_app.add_nav_stack_tick(self._cellular_manager.process_callbacks) def hide_event(self): super().hide_event() self._wifi_manager.set_active(False) gui_app.remove_nav_stack_tick(self._wifi_manager.process_callbacks) + gui_app.remove_nav_stack_tick(self._cellular_manager.process_callbacks) def _edit_apn(self): def update_apn(apn: str): diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py index eb7789cba9..c979fe67a5 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py @@ -3,7 +3,7 @@ from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.network.network_layout import NetworkLayoutMici -from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton +from openpilot.selfdrive.ui.mici.layouts.settings.device.device_layout import DeviceLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.software import SoftwareLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout @@ -28,9 +28,9 @@ class SettingsLayout(NavScroller): network_btn = SettingsBigButton("network", "", gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 76, 56)) network_btn.set_click_callback(lambda: gui_app.push_widget(network_panel)) - device_panel = DeviceLayoutMici() - device_btn = SettingsBigButton("device", "", gui_app.texture("icons_mici/settings/device_icon.png", 72, 58)) - device_btn.set_click_callback(lambda: gui_app.push_widget(device_panel)) + self._device_panel = DeviceLayoutMici() + self._device_button = SettingsBigButton("device", "", gui_app.texture("icons_mici/settings/device_icon.png", 72, 58)) + self._device_button.set_click_callback(lambda: gui_app.push_widget(self._device_panel)) software_panel = SoftwareLayoutMici() software_btn = SettingsBigButton("software", "", gui_app.texture("icons_mici/settings/software.png", 64, 75)) @@ -47,11 +47,28 @@ class SettingsLayout(NavScroller): self._scroller.add_widgets([ toggles_btn, network_btn, - device_btn, + self._device_button, software_btn, - PairBigButton(), firehose_btn, developer_btn, ]) self._font_medium = gui_app.font(FontWeight.MEDIUM) + + def show_pairing(self): + gui_app.push_widget(self) + # add settings in the back stack without showing its entrance animation. + self._y_pos_filter.x = 0.0 + self.set_visible(lambda: self.enabled or self._device_panel.is_dismissing) + self.set_shown_callback(self._scroll_to_device) + gui_app.push_widget(self._device_panel) + self._device_panel.set_shown_callback(self._on_pairing_shown) + + def _scroll_to_device(self): + self._scroller._layout() + offset = (self._device_button.rect.x + self._device_button.rect.width / 2) - (self._rect.x + self._rect.width / 2) + self._scroller.scroll_to(offset, smooth=False) + + def _on_pairing_shown(self): + self.set_visible(True) + self._device_panel.scroll_to_pairing() diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 2a4ae44595..fee5b7ddb5 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -5,14 +5,13 @@ from enum import IntEnum from collections.abc import Callable from openpilot.common.time_helpers import system_time_valid -from openpilot.selfdrive.ui.mici.layouts.settings.device import EngagedConfirmationButton +from openpilot.selfdrive.ui.mici.layouts.settings.device.device_layout import EngagedConfirmationButton +from openpilot.selfdrive.ui.mici.widgets.info import InfoLayoutMici from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, MousePos 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 UPDATER_TIMEOUT = 10.0 # seconds to wait for updater to respond @@ -33,44 +32,21 @@ class UpdaterState(IntEnum): UPDATER_RESPONDING = 2 -class SoftwareInfoLayoutMici(Widget): +class SoftwareInfoLayoutMici(InfoLayoutMici): def __init__(self): - super().__init__() - - self.set_rect(rl.Rectangle(0, 0, 360, 180)) - - subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) - max_width = int(self._rect.width - 20) - self._version_label = UnifiedLabel("version", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self._version_text_label = UnifiedLabel("", 32, max_width=max_width, text_color=subheader_color, - font_weight=FontWeight.ROMAN, wrap_text=False) - - 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, scroll=True) + super().__init__("version", "", "branch", "") def _update_state(self): + super()._update_state() + desc = _split_description(ui_state.params.get("UpdaterCurrentDescription") or "") if desc is not None: version, branch, commit, date = desc - self._version_text_label.set_text(f"{version} ({date})") - self._branch_text_label.set_text(f"{branch} ({commit})") + self.subtext1.set_text(f"{version} ({date})") + self.subtext2.set_text(f"{branch} ({commit})") else: - self._version_text_label.set_text(ui_state.params.get("Version") or "N/A") - self._branch_text_label.set_text(ui_state.params.get("GitBranch") or "N/A") - - def _render(self, _): - self._version_label.set_position(self._rect.x + 20, self._rect.y - 10) - self._version_label.render() - - self._version_text_label.set_position(self._rect.x + 20, self._rect.y + 68 - 25) - self._version_text_label.render() - - self._branch_label.set_position(self._rect.x + 20, self._rect.y + 114 - 30) - self._branch_label.render() - - self._branch_text_label.set_position(self._rect.x + 20, self._rect.y + 161 - 25) - self._branch_text_label.render() + self.subtext1.set_text(ui_state.params.get("Version") or "N/A") + self.subtext2.set_text(ui_state.params.get("GitBranch") or "N/A") class CheckUpdateButton(BigButton): @@ -242,7 +218,8 @@ class BranchSelectPage(NavScroller): class TargetBranchButton(BigButton): def __init__(self, check_update_btn: CheckUpdateButton): - super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "") + super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "", + description="Select the software branch to download on the next update check.") self._check_update_btn = check_update_btn self.set_click_callback(self._on_click) self.set_visible(not ui_state.params.get_bool("IsTestedBranch")) @@ -276,7 +253,9 @@ class SoftwareLayoutMici(NavScroller): uninstall_openpilot_btn = EngagedConfirmationButton("uninstall sunnypilot", "uninstall", gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64), - uninstall_openpilot_callback, exit_on_confirm=False) + uninstall_openpilot_callback, exit_on_confirm=False, + description="Remove openpilot from this device.", + description_icon=gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64)) check_update_btn = CheckUpdateButton() self._scroller.add_widgets([ diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index 2dba124df5..406e0a77d1 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -1,59 +1,46 @@ -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, BigToggle, GreyBigButton -from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationCircleButton +from openpilot.selfdrive.ui.mici.widgets.button import BigToggle, BigParamControl, BigMultiParamToggle 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 +from openpilot.selfdrive.ui.mici.layouts.settings.initial_cruise_speed import InitialCruiseSpeedButton 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 = 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 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) + self._personality_toggle = BigMultiParamToggle("following distance", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"], + description="Standard is recommended.\n" + + "Aggressive follows closer, with firmer gas and braking.\n" + + "Relaxed leaves more space.\n" + + "Use the steering wheel distance button on supported cars.") + self._initial_cruise_speed = InitialCruiseSpeedButton() + self._initial_cruise_speed.set_visible(lambda: ui_state.CP is not None and ui_state.has_longitudinal_control and not ui_state.CP.pcmCruise) + is_metric_toggle = BigToggle("use metric units", initial_state=ui_state.params.get_bool("IsMetric"), toggle_callback=self._on_metric_toggle) + ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled", + description="Warn when you drift across a detected lane line.\n" + + "Only above 31 mph (50 km/h), with no turn signal.") + always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM", description="Monitor the driver even when sunnypilot is not engaged.") + record_front = BigParamControl("record & upload cabin camera", "RecordFront", + description_icon=gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64), + toggle_callback=restart_needed_callback, description="Upload cabin camera data to help improve driver monitoring.") + record_mic = BigParamControl("record & upload mic audio", "RecordAudio", description_icon=gui_app.texture("icons_mici/microphone.png", 64, 64), + toggle_callback=restart_needed_callback, + description="Record microphone audio while driving.\n" + + "Audio is included in dashcam videos in sunnylink.") + enable_openpilot = BigParamControl("enable sunnypilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback, + description="Enable to use sunnypilot driver assistance.\n" + + "Disable to use your car's stock driver assistance.") + self._scroller.add_widgets([ self._personality_toggle, - self._experimental_btn, + self._initial_cruise_speed, is_metric_toggle, ldw_toggle, always_on_dm_toggle, @@ -64,7 +51,6 @@ class TogglesLayoutMici(NavScroller): # Toggle lists self._refresh_toggles = ( - ("ExperimentalMode", self._experimental_btn), ("IsMetric", is_metric_toggle), ("IsLdwEnabled", ldw_toggle), ("AlwaysOnDM", always_on_dm_toggle), @@ -96,35 +82,17 @@ class TogglesLayoutMici(NavScroller): super().show_event() self._update_toggles() + def _on_metric_toggle(self, state: bool): + ui_state.params.put_bool("IsMetric", state, block=True) + self._update_toggles() + def _update_toggles(self): ui_state.update_params() + self._initial_cruise_speed.refresh() - # CP gating for experimental mode if ui_state.CP is not None: - if ui_state.has_longitudinal_control: - self._experimental_btn.set_visible(True) - self._personality_toggle.set_visible(True) - else: - # no long for now - self._experimental_btn.set_visible(False) - self._experimental_btn.set_checked(False) - self._personality_toggle.set_visible(False) - ui_state.params.remove("ExperimentalMode") + self._personality_toggle.set_visible(ui_state.has_longitudinal_control) # 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/augmented_road_view.py b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py index 71ea7e8f8f..ffd7d94b2e 100644 --- a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -8,13 +8,15 @@ from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer +from openpilot.selfdrive.ui.mici.onroad.long_indicator import LongIndicator from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer +from openpilot.selfdrive.ui.mici.onroad.navigation import NavigationPanel, PANEL_WIDTH from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent, TextAlignment, TextAlignmentVertical from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets import Widget -from openpilot.common.filter_simple import BounceFilter +from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame from openpilot.common.transformations.orientation import rot_from_euler from enum import IntEnum @@ -35,14 +37,14 @@ class BookmarkState(IntEnum): DRAGGING = 1 TRIGGERED = 2 -WIDE_CAM_MAX_SPEED = 5.0 # m/s (10 mph) -ROAD_CAM_MIN_SPEED = 10 # m/s (25 mph) +WIDE_CAM_MAX_SPEED = 5.0 # m/s (11 mph) +ROAD_CAM_MIN_SPEED = 10.0 # m/s (22 mph) CAM_Y_OFFSET = 20 class BookmarkIcon(Widget): - PEEK_THRESHOLD = 50 # If icon peeks out this much, snap it fully visible + PEEK_THRESHOLD = 150 # If icon peeks out this much, snap it fully visible FULL_VISIBLE_OFFSET = 200 # How far onscreen when fully visible HIDDEN_OFFSET = -50 # How far offscreen when hidden @@ -50,7 +52,9 @@ class BookmarkIcon(Widget): super().__init__() self._bookmark_callback = bookmark_callback self._icon = gui_app.texture("icons_mici/onroad/bookmark.png", 180, 180) + self._filled_icon = gui_app.texture("icons_mici/onroad/bookmark_fill.png", 180, 180) self._offset_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps) + self._active_alpha = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) # State self._interacting = False @@ -60,6 +64,8 @@ class BookmarkIcon(Widget): self._is_swiping = False self._is_swiping_left: bool = False self._triggered_time: float = 0.0 + self._triggered_duration = 1.5 + self._swipe_expired = False def is_swiping_left(self) -> bool: """Check if currently swiping left (for scroller to disable).""" @@ -70,18 +76,25 @@ class BookmarkIcon(Widget): return interacting def _update_state(self): + if self._state == BookmarkState.TRIGGERED and rl.get_time() - self._triggered_time >= self._triggered_duration: + self._state = BookmarkState.HIDDEN + self._swipe_expired = self._is_swiping + + swipe_offset = self._swipe_start_x - self._swipe_current_x + armed = self._state == BookmarkState.DRAGGING and swipe_offset > self.PEEK_THRESHOLD if self._state == BookmarkState.DRAGGING: - # Allow pulling past activated position with rubber band effect - swipe_offset = self._swipe_start_x - self._swipe_current_x + # Snap to the released position when armed, while allowing further dragging or cancellation. + if armed: + swipe_offset += self.FULL_VISIBLE_OFFSET - self.PEEK_THRESHOLD swipe_offset = min(swipe_offset, self.FULL_VISIBLE_OFFSET + 50) self._offset_filter.update(swipe_offset) elif self._state == BookmarkState.TRIGGERED: - # Continue animating to fully visible - self._offset_filter.update(self.FULL_VISIBLE_OFFSET) - # Stay in TRIGGERED state for 1 second - if rl.get_time() - self._triggered_time >= 1.5: - self._state = BookmarkState.HIDDEN + # Let another left swipe move the same bookmark. + offset = self.FULL_VISIBLE_OFFSET + if self._is_swiping and self._is_swiping_left: + offset += min(swipe_offset, 50) + self._offset_filter.update(offset) elif self._state == BookmarkState.HIDDEN: self._offset_filter.update(self.HIDDEN_OFFSET) @@ -89,17 +102,23 @@ class BookmarkIcon(Widget): if self._offset_filter.x < 1e-3: self._interacting = False + self._active_alpha.update(float(armed or self._state == BookmarkState.TRIGGERED)) + def _handle_mouse_event(self, mouse_event: MouseEvent): if not ui_state.started: return if mouse_event.left_pressed: + if self._state == BookmarkState.TRIGGERED: + self._triggered_time = rl.get_time() + self._triggered_duration = 0.5 + # Store relative position within widget self._swipe_start_x = mouse_event.pos.x self._swipe_current_x = mouse_event.pos.x self._is_swiping = True self._is_swiping_left = False - self._state = BookmarkState.DRAGGING + self._swipe_expired = False elif mouse_event.left_down and self._is_swiping: self._swipe_current_x = mouse_event.pos.x @@ -107,30 +126,34 @@ class BookmarkIcon(Widget): self._is_swiping_left = swipe_offset > 0 if self._is_swiping_left: self._interacting = True + if self._state == BookmarkState.HIDDEN and not self._swipe_expired: + self._state = BookmarkState.DRAGGING elif mouse_event.left_released: - if self._is_swiping: + if self._is_swiping and self._state == BookmarkState.DRAGGING: swipe_distance = self._swipe_start_x - self._swipe_current_x # If peeking past threshold, transition to animating to fully visible and bookmark if swipe_distance > self.PEEK_THRESHOLD: self._state = BookmarkState.TRIGGERED self._triggered_time = rl.get_time() + self._triggered_duration = 1.5 self._bookmark_callback() else: # Otherwise, transition back to hidden self._state = BookmarkState.HIDDEN - # Reset swipe state - self._is_swiping = False - self._is_swiping_left = False + # Reset swipe state + self._is_swiping = False + self._is_swiping_left = False def _render(self, _): """Render the bookmark icon.""" if self._offset_filter.x > 0: icon_x = self.rect.x + self.rect.width - round(self._offset_filter.x) icon_y = self.rect.y + (self.rect.height - self._icon.height) / 2 # Vertically centered - rl.draw_texture_ex(self._icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) + for icon, alpha in ((self._icon, 1.0 - self._active_alpha.x), (self._filled_icon, self._active_alpha.x)): + rl.draw_texture_ex(icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.Color(255, 255, 255, round(255 * alpha))) class AugmentedRoadView(CameraView): @@ -151,8 +174,10 @@ class AugmentedRoadView(CameraView): # Bookmark icon with swipe gesture self._bookmark_icon = BookmarkIcon(bookmark_callback) + self._navigation = NavigationPanel() self._model_renderer = ModelRenderer() self._hud_renderer = HudRenderer() + self._long_indicator = LongIndicator() self._alert_renderer = AlertRenderer() self._driver_state_renderer = DriverStateRenderer() self._confidence_ball = ConfidenceBall() @@ -195,11 +220,14 @@ class AugmentedRoadView(CameraView): # Update calibration before rendering self._update_calibration() + alert_to_render, not_animating_out = self._alert_renderer.will_render() + show_navigation = ui_state.navigation_enabled and ui_state.nav_destination_set and alert_to_render is None + # Create inner content area with border padding self._content_rect = rl.Rectangle( self.rect.x, self.rect.y, - self.rect.width - SIDE_PANEL_WIDTH, + self.rect.width - (PANEL_WIDTH if show_navigation else SIDE_PANEL_WIDTH), self.rect.height, ) @@ -221,14 +249,14 @@ class AugmentedRoadView(CameraView): # Fade out bottom of overlays for looks rl.draw_texture_ex(self._fade_texture, rl.Vector2(self._content_rect.x, self._content_rect.y), 0.0, 1.0, rl.WHITE) - alert_to_render, not_animating_out = self._alert_renderer.will_render() - # Hide DMoji when disengaged unless AlwaysOnDM is enabled should_draw_dmoji = (not self._hud_renderer.drawing_top_icons() and (ui_state.status != UIStatus.DISENGAGED or ui_state.always_on_dm)) self._driver_state_renderer.set_should_draw(should_draw_dmoji) self._driver_state_renderer.set_position(self._rect.x + 16, self._rect.y + 10) self._driver_state_renderer.render() + self._long_indicator.set_should_draw(not self._hud_renderer.drawing_top_icons()) + self._long_indicator.render(self._content_rect) self._hud_renderer.set_can_draw_top_icons(alert_to_render is None) self._hud_renderer.set_wheel_critical_icon(alert_to_render is not None and not not_animating_out and @@ -244,12 +272,19 @@ class AugmentedRoadView(CameraView): # Custom UI extension point - add custom overlays here # Use self._content_rect for positioning within camera bounds - self._confidence_ball.render(self.rect) + if show_navigation: + self._navigation.render(rl.Rectangle(self._content_rect.x + self._content_rect.width, self.rect.y, PANEL_WIDTH, self.rect.height)) + else: + self._confidence_ball.render(self.rect) self._bookmark_icon.render(self.rect) + def close(self): + self._navigation.close() + super().close() + def _switch_stream_if_needed(self, sm): - if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams: + if sm['carParams'].openpilotLongitudinalControl and WIDE_CAM in self.available_streams: v_ego = sm['carState'].vEgo if v_ego < WIDE_CAM_MAX_SPEED: target = WIDE_CAM diff --git a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py index 05abae82eb..3530fc592d 100644 --- a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py @@ -190,9 +190,6 @@ class HudRenderer(Widget): 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 diff --git a/openpilot/selfdrive/ui/mici/onroad/long_indicator.py b/openpilot/selfdrive/ui/mici/onroad/long_indicator.py new file mode 100644 index 0000000000..1ff83082a2 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/onroad/long_indicator.py @@ -0,0 +1,89 @@ +import pyray as rl +from openpilot.cereal import log +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import Widget + +HIGHLIGHT_TIME = 2.5 # seconds + + +class LongIndicator(Widget): + def __init__(self): + super().__init__() + self._txt_lead_car = (self._texture('car', 35, 27), self._texture('car_green', 50, 42)) + self._txt_distance = [(self._texture('distance_1', 32, 7), self._texture('distance_1_green', 60, 35)), + (self._texture('distance_2', 40, 9), self._texture('distance_2_green', 68, 37)), + (self._texture('distance_3', 48, 11), self._texture('distance_3_green', 76, 39))] + self._alpha_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps) + # crossfade between states, a white and a green filter per icon + self._lead_car_filters = (FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps), FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)) + self._distance_filters = [(FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps), FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)) + for _ in range(3)] + self._personality: int | None = None + self._personality_changed_time = -HIGHLIGHT_TIME + self._should_draw = False + + @staticmethod + def _texture(name: str, width: int, height: int) -> rl.Texture: + return gui_app.texture(f'icons_mici/longitudinal/{name}.png', width, height, keep_aspect_ratio=False) + + def set_should_draw(self, should_draw: bool): + self._should_draw = should_draw + + def _render(self, rect: rl.Rectangle) -> None: + sm = ui_state.sm + if sm.recv_frame['selfdriveState'] < ui_state.started_frame or not sm['selfdriveState'].enabled or not ui_state.has_longitudinal_control: + self._personality = None + self._personality_changed_time = -HIGHLIGHT_TIME + self._alpha_filter.x = 0.0 + return + + # hidden under alerts and set speed + visible = self._should_draw and sm['selfdriveState'].alertSize == log.SelfdriveState.AlertSize.none + alpha = self._alpha_filter.update(visible) + self._draw_lead_car(rect, alpha) + self._draw_distance_bars(rect, alpha, visible) + + def _draw_lead_car(self, rect: rl.Rectangle, alpha: float) -> None: + sm = ui_state.sm + plan = sm['longitudinalPlan'] + has_lead = sm.alive['longitudinalPlan'] and plan.hasLead + + e2e = has_lead and plan.longitudinalPlanSource == log.LongitudinalPlan.LongitudinalPlanSource.e2e + white_f, green_f = self._lead_car_filters + white_alpha = white_f.update(0.0 if e2e else 0.9 if has_lead else 0.35) + green_alpha = green_f.update(float(e2e)) + + white, green = self._txt_lead_car + self._draw_centered(white, rect, 100, white_alpha * alpha) + self._draw_centered(green, rect, 100, green_alpha * alpha) + + def _draw_distance_bars(self, rect: rl.Rectangle, alpha: float, visible: bool) -> None: + sm = ui_state.sm + now = rl.get_time() + personality = sm['selfdriveState'].personality.raw + if self._personality is not None and personality != self._personality: + self._personality_changed_time = now + self._personality = personality + # the personality alert covers the bars, hold the highlight until they show + if not visible and now - self._personality_changed_time < HIGHLIGHT_TIME: + self._personality_changed_time = now + highlight = now - self._personality_changed_time < HIGHLIGHT_TIME + + # blink at double the turn signal rate (2.67 Hz) while overriding the gas + overriding = any(e.name == log.OnroadEvent.EventName.gasPressedOverride for e in sm['onroadEvents']) + blink = 0.35 / 0.9 if overriding and now % 0.375 > 0.1875 else 1.0 + + count = personality + 1 + for i, ((white, green), y, (active_f, green_f)) in enumerate(zip(self._txt_distance, (122, 136, 152), self._distance_filters, strict=True)): + active = active_f.update(float(i < count)) + green_alpha = green_f.update(float(highlight and i == count - 1)) + # only lit bars blink on override + self._draw_centered(white, rect, y, (0.35 * (1 - active) + 0.9 * (active - green_alpha) * blink) * alpha) + self._draw_centered(green, rect, y, green_alpha * blink * alpha) + + @staticmethod + def _draw_centered(texture: rl.Texture, rect: rl.Rectangle, y: float, alpha: float) -> None: + pos = rl.Vector2(rect.x + 46 - texture.width / 2, rect.y + y - texture.height / 2) + rl.draw_texture_ex(texture, pos, 0.0, 1.0, rl.Color(255, 255, 255, round(255 * alpha))) diff --git a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py index 805bdcc335..68d7488471 100644 --- a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py @@ -1,11 +1,12 @@ import colorsys import numpy as np import pyray as rl -from openpilot.cereal import messaging +from openpilot.cereal import log, messaging from opendbc.car.structs import car from dataclasses import dataclass, field from openpilot.common.params import Params from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.controls.radard import RADAR_TO_CAMERA from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.mici.onroad import blend_colors @@ -18,18 +19,15 @@ from openpilot.selfdrive.ui.sunnypilot.mici.onroad.model_renderer import LANE_LI CLIP_MARGIN = 500 MIN_DRAW_DISTANCE = 10.0 MAX_DRAW_DISTANCE = 100.0 +LEAD_BAR_LENGTH = 12.0 # px +LEAD_BAR_WIDTH = 1.8 # m -THROTTLE_COLORS = [ - rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4) - rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35) - rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0) -] - -NO_THROTTLE_COLORS = [ - rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4) - rl.Color(242, 242, 242, 89), # HSLF(112/360, 0.0, 0.95, 0.35) - rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0) -] +STOCK_PATH_GRADIENT = Gradient( + start=(0.0, 1.0), + end=(0.0, 0.0), + colors=[rl.Color(13, 248, 122, 102), rl.Color(114, 255, 92, 89), rl.Color(114, 255, 92, 0)], + stops=[0.0, 0.5, 1.0], +) LANE_LINE_COLORS = { UIStatus.DISENGAGED: rl.Color(200, 200, 200, 255), @@ -45,11 +43,12 @@ class ModelPoints: projected_points: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32)) -@dataclass class LeadVehicle: - glow: list[tuple[float, float]] = field(default_factory=list) - chevron: list[tuple[float, float]] = field(default_factory=list) - fill_alpha: int = 0 + def __init__(self): + self.bar = np.empty((0, 2), dtype=np.float32) + self.d_filter = FirstOrderFilter(0.0, 0.2, 1 / gui_app.target_fps, initialized=False) + self.y_filter = FirstOrderFilter(0.0, 0.2, 1 / gui_app.target_fps, initialized=False) + self.fade_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) class ModelRenderer(Widget, ModelRendererSP): @@ -57,9 +56,6 @@ class ModelRenderer(Widget, ModelRendererSP): Widget.__init__(self) ModelRendererSP.__init__(self) self._longitudinal_control = False - self._experimental_mode = False - self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps) - self._prev_allow_throttle = True self._lane_line_probs = np.zeros(4, dtype=np.float32) self._road_edge_stds = np.zeros(2, dtype=np.float32) self._lead_vehicles = [LeadVehicle(), LeadVehicle()] @@ -71,11 +67,7 @@ class ModelRenderer(Widget, ModelRendererSP): self._road_edges = [ModelPoints() for _ in range(2)] self._acceleration_x = np.empty((0,), dtype=np.float32) - self._acceleration_x_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) - self._acceleration_x_filter2 = FirstOrderFilter(0.0, 1, 1 / gui_app.target_fps) - self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps) - self._ll_color_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) # 3x3 car space -> rect-origin space (draw methods add rect.x/y) self._car_space_transform = np.zeros((3, 3), dtype=np.float32) @@ -85,7 +77,7 @@ class ModelRenderer(Widget, ModelRendererSP): self._counter = -1 self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0 - self._exp_gradient = Gradient( + self._path_gradient = Gradient( start=(0.0, 1.0), # Bottom of path end=(0.0, 0.0), # Top of path colors=[], @@ -121,8 +113,6 @@ class ModelRenderer(Widget, ModelRendererSP): ) # Update state - self._experimental_mode = sm['selfdriveState'].experimentalMode - extrinsics_calibration = sm['extrinsicsCalibration'] self._path_offset_z = extrinsics_calibration.height[0] if extrinsics_calibration.height else HEIGHT_INIT[0] @@ -145,17 +135,18 @@ class ModelRenderer(Widget, ModelRendererSP): return self._update_model(lead_one, path_x_array) - if render_lead_indicator: - self._update_leads(radar_state, path_x_array) self._transform_dirty = False # Draw elements (hide when disengaged) if ui_state.status != UIStatus.DISENGAGED: self._draw_lane_lines() - self._draw_path(sm) + self._draw_path() - # if render_lead_indicator and radar_state: - # self._draw_lead_indicator() + if render_lead_indicator: + self._update_leads(sm) + self._draw_lead_indicator() + else: + self._lead_vehicles = [LeadVehicle(), LeadVehicle()] def _update_raw_points(self, model): """Update raw 3D points from model data""" @@ -171,21 +162,45 @@ class ModelRenderer(Widget, ModelRendererSP): self._road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32) self._acceleration_x = np.array(model.acceleration.x, dtype=np.float32) - def _update_leads(self, radar_state, path_x_array): - """Update positions of lead vehicles""" - self._lead_vehicles = [LeadVehicle(), LeadVehicle()] - leads = [radar_state.leadOne, radar_state.leadTwo] + def _update_leads(self, sm): + plan = sm['longitudinalPlan'] + if plan.longitudinalPlanSource == log.LongitudinalPlan.LongitudinalPlanSource.e2e and len(sm['modelV2'].leadsV3) > 1: + leads = [(lead.prob > 0.5, lead.x[0], -lead.y[0]) for lead in list(sm['modelV2'].leadsV3)[:2]] + else: + radar = sm['radarState'] + leads = [(lead.present, lead.dRel + RADAR_TO_CAMERA, lead.yRel) for lead in (radar.leadOne, radar.leadTwo)] - for i, lead_data in enumerate(leads): - if lead_data and lead_data.present: - d_rel, y_rel, v_rel = lead_data.dRel, lead_data.yRel, lead_data.vRel - idx = self._get_path_length_idx(path_x_array, d_rel) + # both leads can be the same vehicle + if leads[0][0] and abs(leads[1][1] - leads[0][1]) < 3.0: + leads[1] = (False, 0.0, 0.0) - # Get z-coordinate from path at the lead vehicle position - z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0 - point = self._map_to_screen(d_rel, -y_rel + self._camera_offset, z + self._path_offset_z) - if point: - self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect) + ss, cs = sm['selfdriveState'], sm['carState'] + # braking disengages without making openpilot unavailable + available = ss.enabled or ss.engageable or cs.brakePressed + lane = self._path.raw_points + opacity = 0.4 if ui_state.status == UIStatus.DISENGAGED else 0.8 + for lead, (present, d_rel, y_rel) in zip(self._lead_vehicles, leads, strict=True): + visible = available and present and d_rel < MAX_DRAW_DISTANCE and len(lane) > 0 + # snap to a new vehicle instead of sliding over + if not visible or abs(y_rel - lead.y_filter.x) > 3.0: + lead.d_filter.initialized = lead.y_filter.initialized = False + lead.fade_filter.update(opacity if visible else 0.0) + if visible: + lead.bar = self._get_lead_bar(lane, lead.d_filter.update(d_rel), lead.y_filter.update(y_rel)) + + def _get_lead_bar(self, lane, d_rel, y_rel): + # bar on the road behind the lead, following the lane + x = np.array([d_rel, d_rel - min(6.0, 0.25 * d_rel)]) + y = np.interp(x, lane[:, 0], lane[:, 1]) - np.interp(d_rel, lane[:, 0], lane[:, 1]) - y_rel + self._camera_offset + z = np.interp(x, self._path.raw_points[:, 0], self._path.raw_points[:, 2]) + self._path_offset_z + corners = np.vstack((np.column_stack((x, y + LEAD_BAR_WIDTH / 2, z)), np.column_stack((x, y - LEAD_BAR_WIDTH / 2, z))[::-1])) + pts = self._car_space_transform @ corners.T + bar = (pts[:2] / pts[2]).T + + far, near = bar[[0, 3]], bar[[1, 2]] + length = np.linalg.norm(near.mean(axis=0) - far.mean(axis=0)) + bar[[1, 2]] = far + (near - far) * np.clip(length, 3.0, LEAD_BAR_LENGTH) / length + return bar.astype(np.float32) def _update_model(self, lead, path_x_array): """Update model visualization data based on model message""" @@ -210,27 +225,15 @@ class ModelRenderer(Widget, ModelRendererSP): lead_d = lead.dRel * 2.0 max_distance = np.clip(lead_d - min(lead_d * 0.35, 10.0), 0.0, max_distance) - soon_acceleration = self._acceleration_x[len(self._acceleration_x) // 4] if len(self._acceleration_x) > 0 else 0 - self._acceleration_x_filter.update(soon_acceleration) - self._acceleration_x_filter2.update(soon_acceleration) - - # make path width wider/thinner when initially braking/accelerating - if self._experimental_mode and False: - high_pass_acceleration = self._acceleration_x_filter.x - self._acceleration_x_filter2.x - y_off = np.interp(high_pass_acceleration, [-1, 0, 1], [0.9 * 2, 0.9, 0.9 / 2]) - else: - y_off = 0.9 - 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, y_off, self._path_offset_z, max_idx, allow_invert=False + self._path.raw_points, 0.9, self._path_offset_z, max_idx, allow_invert=False ) - self._update_experimental_gradient() + self._update_path_gradient() - def _update_experimental_gradient(self): - """Pre-calculate experimental mode gradient colors""" - if not self._experimental_mode: + def _update_path_gradient(self): + if not self._longitudinal_control: return path_pts = self._path.projected_points + np.array([self._rect.x, self._rect.y], dtype=np.float32) @@ -267,32 +270,8 @@ class ModelRenderer(Widget, ModelRendererSP): i += 1 + (1 if (i + 2) < max_len else 0) # Store the gradient in the path object - self._exp_gradient.colors = segment_colors - self._exp_gradient.stops = gradient_stops - - def _update_lead_vehicle(self, d_rel, v_rel, point, rect): - speed_buff, lead_buff = 10.0, 40.0 - - # Calculate fill alpha - fill_alpha = 0 - if d_rel < lead_buff: - fill_alpha = 255 * (1.0 - (d_rel / lead_buff)) - if v_rel < 0: - fill_alpha += 255 * (-1 * (v_rel / speed_buff)) - fill_alpha = min(fill_alpha, 255) - - # Calculate size and position - sz = np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0) * 1 - x = np.clip(point[0], 0.0, rect.width - sz / 2) - y = min(point[1], rect.height - sz * 0.6) - - g_xo = sz / 5 - g_yo = sz / 10 - - glow = [(x + (sz * 1.35) + g_xo, y + sz + g_yo), (x, y - g_yo), (x - (sz * 1.35) - g_xo, y + sz + g_yo)] - chevron = [(x + (sz * 1.25), y + sz), (x, y), (x - (sz * 1.25), y + sz)] - - return LeadVehicle(glow=glow, chevron=chevron, fill_alpha=int(fill_alpha)) + self._path_gradient.colors = segment_colors + self._path_gradient.stops = gradient_stops def _get_ll_color(self, prob: float, adjacent: bool, left: bool): alpha = np.clip(prob, 0.0, 0.7) @@ -336,52 +315,29 @@ class ModelRenderer(Widget, ModelRendererSP): color = self._get_ll_color(float(1.0 - self._road_edge_stds[i]), float(self._lane_line_probs[i + 1]) < 0.25, i == 0) draw_polygon(self._rect, road_edge.projected_points + offset, color) - def _draw_path(self, sm): - """Draw path with dynamic coloring based on mode and throttle state.""" + def _draw_path(self): if not self._path.projected_points.size: return - allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control - self._blend_filter.update(int(allow_throttle)) - if ui_state.rainbow_path: self.rainbow_path.draw_rainbow_path(self._rect, self._path) return path_pts = self._path.projected_points + np.array([self._rect.x, self._rect.y], dtype=np.float32) - if self._experimental_mode: - # Draw with acceleration coloring - if ui_state.status == UIStatus.DISENGAGED: - draw_polygon(self._rect, path_pts, rl.Color(0, 0, 0, 90)) - elif len(self._exp_gradient.colors) > 1: - draw_polygon(self._rect, path_pts, gradient=self._exp_gradient) - else: - draw_polygon(self._rect, path_pts, rl.Color(255, 255, 255, 30)) + if ui_state.status == UIStatus.DISENGAGED: + draw_polygon(self._rect, path_pts, rl.Color(0, 0, 0, 90)) + elif not self._longitudinal_control: + draw_polygon(self._rect, path_pts, gradient=STOCK_PATH_GRADIENT) + elif len(self._path_gradient.colors) > 1: + draw_polygon(self._rect, path_pts, gradient=self._path_gradient) else: - # Blend throttle/no throttle colors based on transition - blend_factor = round(self._blend_filter.x * 100) / 100 - blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor) - gradient = Gradient( - start=(0.0, 1.0), # Bottom of path - end=(0.0, 0.0), # Top of path - colors=blended_colors, - stops=[0.0, 0.5, 1.0], - ) - - if ui_state.status == UIStatus.DISENGAGED: - draw_polygon(self._rect, path_pts, rl.Color(0, 0, 0, 90)) - else: - draw_polygon(self._rect, path_pts, gradient=gradient) + draw_polygon(self._rect, path_pts, rl.Color(255, 255, 255, 30)) def _draw_lead_indicator(self): - # Draw lead vehicles if available + offset = np.array([self._rect.x, self._rect.y], dtype=np.float32) for lead in self._lead_vehicles: - if not lead.glow or not lead.chevron: - continue - - rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255)) - rl.draw_triangle_fan(lead.chevron, len(lead.chevron), rl.Color(201, 34, 49, lead.fill_alpha)) + draw_polygon(self._rect, lead.bar + offset, rl.Color(255, 255, 255, int(255 * lead.fade_filter.x))) @staticmethod def _get_path_length_idx(pos_x_array: np.ndarray, path_height: float) -> int: @@ -391,22 +347,6 @@ class ModelRenderer(Widget, ModelRendererSP): indices = np.where(pos_x_array <= path_height)[0] return indices[-1] if indices.size > 0 else 0 - def _map_to_screen(self, in_x, in_y, in_z): - """Project a point in car space to screen space""" - input_pt = np.array([in_x, in_y, in_z]) - pt = self._car_space_transform @ input_pt - - if abs(pt[2]) < 1e-6: - return None - - x, y = pt[0] / pt[2], pt[1] / pt[2] - - clip = self._clip_region - if not (clip.x <= x <= clip.x + clip.width and clip.y <= y <= clip.y + clip.height): - return None - - return (x, y) - def _map_line_to_polygon(self, line: np.ndarray, y_off: float, z_off: float, max_idx: int, allow_invert: bool = True) -> np.ndarray: """Convert 3D line to 2D polygon for rendering.""" if line.shape[0] == 0: @@ -482,18 +422,3 @@ class ModelRenderer(Widget, ModelRendererSP): int(rgb[2] * 255), int(a * 255) ) - - @staticmethod - def _blend_colors(begin_colors, end_colors, t): - if t >= 1.0: - return end_colors - if t <= 0.0: - return begin_colors - - inv_t = 1.0 - t - return [rl.Color( - int(inv_t * start.r + t * end.r), - int(inv_t * start.g + t * end.g), - int(inv_t * start.b + t * end.b), - int(inv_t * start.a + t * end.a) - ) for start, end in zip(begin_colors, end_colors, strict=True)] diff --git a/openpilot/selfdrive/ui/mici/onroad/navigation.py b/openpilot/selfdrive/ui/mici/onroad/navigation.py new file mode 100644 index 0000000000..7be9845417 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/onroad/navigation.py @@ -0,0 +1,124 @@ +import numpy as np +import pyray as rl + +from msgq.visionipc import VisionIpcClient +from openpilot.cereal.visionipc import VisionStreamType +from openpilot.common.constants import CV +from openpilot.selfdrive.navigation.helpers import latest_gps_source +from openpilot.selfdrive.navigation.renderer import EGO, ROUTE_INTENSITY, SIZE +from openpilot.selfdrive.ui.mici.onroad.cameraview import CONNECTION_RETRY_INTERVAL +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import FontWeight, TextAlignmentVertical +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label + +PANEL_WIDTH = 261 +STALE_SECONDS = 3 +MARKER_MARGIN = 18 # pixels kept below the ego so its marker stays visible + +# Recolor the model's grayscale frame for people: dim the white roads and make the grey route pop. Only the route's +# exact level (and its neighbors) is recolored, so antialiased road edges passing through that grey stay grey. +MAP_COLORS = np.repeat(np.round(np.arange(256) * 0.45).astype(np.uint8)[:, None], 3, axis=1) +MAP_COLORS[ROUTE_INTENSITY - 1:ROUTE_INTENSITY + 2] = (72, 235, 132) + + +def format_distance(meters: float) -> str: + """m / ft under 1000 m / 300 m, km / mi beyond.""" + if ui_state.is_metric: + return f'{round(meters / 10) * 10} m' if meters < 1000 else f'{meters / 1000:.1f} km' + return f'{round(meters * CV.METER_TO_FOOT / 50) * 50} ft' if meters < 300 else f'{meters * CV.METER_TO_MILE:.1f} mi' + + +class NavigationPanel(Widget): + """Turn instructions around the map frames mapsd publishes for the model.""" + + def __init__(self): + super().__init__() + self._client = VisionIpcClient('mapsd', VisionStreamType.VISION_STREAM_MAP, True) + self._last_connect = 0.0 + self._frame_time = 0.0 + self._texture = None + self._eta_label = UnifiedLabel('', 34, FontWeight.BOLD, rl.WHITE, scroll=True) + self._accent = rl.Color(155, 230, 185, 255) + self._muted = rl.Color(162, 175, 185, 255) + + def _update_state(self): + if not self._client.is_connected(): + if rl.get_time() - self._last_connect < CONNECTION_RETRY_INTERVAL: + return + self._last_connect = rl.get_time() + if not self._client.connect(False) or not self._client.num_buffers: + return + self._unload_texture() + self._texture = rl.load_texture_from_image(rl.Image(None, SIZE, SIZE, 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8)) + frame = self._client.recv(timeout_ms=0) + if frame is not None: + gray = np.frombuffer(frame.data, dtype=np.uint8, count=SIZE * SIZE) + rl.update_texture(self._texture, rl.ffi.cast("void *", rl.ffi.from_buffer(MAP_COLORS[gray]))) + # Without a usable map, mapsd publishes black frames with a zero timestamp. + self._frame_time = rl.get_time() if self._client.timestamp_sof else 0.0 + + @property + def routing(self): + return ui_state.sm.valid['navInstruction'] and ui_state.sm.alive['navInstruction'] + + @property + def map_ready(self): + return self._texture is not None and 0 <= rl.get_time() - self._frame_time < STALE_SECONDS + + def _draw_status(self, rect): + """What the map is waiting on, in place of the map.""" + statuses = ( + ('GPS fix', latest_gps_source(ui_state.sm) is not None), + ('Route', self.routing), + ('Map frames', self.map_ready), + ) + for i, (name, ok) in enumerate(statuses): + gui_label(rl.Rectangle(rect.x + 10, rect.y + 12 + i * 28, rect.width - 20, 26), f'{name}: {"yes" if ok else "no"}', 22, + self._accent if ok else self._muted, FontWeight.MEDIUM, alignment_vertical=TextAlignmentVertical.TOP) + + def _render(self, rect): + x, y = rect.x, rect.y + rl.draw_rectangle_rec(rect, rl.Color(7, 13, 18, 255)) + instruction = ui_state.sm['navInstruction'] + if self.routing: + direction = instruction.maneuverModifier + flip = -1 if 'left' in direction else 1 + # The maneuver arrow on a 1.4x grid, centered in the header's icon box, with round joins for the thick stroke. + if direction in ('straight', ''): + shape = [(0, 14), (0, -12), (-6, -5), (0, -12), (6, -5)] + else: + shape = [(-flip*9, 14), (-flip*9, -4), (-flip*2, -9), (flip*9, -9), (flip*3, -15), (flip*9, -9), (flip*3, -3)] + points = [rl.Vector2(x + 31 + 1.4*px, y + 30 + 1.4*py) for px, py in shape] + for a, b in zip(points[:-1], points[1:], strict=True): + rl.draw_line_ex(a, b, 7, self._accent) + for point in points: + rl.draw_circle_v(point, 3.5, self._accent) + gui_label(rl.Rectangle(x+64, y+11, rect.width-72, 40), format_distance(max(0, instruction.maneuverDistance)), 34, rl.WHITE, FontWeight.BOLD, + alignment_vertical=TextAlignmentVertical.TOP) + else: + gui_label(rl.Rectangle(x+12, y+18, rect.width-24, 30), 'Waiting for route', 26, rl.WHITE, FontWeight.BOLD, + alignment_vertical=TextAlignmentVertical.TOP) + map_rect = rl.Rectangle(x+8, y+61, rect.width-16, rect.height-111) + rl.draw_rectangle_rec(map_rect, rl.BLACK) + if self.map_ready: + # A 1:1 crop of the model's frame, centered horizontally with the ego near the bottom. + width, height = min(map_rect.width, SIZE), min(map_rect.height, SIZE) + source = rl.Rectangle((SIZE - width) // 2, min(SIZE - height, max(0, EGO[1] + MARKER_MARGIN - height)), width, height) + target = rl.Rectangle(map_rect.x + (map_rect.width - width) / 2, map_rect.y + map_rect.height - height, width, height) + rl.draw_texture_pro(self._texture, source, target, rl.Vector2(0, 0), 0, rl.WHITE) + else: + self._draw_status(map_rect) + if self.routing: + seconds = max(0, instruction.timeRemaining) + remaining = max(0, instruction.distanceRemaining) + self._eta_label.set_text(f'{max(1, round(seconds / 60))} min / {format_distance(remaining)}') + self._eta_label.render(rl.Rectangle(x+9, y+rect.height-45, rect.width-18, 40)) + + def _unload_texture(self): + if self._texture: + rl.unload_texture(self._texture) + self._texture = None + + def close(self): + self._unload_texture() diff --git a/openpilot/selfdrive/ui/mici/onroad/torque_bar.py b/openpilot/selfdrive/ui/mici/onroad/torque_bar.py index c4ccafa0a3..f4a4aa3768 100644 --- a/openpilot/selfdrive/ui/mici/onroad/torque_bar.py +++ b/openpilot/selfdrive/ui/mici/onroad/torque_bar.py @@ -14,7 +14,7 @@ from openpilot.system.ui.widgets import Widget from openpilot.common.filter_simple import FirstOrderFilter # TODO: arc_bar_pts doesn't consider rounded end caps part of the angle span -TORQUE_ANGLE_SPAN = 12.7 +TORQUE_ANGLE_SPAN = 12.7 # degrees on the default 476 px camera view; narrower views beside the navigation panel scale down DEBUG = False @@ -208,7 +208,7 @@ class TorqueBar(Widget): # draw curved line polygon torque bar torque_line_radius = 1200 * self._scale top_angle = -90 - torque_bg_angle_span = self._torque_line_alpha_filter.x * TORQUE_ANGLE_SPAN + torque_bg_angle_span = self._torque_line_alpha_filter.x * TORQUE_ANGLE_SPAN * rect.width / 476 torque_start_angle = top_angle - torque_bg_angle_span / 2 torque_end_angle = top_angle + torque_bg_angle_span / 2 # centerline radius & center (you already have these values) diff --git a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py index c0f1ac1510..71530f9fd8 100755 --- a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py +++ b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py @@ -19,7 +19,7 @@ KNOWN_LEAKS = { "openpilot.system.ui.widgets.button.Button", "openpilot.system.ui.widgets.html_render.HtmlRenderer", "openpilot.system.ui.widgets.nav_widget.NavBar", - "openpilot.selfdrive.ui.mici.layouts.settings.device.MiciFccModal", + "openpilot.selfdrive.ui.mici.layouts.settings.device.device_layout.MiciFccModal", "openpilot.system.ui.widgets.inputbox.InputBox", "openpilot.system.ui.widgets.scroller_tici.Scroller", "openpilot.system.ui.widgets.label.UnifiedLabel", @@ -52,9 +52,9 @@ class TestWidgetLeaks(OpenpilotTestCase): # 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.layouts.settings.device.prime import PrimeScroller from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog - from openpilot.selfdrive.ui.mici.layouts.settings.device import MiciFccModal + from openpilot.selfdrive.ui.mici.layouts.settings.device.device_layout import MiciFccModal # tici dialogs from openpilot.selfdrive.ui.onroad.cabin_camera_dialog import CabinCameraDialog as TiciCabinCameraDialog @@ -71,7 +71,7 @@ class TestWidgetLeaks(OpenpilotTestCase): for ctor in ( # mici - MiciCabinCameraDialog, MiciPairingDialog, + MiciCabinCameraDialog, PrimeScroller, lambda: MiciTrainingGuide(lambda: None), lambda: MiciOnboardingWindow(lambda: None), lambda: BigDialog("test", "test"), diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index 59a3f95191..c481a7707b 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -29,9 +29,41 @@ class ScrollState(Enum): POST_SCROLL = 2 -class BigCircleButton(Widget): - def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0)): +class BaseButton(Widget): + def __init__(self, description: str, title: str, icon: Union[rl.Texture, None] = None): super().__init__() + self._shake_start: float | None = None + if description: + # Dialogs also use buttons; import lazily to avoid a circular import. + from openpilot.selfdrive.ui.mici.widgets.dialog import SettingDescriptionDialog + self.set_long_press_callback(lambda: gui_app.push_widget(SettingDescriptionDialog(title, description, icon))) + else: + self.set_long_press_callback(self.trigger_shake) + + def trigger_shake(self): + self._shake_start = rl.get_time() + + @property + def _shake_offset(self) -> float: + SHAKE_DURATION = 0.5 + SHAKE_AMPLITUDE = 24.0 + SHAKE_FREQUENCY = 32.0 + if self._shake_start is None: + return 0.0 + t = rl.get_time() - self._shake_start + if t > SHAKE_DURATION: + return 0.0 + decay = 1.0 - t / SHAKE_DURATION + return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY) + + def set_position(self, x: float, y: float) -> None: + super().set_position(x + self._shake_offset, y) + +class BigCircleButton(BaseButton): + def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0), + *, description: str = "", + description_icon: Union[rl.Texture, None] = None, title: str = ""): + super().__init__(description, title, description_icon or icon) self._red = red self._icon_offset = icon_offset @@ -73,8 +105,9 @@ class BigCircleButton(Widget): class BigCircleToggle(BigCircleButton): - def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)): - super().__init__(icon, False, icon_offset=icon_offset) + def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0), + *, description: str = "", description_icon: Union[rl.Texture, None] = None, title: str = ""): + super().__init__(icon, False, icon_offset=icon_offset, description=description, description_icon=description_icon, title=title) self._toggle_callback = toggle_callback # State @@ -103,14 +136,16 @@ class BigCircleToggle(BigCircleButton): 0, 1.0, rl.WHITE) -class BigButton(Widget): +class BigButton(BaseButton): LABEL_HORIZONTAL_PADDING = 40 LABEL_VERTICAL_PADDING = 23 # visually matches 30 in figma """A lightweight stand-in for the Qt BigButton, drawn & updated each frame.""" - def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False): - super().__init__() + def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False, + *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(description, text, description_icon or icon or None) self.set_rect(rl.Rectangle(0, 0, 402, 180)) self.text = text self.value = value @@ -119,7 +154,6 @@ class BigButton(Widget): self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) self._click_delay = 0.075 - self._shake_start: float | None = None self._grow_animation_until: float | None = None self._rotate_icon_t: float | None = None @@ -187,28 +221,9 @@ class BigButton(Widget): def get_text(self): return self.text - def trigger_shake(self): - self._shake_start = rl.get_time() - def trigger_grow_animation(self, duration: float = 0.65): self._grow_animation_until = rl.get_time() + duration - @property - def _shake_offset(self) -> float: - SHAKE_DURATION = 0.5 - SHAKE_AMPLITUDE = 24.0 - SHAKE_FREQUENCY = 32.0 - if self._shake_start is None: - return 0.0 - t = rl.get_time() - self._shake_start - if t > SHAKE_DURATION: - return 0.0 - decay = 1.0 - t / SHAKE_DURATION - return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY) - - def set_position(self, x: float, y: float) -> None: - super().set_position(x + self._shake_offset, y) - def _handle_background(self) -> tuple[rl.Texture, float, float, float]: if self._grow_animation_until is not None: if rl.get_time() >= self._grow_animation_until: @@ -226,6 +241,9 @@ class BigButton(Widget): btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2 return txt_bg, btn_x, btn_y, scale + def _draw_subtitle(self, rect: rl.Rectangle): + self._sub_label.render(rect) + def _draw_content(self, btn_y: float): # LABEL ------------------------------------------------------------------ label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING @@ -240,7 +258,7 @@ class BigButton(Widget): 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._subtitle_width_hint(), sub_label_height) - self._sub_label.render(sub_label_rect) + self._draw_subtitle(sub_label_rect) # ICON ------------------------------------------------------------------- if self._txt_icon: @@ -272,8 +290,10 @@ class BigButton(Widget): class BigToggle(BigButton): - def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None): - super().__init__(text, value, "") + def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None, + *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(text, value, "", description=description, description_icon=description_icon) self._checked = initial_state self._toggle_callback = toggle_callback @@ -308,8 +328,8 @@ class BigToggle(BigButton): class BigMultiToggle(BigToggle): def __init__(self, text: str, options: list[str], toggle_callback: Callable | None = None, - select_callback: Callable | None = None): - super().__init__(text, "", toggle_callback=toggle_callback) + select_callback: Callable | None = None, *, description: str = "", description_icon: Union[rl.Texture, None] = None): + super().__init__(text, "", toggle_callback=toggle_callback, description=description, description_icon=description_icon) assert len(options) > 0 self._options = options self._select_callback = select_callback @@ -374,9 +394,9 @@ 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): + select_callback: Callable | None = None, *, description: str = "", description_icon: Union[rl.Texture, None] = None): assert Params is not None - super().__init__(text, options, toggle_callback, select_callback) + super().__init__(text, options, toggle_callback, select_callback, description=description, description_icon=description_icon) self._param = param self._params = Params() @@ -392,9 +412,10 @@ class BigMultiParamToggle(BigMultiToggle): class BigParamControl(BigToggle): - def __init__(self, text: str, param: str, toggle_callback: Callable | None = None): + def __init__(self, text: str, param: str, toggle_callback: Callable | None = None, *, description: str = "", + description_icon: Union[rl.Texture, None] = None): assert Params is not None - super().__init__(text, "", toggle_callback=toggle_callback) + super().__init__(text, "", toggle_callback=toggle_callback, description=description, description_icon=description_icon) self.param = param self.params = Params() self.set_checked(self.params.get_bool(self.param, False)) @@ -410,9 +431,9 @@ class BigParamControl(BigToggle): # TODO: param control base class class BigCircleParamControl(BigCircleToggle): def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None, - icon_offset: tuple[int, int] = (0, 0)): + icon_offset: tuple[int, int] = (0, 0), *, description: str = "", description_icon: Union[rl.Texture, None] = None, title: str = ""): assert Params is not None - super().__init__(icon, toggle_callback, icon_offset=icon_offset) + super().__init__(icon, toggle_callback, icon_offset=icon_offset, description=description, description_icon=description_icon, title=title) self._param = param self.params = Params() self.set_checked(self.params.get_bool(self._param, False)) diff --git a/openpilot/selfdrive/ui/mici/widgets/dialog.py b/openpilot/selfdrive/ui/mici/widgets/dialog.py index 77dfa3cb97..80625aa451 100644 --- a/openpilot/selfdrive/ui/mici/widgets/dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/dialog.py @@ -1,9 +1,11 @@ import abc import math +import re import pyray as rl from typing import Union from collections.abc import Callable from openpilot.system.ui.widgets.nav_widget import NavWidget +from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.mici_keyboard import MiciKeyboard from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -37,6 +39,31 @@ class BigDialog(BigDialogBase): )) +class SettingDescriptionDialog(NavScroller): + def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None): + super().__init__() + cards = [GreyBigButton(title, "scroll for details", icon or gui_app.texture("icons_mici/setup/green_info.png", 64, 64))] + # Explicit lines are authored cards; otherwise prefer sentence boundaries. + paragraphs = description.splitlines() if "\n" in description else re.split(r"(?<=[.!?])\s+", description.strip()) + # Measure each card so longer text still fits with the actual font and padding. + for sentence in paragraphs: + card = GreyBigButton("", "") + words: list[str] = [] + for word in sentence.split(): + card.set_value(" ".join([*words, word])) + height = card._sub_label.get_content_height(card._subtitle_width_hint()) + if words and height > card.rect.height - 2 * card.LABEL_VERTICAL_PADDING: + card.set_value(" ".join(words)) + cards.append(card) + card = GreyBigButton("", "") + words = [] + words.append(word) + if words: + card.set_value(" ".join(words)) + cards.append(card) + self._scroller.add_widgets(cards) + + class BigConfirmationDialog(BigDialogBase): def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], exit_on_confirm: bool = True, red: bool = False): @@ -76,14 +103,15 @@ class BigInputDialog(BigDialogBase): default_text: str = "", minimum_length: int = 1, confirm_callback: Callable[[str], None] | None = None, - auto_return_to_letters: str = ""): + auto_return_to_letters: str = "", + text_validator: Callable[[str], bool] | None = None): super().__init__() self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)), font_weight=FontWeight.MEDIUM) self._keyboard = MiciKeyboard(auto_return_to_letters=auto_return_to_letters) self._keyboard.set_text(default_text) self._keyboard.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget - self._minimum_length = minimum_length + self._text_valid = lambda text: len(text) >= minimum_length and (text_validator is None or text_validator(text)) self._backspace_held_time: float | None = None @@ -100,7 +128,8 @@ class BigInputDialog(BigDialogBase): def confirm_callback_wrapper(): text = self._keyboard.text() - self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None) + if self._text_valid(text): + self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None) self._confirm_callback = confirm_callback_wrapper def _update_state(self): @@ -154,7 +183,7 @@ class BigInputDialog(BigDialogBase): # draw gradient on left side to indicate more text if text_size.x > text_field_rect.width: rl.draw_rectangle_gradient_ex(rl.Rectangle(text_field_rect.x, text_field_rect.y, 80, text_field_rect.height), - rl.BLACK, rl.BLANK, rl.BLANK, rl.BLACK) + rl.BLACK, rl.BLACK, rl.BLANK, rl.BLANK) # draw cursor blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2 @@ -185,7 +214,7 @@ class BigInputDialog(BigDialogBase): self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height) # draw enter button - self._enter_img_alpha.update(255 if len(text) >= self._minimum_length else 0) + self._enter_img_alpha.update(255 if self._text_valid(text) else 0) color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x)) rl.draw_texture_ex(self._enter_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y), 0.0, 1.0, color) color = rl.Color(255, 255, 255, 255 - int(self._enter_img_alpha.x)) diff --git a/openpilot/selfdrive/ui/mici/widgets/info.py b/openpilot/selfdrive/ui/mici/widgets/info.py new file mode 100644 index 0000000000..2d765cd6fd --- /dev/null +++ b/openpilot/selfdrive/ui/mici/widgets/info.py @@ -0,0 +1,31 @@ +import pyray as rl + +from openpilot.system.ui.lib.application import FontWeight +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel + + +class InfoLayoutMici(Widget): + def __init__(self, title1: str, subtext1: str, title2: str, subtext2: str, *, width: int = 360): + super().__init__() + self.set_rect(rl.Rectangle(0, 0, width, 180)) + + max_width = int(self._rect.width - 20) + self._subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) + + self.title1 = UnifiedLabel(title1, 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) + self.subtext1 = UnifiedLabel(subtext1, 36, max_width=max_width, text_color=self._subheader_color, + font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True) + self.title2 = UnifiedLabel(title2, 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) + self.subtext2 = UnifiedLabel(subtext2, 36, max_width=max_width, text_color=self._subheader_color, + font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True) + + def _layout(self): + self.title1.set_position(self._rect.x + 20, self._rect.y - 10) + self.subtext1.set_position(self._rect.x + 20, self._rect.y + 68 - 25) + self.title2.set_position(self._rect.x + 20, self._rect.y + 114 - 30) + self.subtext2.set_position(self._rect.x + 20, self._rect.y + 161 - 25) + + def _render(self, rect: rl.Rectangle): + for widget in [self.title1, self.subtext1, self.title2, self.subtext2]: + widget.render() diff --git a/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py b/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py deleted file mode 100644 index 7e05bc2fa0..0000000000 --- a/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py +++ /dev/null @@ -1,95 +0,0 @@ -import pyray as rl -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 -from openpilot.system.ui.widgets.nav_widget import NavWidget -from openpilot.system.ui.lib.application import FontWeight, gui_app -from openpilot.system.ui.widgets.label import UnifiedLabel - - -class PairingDialog(NavWidget): - """Dialog for device pairing with QR code.""" - - QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds - - def __init__(self): - super().__init__() - self._params = Params() - self._qr_texture: rl.Texture | None = None - self._last_qr_generation = float("-inf") - - self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 33, 60) - self._pair_label = UnifiedLabel("pair with comma connect", font_size=48, font_weight=FontWeight.BOLD, line_height=0.8) - - def _get_pairing_url(self) -> str: - try: - dongle_id = self._params.get("DongleId") or "" - token = Api(dongle_id).get_token({'pair': True}) - except Exception as e: - cloudlog.warning(f"Failed to get pairing token: {e}") - token = "" - return f"https://connect.comma.ai/?pair={token}" - - def _generate_qr_code(self) -> None: - try: - if self._qr_texture and self._qr_texture.id != 0: - rl.unload_texture(self._qr_texture) - 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 - - def _check_qr_refresh(self) -> None: - current_time = time.monotonic() - if current_time - self._last_qr_generation >= self.QR_REFRESH_INTERVAL: - self._generate_qr_code() - self._last_qr_generation = current_time - - def _update_state(self): - super()._update_state() - if ui_state.prime_state.is_paired() and not self.is_dismissing: - self.dismiss() - - def _render(self, rect: rl.Rectangle): - self._check_qr_refresh() - - self._render_qr_code() - - label_x = self._rect.x + 8 + self._rect.height + 24 - self._pair_label.set_max_width(int(self._rect.width - label_x)) - self._pair_label.set_position(label_x, self._rect.y + 16) - self._pair_label.render() - - rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16), - 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35))) - - def _render_qr_code(self) -> None: - if not self._qr_texture: - error_font = gui_app.font(FontWeight.BOLD) - rl.draw_text_ex( - error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED - ) - return - - scale = self._rect.height / self._qr_texture.height - pos = rl.Vector2(round(self._rect.x + 8), round(self._rect.y)) - rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE) - - def __del__(self): - if self._qr_texture and self._qr_texture.id != 0: - rl.unload_texture(self._qr_texture) - - -if __name__ == "__main__": - gui_app.init_window("pairing device") - pairing = PairingDialog() - gui_app.push_widget(pairing) - try: - for _ in gui_app.render(): - pass - finally: - del pairing diff --git a/openpilot/selfdrive/ui/mici/widgets/qr.py b/openpilot/selfdrive/ui/mici/widgets/qr.py new file mode 100644 index 0000000000..f0ff0d0ed9 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/widgets/qr.py @@ -0,0 +1,37 @@ +import pyray as rl + +from openpilot.common.swaglog import cloudlog +from openpilot.system.ui.lib.application import FontWeight +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import Label +from openpilot.common.qrcode import make_texture + + +class QR(Widget): + def __init__(self, url: str, width: int = 170): + super().__init__() + self._url = url + + self._texture = self._generate_qr_code() + + self.set_rect(rl.Rectangle(0, 0, width, width)) + self._error = Label("QR Code Error", font_size=30, font_weight=FontWeight.BOLD, text_color=rl.RED) + + def _generate_qr_code(self): + try: + return make_texture(self._url, inverted=True) + except Exception as e: + cloudlog.warning(f"QR code generation failed: {e}") + return None + + def _render(self, rect: rl.Rectangle): + if not self._texture: + self._error.render(rect) + return + scale = rect.height / self._texture.height + pos = rl.Vector2(round(rect.x), round(rect.y)) + rl.draw_texture_ex(self._texture, pos, 0.0, scale, rl.WHITE) + + def __del__(self): + if self._texture and self._texture.id != 0: + rl.unload_texture(self._texture) diff --git a/openpilot/selfdrive/ui/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/onroad/augmented_road_view.py index 70b75d13bd..0a8779487f 100644 --- a/openpilot/selfdrive/ui/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/onroad/augmented_road_view.py @@ -33,8 +33,8 @@ BORDER_COLORS = { **BORDER_COLORS_SP, } -WIDE_CAM_MAX_SPEED = 10.0 # m/s (22 mph) -ROAD_CAM_MIN_SPEED = 15.0 # m/s (34 mph) +WIDE_CAM_MAX_SPEED = 5.0 # m/s (11 mph) +ROAD_CAM_MIN_SPEED = 10.0 # m/s (22 mph) INF_POINT = np.array([1000.0, 0.0, 0.0]) @@ -104,11 +104,11 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewSP): self._draw_border(rect) def _handle_mouse_press(self, _): - if not self._hud_renderer.user_interacting() and self._click_callback is not None: + if self._click_callback is not None: self._click_callback() def _handle_mouse_release(self, _): - # We only call click callback on press if not interacting with HUD + # We only call click callback on press pass def _draw_border(self, rect: rl.Rectangle): @@ -120,7 +120,7 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewSP): rl.draw_rectangle_rounded_lines_ex(border_rect, border_roundness, 10, UI_BORDER_SIZE, border_color) def _switch_stream_if_needed(self, sm): - if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams: + if sm['carParams'].openpilotLongitudinalControl and WIDE_CAM in self.available_streams: v_ego = sm['carState'].vEgo if v_ego < WIDE_CAM_MAX_SPEED: target = WIDE_CAM diff --git a/openpilot/selfdrive/ui/onroad/exp_button.py b/openpilot/selfdrive/ui/onroad/exp_button.py deleted file mode 100644 index 9a92ebc3c3..0000000000 --- a/openpilot/selfdrive/ui/onroad/exp_button.py +++ /dev/null @@ -1,70 +0,0 @@ -import time -import pyray as rl -from openpilot.common.params import Params -from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.widgets import Widget - - -class ExpButton(Widget): - def __init__(self, button_size: int, icon_size: int): - super().__init__() - self._params = Params() - self._experimental_mode: bool = False - self._engageable: bool = False - - # State hold mechanism - self._hold_duration = 2.0 # seconds - self._held_mode: bool | None = None - self._hold_end_time: float | None = None - - self._white_color: rl.Color = rl.Color(255, 255, 255, 255) - self._black_bg: rl.Color = rl.Color(0, 0, 0, 166) - self._txt_wheel: rl.Texture = gui_app.texture('icons/chffr_wheel.png', icon_size, icon_size) - self._txt_exp: rl.Texture = gui_app.texture('icons/experimental.png', icon_size, icon_size) - self._rect = rl.Rectangle(0, 0, button_size, button_size) - - def set_rect(self, rect: rl.Rectangle) -> None: - self._rect.x, self._rect.y = rect.x, rect.y - - def _update_state(self) -> None: - selfdrive_state = ui_state.sm["selfdriveState"] - self._experimental_mode = selfdrive_state.experimentalMode - self._engageable = selfdrive_state.engageable or selfdrive_state.enabled - - def _handle_mouse_release(self, _): - super()._handle_mouse_release(_) - if self._is_toggle_allowed(): - new_mode = not self._experimental_mode - self._params.put_bool("ExperimentalMode", new_mode) - - # Hold new state temporarily - self._held_mode = new_mode - self._hold_end_time = time.monotonic() + self._hold_duration - - def _render(self, rect: rl.Rectangle) -> None: - center_x = int(self._rect.x + self._rect.width // 2) - center_y = int(self._rect.y + self._rect.height // 2) - - self._white_color.a = 180 if self.is_pressed or not self._engageable else 255 - - texture = self._txt_exp if self._held_or_actual_mode() else self._txt_wheel - rl.draw_circle(center_x, center_y, self._rect.width / 2, self._black_bg) - rl.draw_texture_ex(texture, rl.Vector2(center_x - texture.width / 2, center_y - texture.height / 2), 0.0, 1.0, self._white_color) - - def _held_or_actual_mode(self): - now = time.monotonic() - if self._hold_end_time and now < self._hold_end_time: - return self._held_mode - - if self._hold_end_time and now >= self._hold_end_time: - self._hold_end_time = self._held_mode = None - - return self._experimental_mode - - def _is_toggle_allowed(self): - if not self._params.get_bool("ExperimentalModeConfirmed"): - return False - - # Mirror exp mode toggle using persistent car params - return ui_state.has_longitudinal_control diff --git a/openpilot/selfdrive/ui/onroad/hud_renderer.py b/openpilot/selfdrive/ui/onroad/hud_renderer.py index 73df8b3961..ccc20c0aab 100644 --- a/openpilot/selfdrive/ui/onroad/hud_renderer.py +++ b/openpilot/selfdrive/ui/onroad/hud_renderer.py @@ -1,7 +1,6 @@ import pyray as rl from dataclasses import dataclass from openpilot.common.constants import CV -from openpilot.selfdrive.ui.onroad.exp_button import ExpButton from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr @@ -17,12 +16,9 @@ CRUISE_DISABLED_CHAR = '–' @dataclass(frozen=True) class UIConfig: header_height: int = 300 - border_size: int = 30 - button_size: int = 192 set_speed_width_metric: int = 200 set_speed_width_imperial: int = 172 set_speed_height: int = 204 - wheel_icon_size: int = 144 @dataclass(frozen=True) @@ -70,8 +66,6 @@ class HudRenderer(Widget): self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD) self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM) - self._exp_button: ExpButton = ExpButton(UI_CONFIG.button_size, UI_CONFIG.wheel_icon_size) - def _update_state(self) -> None: """Update HUD state based on car state and controls state.""" sm = ui_state.sm @@ -117,13 +111,6 @@ class HudRenderer(Widget): self._draw_current_speed(rect) - button_x = rect.x + rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size - button_y = rect.y + UI_CONFIG.border_size - self._exp_button.render(rl.Rectangle(button_x, button_y, UI_CONFIG.button_size, UI_CONFIG.button_size)) - - def user_interacting(self) -> bool: - return self._exp_button.is_pressed - def _draw_set_speed(self, rect: rl.Rectangle) -> None: """Draw the MAX speed indicator box.""" set_speed_width = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial diff --git a/openpilot/selfdrive/ui/onroad/model_renderer.py b/openpilot/selfdrive/ui/onroad/model_renderer.py index f5a39a2a5b..6db7d348a2 100644 --- a/openpilot/selfdrive/ui/onroad/model_renderer.py +++ b/openpilot/selfdrive/ui/onroad/model_renderer.py @@ -4,11 +4,9 @@ import pyray as rl from openpilot.cereal import messaging from opendbc.car.structs import car from dataclasses import dataclass, field -from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient from openpilot.system.ui.widgets import Widget @@ -18,17 +16,12 @@ CLIP_MARGIN = 500 MIN_DRAW_DISTANCE = 10.0 MAX_DRAW_DISTANCE = 100.0 -THROTTLE_COLORS = [ - rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4) - rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35) - rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0) -] - -NO_THROTTLE_COLORS = [ - rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4) - rl.Color(242, 242, 242, 89), # HSLF(112/360, 0.0, 0.95, 0.35) - rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0) -] +STOCK_PATH_GRADIENT = Gradient( + start=(0.0, 1.0), + end=(0.0, 0.0), + colors=[rl.Color(13, 248, 122, 102), rl.Color(114, 255, 92, 89), rl.Color(114, 255, 92, 0)], + stops=[0.0, 0.5, 1.0], +) @dataclass @@ -50,9 +43,6 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): ChevronMetrics.__init__(self) ModelRendererSP.__init__(self) self._longitudinal_control = False - self._experimental_mode = False - self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps) - self._prev_allow_throttle = True self._lane_line_probs = np.zeros(4, dtype=np.float32) self._road_edge_stds = np.zeros(2, dtype=np.float32) self._lead_vehicles = [LeadVehicle(), LeadVehicle()] @@ -70,7 +60,7 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): self._transform_dirty = True self._clip_region = None - self._exp_gradient = Gradient( + self._path_gradient = Gradient( start=(0.0, 1.0), # Bottom of path end=(0.0, 0.0), # Top of path colors=[], @@ -100,8 +90,6 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): ) # Update state - self._experimental_mode = sm['selfdriveState'].experimentalMode - extrinsics_calibration = sm['extrinsicsCalibration'] self._path_offset_z = extrinsics_calibration.height[0] if extrinsics_calibration.height else HEIGHT_INIT[0] @@ -134,7 +122,7 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): # Draw elements self._draw_lane_lines() - self._draw_path(sm) + self._draw_path() if render_lead_indicator and radar_state: self._draw_lead_indicator() @@ -195,11 +183,10 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): self._path.raw_points, self._get_path_half_width(), self._path_offset_z, max_idx, max_distance, allow_invert=False ) - self._update_experimental_gradient() + self._update_path_gradient() - def _update_experimental_gradient(self): - """Pre-calculate experimental mode gradient colors""" - if not self._experimental_mode: + def _update_path_gradient(self): + if not self._longitudinal_control: return max_len = min(len(self._path.projected_points) // 2, len(self._acceleration_x)) @@ -235,7 +222,7 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): i += 1 + (1 if (i + 2) < max_len else 0) # Store the gradient in the path object - self._exp_gradient = Gradient( + self._path_gradient = Gradient( start=(0.0, 1.0), # Bottom of path end=(0.0, 0.0), # Top of path colors=segment_colors, @@ -284,35 +271,20 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): color = rl.Color(255, 0, 0, int(alpha * 255)) draw_polygon(self._rect, road_edge.projected_points, color) - def _draw_path(self, sm): - """Draw path with dynamic coloring based on mode and throttle state.""" + def _draw_path(self): if not self._path.projected_points.size: return - allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control - self._blend_filter.update(int(allow_throttle)) - if ui_state.rainbow_path and self._lateral_active: self.rainbow_path.draw_rainbow_path(self._rect, self._path) return - if self._experimental_mode: - # Draw with acceleration coloring - if len(self._exp_gradient.colors) > 1: - draw_polygon(self._rect, self._path.projected_points, gradient=self._exp_gradient) - else: - draw_polygon(self._rect, self._path.projected_points, rl.Color(255, 255, 255, 30)) + if not self._longitudinal_control: + draw_polygon(self._rect, self._path.projected_points, gradient=STOCK_PATH_GRADIENT) + elif len(self._path_gradient.colors) > 1: + draw_polygon(self._rect, self._path.projected_points, gradient=self._path_gradient) else: - # Blend throttle/no throttle colors based on transition - blend_factor = round(self._blend_filter.x * 100) / 100 - blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor) - gradient = Gradient( - start=(0.0, 1.0), # Bottom of path - end=(0.0, 0.0), # Top of path - colors=blended_colors, - stops=[0.0, 0.5, 1.0], - ) - draw_polygon(self._rect, self._path.projected_points, gradient=gradient) + draw_polygon(self._rect, self._path.projected_points, rl.Color(255, 255, 255, 30)) def _draw_lead_indicator(self): # Draw lead vehicles if available @@ -433,18 +405,3 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): int(rgb[2] * 255), int(a * 255) ) - - @staticmethod - def _blend_colors(begin_colors, end_colors, t): - if t >= 1.0: - return end_colors - if t <= 0.0: - return begin_colors - - inv_t = 1.0 - t - return [rl.Color( - int(inv_t * start.r + t * end.r), - int(inv_t * start.g + t * end.g), - int(inv_t * start.b + t * end.b), - int(inv_t * start.a + t * end.a) - ) for start, end in zip(begin_colors, end_colors, strict=True)] diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py index 671174ac7a..61254ce624 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py @@ -82,14 +82,8 @@ class CruiseLayout(Widget): callback=lambda: self._set_current_panel(PanelType.SLA) ) - self.dec_toggle = toggle_item_sp( - title=tr("Enable Dynamic Experimental Control"), - description=tr("Enable toggle to allow the model to determine when to use sunnypilot ACC or sunnypilot End to End Longitudinal."), - param="DynamicExperimentalControl") - items = [ self.icbm_toggle, - self.dec_toggle, self.scc_v_toggle, self.scc_m_toggle, self.custom_acc_toggle, @@ -144,16 +138,13 @@ class CruiseLayout(Widget): if has_long or has_icbm: self.custom_acc_toggle.action_item.set_enabled(((has_long and not ui_state.CP.pcmCruise) or has_icbm) and ui_state.is_offroad()) - self.dec_toggle.action_item.set_enabled(has_long) self.scc_v_toggle.action_item.set_enabled(True) self.scc_m_toggle.action_item.set_enabled(True) else: ui_state.params.remove("CustomAccIncrementsEnabled") - ui_state.params.remove("DynamicExperimentalControl") ui_state.params.remove("SmartCruiseControlVision") ui_state.params.remove("SmartCruiseControlMap") self.custom_acc_toggle.action_item.set_enabled(False) - self.dec_toggle.action_item.set_enabled(False) self.scc_v_toggle.action_item.set_enabled(False) self.scc_m_toggle.action_item.set_enabled(False) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/initial_cruise_speed.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/initial_cruise_speed.py new file mode 100644 index 0000000000..6315b0433f --- /dev/null +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/initial_cruise_speed.py @@ -0,0 +1,38 @@ +""" +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.layouts.settings.initial_cruise_speed import get_speed_options, set_initial_cruise_speed +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 ListItemSP +from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP + + +class InitialCruiseSpeedControlSP(OptionControlSP): + """Upstream's initial max speed stepper in the sunnypilot option style. Indexes the upstream option list, + which is in display units; the param stays in km/h.""" + def __init__(self): + self._options, idx = get_speed_options() + super().__init__("InitialCruiseSpeedKph", 0, len(self._options) - 1) + self.current_value = idx + + def refresh(self): + self._options, self.current_value = get_speed_options() + self.max_value = len(self._options) - 1 + + def set_value(self, value: int): + if not (self.min_value <= value <= self.max_value) or value == self.current_value: + return + self.current_value = value + set_initial_cruise_speed(self._options[value]) + + def get_displayed_value(self) -> str: + return f"{self._options[self.current_value]} " + (tr("km/h") if ui_state.is_metric else tr("mph")) + + +def initial_cruise_speed_item_sp(**kwargs) -> ListItemSP: + """Stock row in the sunnypilot option layout: title on top, control below, like option_item_sp.""" + return ListItemSP(inline=False, **kwargs) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index d036d8d281..22cc818ff4 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -235,13 +235,17 @@ class ModelsLayout(Widget): progress = sum(p.progress for p in progresses) / len(progresses) ds = custom.ModelManagerSP.DownloadStatus + if any(p.canceling for p in progresses): + return {"name": name, "downloading": True, "progress": progress, "status_text": tr("canceling...")} 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} + speed = max((p.speed for p in progresses), default=0.0) + status_text = f"{speed / 1024 ** 2:.1f} MB/s" if speed > 0 else "--.- MB/s" + return {"name": name, "downloading": True, "progress": progress, "status_text": status_text} 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 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 82419a5567..1c2c191157 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 @@ -7,6 +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.interfaces import has_bsm 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, option_item_sp, LineSeparatorSP @@ -82,7 +83,7 @@ class LaneChangeSettingsLayout(Widget): self._scroller.show_event() def _update_toggles(self): - enable_bsm = ui_state.CP is not None and ui_state.CP.enableBsm + enable_bsm = ui_state.CP is not None and has_bsm(ui_state.CP, ui_state.CP_SP) if not enable_bsm and ui_state.params.get_bool("AutoLaneChangeBsmDelay"): ui_state.params.remove("AutoLaneChangeBsmDelay") self._bsm_delay.action_item.set_enabled(enable_bsm and ui_state.params.get("AutoLaneChangeTimer", return_default=True) > AutoLaneChangeMode.NUDGE) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py index aab935d4b8..0c3ac340e6 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py @@ -92,7 +92,7 @@ class PlatformSelector(Button): def _on_platform_selected(self, dialog, res): if res == DialogResult.CONFIRM and dialog.selection_ref: - offroad_msg = tr("This setting will take effect immediately.") if ui_state.is_offroad else \ + offroad_msg = tr("This setting will take effect immediately.") if ui_state.is_offroad() else \ tr("This setting will take effect once the device enters offroad state.") callback = partial(self._confirm_platform, dialog.selection_ref) diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py index 404ff2e305..c1f1f09155 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -251,11 +251,13 @@ class ModelsLayoutMici(NavScroller): self.current_model_info.info_text.set_text(tr("download failed")) elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading: - self.cancel_download_btn.set_visible(True) + self.cancel_download_btn.set_visible(ui_state.params.get("ModelManager_DownloadRef") is not None) device.set_override_interactive_timeout(5) progress = 0.0 count = 0 verifying = False + canceling = False + speed = 0.0 for model in manager.selectedBundle.models: count += 1 p = model.artifact.downloadProgress @@ -263,11 +265,14 @@ class ModelsLayoutMici(NavScroller): custom.ModelManagerSP.DownloadStatus.verifying): progress += p.progress verifying = verifying or p.status == custom.ModelManagerSP.DownloadStatus.verifying + canceling = canceling or p.canceling + speed = max(speed, p.speed) # only one file downloads at a time, so the non-zero speed is the live one 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("verifying") if verifying else tr("downloading")) + header = tr("canceling...") if canceling else tr("verifying") if verifying else tr("downloading") + self.current_model_info.current_model_header.set_text(header) self.cancel_download_btn.set_text(tr("cancel verification") if verifying else tr("cancel download")) self.current_model_info.current_model_header._shimmer = True name_text = manager.selectedBundle.internalName.lower() @@ -276,7 +281,10 @@ class ModelsLayoutMici(NavScroller): 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}%") + progress_text = f"{progress/count:.2f}%" + if not verifying and not canceling: + progress_text += f" | {speed / 1024 ** 2:.1f} MB/s" if speed > 0 else " | --.- MB/s" + self.current_model_info.info_text.set_text(progress_text) elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloaded: self.current_model_info.info_header.set_text(tr("downloaded")) diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py index 4c0eba41ea..d38e7596fa 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py @@ -6,7 +6,7 @@ See the LICENSE.md file in the root directory for more details. """ from openpilot.selfdrive.ui.mici.layouts.settings import settings as OP from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsBigButton -from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici +from openpilot.selfdrive.ui.mici.layouts.settings.device.device_layout import DeviceLayoutMici from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py b/openpilot/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py index 9d39d01727..83b619cc9a 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py @@ -8,17 +8,24 @@ import pyray as rl from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer from openpilot.selfdrive.ui.sunnypilot.onroad.blind_spot_indicators import BlindSpotIndicators +from openpilot.selfdrive.ui.ui_state import ui_state class HudRendererSP(HudRenderer): def __init__(self): super().__init__() self.blind_spot_indicators = BlindSpotIndicators() + self._sp_engaged: bool = False def _update_state(self) -> None: super()._update_state() self.blind_spot_indicators.update() + sp_engaged = ui_state.engaged + if sp_engaged != self._sp_engaged: + self._chestnut_fade_time = rl.get_time() if sp_engaged else 0 + self._sp_engaged = sp_engaged + def _render(self, rect: rl.Rectangle) -> None: super()._render(rect) self.blind_spot_indicators.render(rect) diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py b/openpilot/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py index c727e4fc1c..0a53763a65 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py @@ -5,10 +5,12 @@ 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 base64 +import time import pyray as rl +from openpilot.common.params import Params +from openpilot.common.qrcode import make_texture from openpilot.common.swaglog import cloudlog -from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID, API_HOST from openpilot.system.ui.lib.application import FontWeight, gui_app from openpilot.system.ui.lib.multilang import tr @@ -16,11 +18,17 @@ from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.system.ui.widgets.label import UnifiedLabel -class SunnylinkPairingDialog(PairingDialog): +class SunnylinkPairingDialog(NavWidget): """Dialog for device pairing with QR code.""" + QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds + def __init__(self, sponsor_pairing: bool = False): - PairingDialog.__init__(self) + super().__init__() + self._params = Params() + self._qr_texture: rl.Texture | None = None + self._last_qr_generation = float("-inf") + self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 33, 60) self._sponsor_pairing = sponsor_pairing label_text = tr("pair with sunnylink") if sponsor_pairing else tr("become a sunnypilot sponsor") self._pair_label = UnifiedLabel(label_text, font_size=48, font_weight=FontWeight.BOLD, @@ -41,8 +49,49 @@ class SunnylinkPairingDialog(PairingDialog): return qr_string - def _update_state(self): - NavWidget._update_state(self) + def _generate_qr_code(self) -> None: + try: + if self._qr_texture and self._qr_texture.id != 0: + rl.unload_texture(self._qr_texture) + 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 + + def _check_qr_refresh(self) -> None: + current_time = time.monotonic() + if current_time - self._last_qr_generation >= self.QR_REFRESH_INTERVAL: + self._generate_qr_code() + self._last_qr_generation = current_time + + def _render(self, rect: rl.Rectangle): + self._check_qr_refresh() + + self._render_qr_code() + + label_x = self._rect.x + 8 + self._rect.height + 24 + self._pair_label.set_max_width(int(self._rect.width - label_x)) + self._pair_label.set_position(label_x, self._rect.y + 16) + self._pair_label.render() + + rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16), + 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35))) + + def _render_qr_code(self) -> None: + if not self._qr_texture: + error_font = gui_app.font(FontWeight.BOLD) + rl.draw_text_ex( + error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED + ) + return + + scale = self._rect.height / self._qr_texture.height + pos = rl.Vector2(round(self._rect.x + 8), round(self._rect.y)) + rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE) + + def __del__(self): + if self._qr_texture and self._qr_texture.id != 0: + rl.unload_texture(self._qr_texture) if __name__ == "__main__": diff --git a/openpilot/selfdrive/ui/sunnypilot/ui_state.py b/openpilot/selfdrive/ui/sunnypilot/ui_state.py index d130c3862a..e5ddd70400 100644 --- a/openpilot/selfdrive/ui/sunnypilot/ui_state.py +++ b/openpilot/selfdrive/ui/sunnypilot/ui_state.py @@ -8,6 +8,7 @@ from enum import Enum from openpilot.cereal import messaging, log, custom from opendbc.car.structs import car +from opendbc.sunnypilot.car.interfaces import has_bsm 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 @@ -35,7 +36,7 @@ class UIStateSP: self.is_sp_release: bool = self.params.get_bool("IsReleaseSpBranch") self.sm_services_ext = [ "modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP", - "gpsLocation", "lateralTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "lateralDelay" + "lateralTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "lateralDelay" ] self.sunnylink_state = SunnylinkState() @@ -212,7 +213,7 @@ class UIStateSP: self.params.remove("AlphaLongitudinalEnabled") # BSM not available: clear BSM-dependent settings - if not CP.enableBsm: + if not has_bsm(CP, self.CP_SP): self.params.remove("AutoLaneChangeBsmDelay") else: # No CarParams: clear all car-dependent params as safety default @@ -221,11 +222,6 @@ class UIStateSP: self.params.remove("LateralJerkTorqueController") self.params.remove("AlphaLongitudinalEnabled") - # No longitudinal control: no experimental mode or DEC - if not has_long: - self.params.remove("ExperimentalMode") - self.params.remove("DynamicExperimentalControl") - # ICBM: clear if not available or if full longitudinal control is active if self.CP_SP is not None: if not self.CP_SP.intelligentCruiseButtonManagementAvailable or has_long: diff --git a/openpilot/selfdrive/ui/tests/diff/replay_script.py b/openpilot/selfdrive/ui/tests/diff/replay_script.py index fb654c5e8b..4211a49ba9 100644 --- a/openpilot/selfdrive/ui/tests/diff/replay_script.py +++ b/openpilot/selfdrive/ui/tests/diff/replay_script.py @@ -481,7 +481,6 @@ def build_tizi_script(pm: PubMaster, main_layout, script: Script) -> None: # === Settings - Toggles === script.click(278, 600) - script.click(1200, 280) # expand experimental mode description # === Settings - Software === script.setup(lambda: setup_update_available(False), wait_after=0) # start with no update available diff --git a/openpilot/selfdrive/ui/translations/app.pot b/openpilot/selfdrive/ui/translations/app.pot index 37a80ff223..e6686fea7a 100644 --- a/openpilot/selfdrive/ui/translations/app.pot +++ b/openpilot/selfdrive/ui/translations/app.pot @@ -54,6 +54,9 @@ msgid "Cancel" msgstr "" #: system/ui/widgets/network.py +#: openpilot/system/ui/widgets/option_dialog.py +msgid "Select" +msgstr "" msgid "Advanced" msgstr "" @@ -185,6 +188,22 @@ msgstr "" msgid "Select" msgstr "" +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Pair your device to your comma account" +msgstr "" +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Go to https://connect.comma.ai on your phone" +msgstr "" +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Click \"add new device\" and scan the QR code on the right" +msgstr "" +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Bookmark connect.comma.ai to your home screen to use it like an app" +msgstr "" +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "QR Code Error" +msgstr "" + #: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "LOADING" msgstr "" @@ -320,6 +339,41 @@ msgstr "" #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "QR Code Error" +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "Close" +msgstr "" +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "Snooze Update" +msgstr "" +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "Acknowledge Excessive Actuation" +msgstr "" +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "Reboot and Update" +msgstr "" +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "No release notes available." +msgstr "" +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +msgstr "" +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." +msgstr "" +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Finish Setup" +msgstr "" +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair device" +msgstr "" +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Open" +msgstr "" +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Firehose Mode" +msgstr "" +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "" #: openpilot/selfdrive/ui/layouts/home.py @@ -406,6 +460,11 @@ msgstr "" #: openpilot/selfdrive/ui/layouts/onboarding.py msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." + +msgid "Welcome to openpilot" +msgstr "" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." msgstr "" #: openpilot/selfdrive/ui/layouts/onboarding.py @@ -446,6 +505,122 @@ msgstr "" #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" + +msgid "You must accept the Terms and Conditions in order to use openpilot." +msgstr "" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall openpilot" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Enable ADB" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Enable SSH" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "SSH Keys" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Joystick Debug Mode" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Longitudinal Maneuver Mode" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "openpilot Longitudinal Control (Alpha)" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Enable" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "checking..." +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "downloading..." +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "finalizing update..." +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "never" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "now" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "CHECK" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Are you sure you want to uninstall?" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Uninstall" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Select a branch" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "{} minute ago" +msgid_plural "{} minutes ago" +msgstr[0] "" +msgstr[1] "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "{} hour ago" +msgid_plural "{} hours ago" +msgstr[0] "" +msgstr[1] "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "{} day ago" +msgid_plural "{} days ago" +msgstr[0] "" +msgstr[1] "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Updates are only downloaded while the car is off." +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Current Version" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Download" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Install Update" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "INSTALL" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Target Branch" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "SELECT" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "failed to check for update" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "UNINSTALL" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "update available" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "DOWNLOAD" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "up to date, last checked never" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "up to date, last checked {}" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Preview the cabin camera to ensure that driver monitoring has good visibility. (vehicle must be off)" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/device.py @@ -653,6 +828,38 @@ msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Device" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Network" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Toggles" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Software" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Firehose" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Developer" +msgstr "" +#: openpilot/selfdrive/ui/onroad/hud_renderer.py +msgid "km/h" +msgstr "" +#: openpilot/selfdrive/ui/onroad/hud_renderer.py +msgid "mph" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage openpilot." +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when openpilot is not engaged." +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the cabin camera and help improve the driver monitoring algorithm." msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -681,6 +888,14 @@ msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Experimental Mode" + +msgid "Following Distance" +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart openpilot if the car is powered on." +msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable openpilot" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -697,6 +912,8 @@ msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Driver Camera" + +msgid "Record and Upload Cabin Camera" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -709,6 +926,8 @@ msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "sunnypilot longitudinal control may come in a future update." + +msgid "Initial Max Speed" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -831,6 +1050,8 @@ msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked {}" +#: openpilot/selfdrive/ui/onroad/hud_renderer.py +msgid "MAX" msgstr "" #: openpilot/selfdrive/ui/onroad/alert_renderer.py diff --git a/openpilot/selfdrive/ui/translations/app_de.po b/openpilot/selfdrive/ui/translations/app_de.po index b4af3fcc1d..e0cb0fd3f8 100644 --- a/openpilot/selfdrive/ui/translations/app_de.po +++ b/openpilot/selfdrive/ui/translations/app_de.po @@ -113,10 +113,6 @@ msgstr "ÄNDERN" msgid "CHECK" msgstr "PRÜFEN" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "CHILL‑MODUS AKTIV" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: system/ui/widgets/network.py msgid "CONNECT" @@ -238,7 +234,6 @@ msgid "Early Access: Become a sunnypilot Sponsor" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Aktivieren" @@ -331,6 +326,9 @@ msgid "Follow the prompts to complete the pairing process" msgstr "" #: system/ui/widgets/network.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" msgid "Forget" msgstr "Vergessen" @@ -364,6 +362,8 @@ msgstr "IP‑Adresse" #: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py @@ -765,6 +765,7 @@ msgstr "Update wird finalisiert..." msgid "for \"{}\"" msgstr "für „{}“" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -781,6 +782,7 @@ msgstr "links" msgid "metered" msgstr "getaktet" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -793,6 +795,16 @@ msgstr "nie" msgid "now" msgstr "jetzt" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "openpilot Longitudinal Control (Alpha)" +msgstr "openpilot Längsregelung (Alpha)" +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "openpilot Unavailable" +msgstr "openpilot nicht verfügbar" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "openpilot erfordert, dass das Gerät innerhalb von 4° nach links oder rechts und innerhalb von 5° nach oben oder 9° nach unten montiert ist." + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "rechts" diff --git a/openpilot/selfdrive/ui/translations/app_en.po b/openpilot/selfdrive/ui/translations/app_en.po index ad2ed1241f..a6c0cdf50b 100644 --- a/openpilot/selfdrive/ui/translations/app_en.po +++ b/openpilot/selfdrive/ui/translations/app_en.po @@ -113,10 +113,6 @@ msgstr "CHANGE" msgid "CHECK" msgstr "CHECK" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "CHILL MODE ON" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: system/ui/widgets/network.py msgid "CONNECT" @@ -238,7 +234,6 @@ msgid "Early Access: Become a sunnypilot Sponsor" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Enable" @@ -331,6 +326,9 @@ msgid "Follow the prompts to complete the pairing process" msgstr "" #: system/ui/widgets/network.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "Following Distance" msgid "Forget" msgstr "Forget" @@ -364,6 +362,8 @@ msgstr "IP Address" #: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py @@ -765,6 +765,7 @@ msgstr "finalizing update..." msgid "for \"{}\"" msgstr "for \"{}\"" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -781,6 +782,7 @@ msgstr "left" msgid "metered" msgstr "metered" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -793,6 +795,16 @@ msgstr "never" msgid "now" msgstr "now" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "openpilot Longitudinal Control (Alpha)" +msgstr "openpilot Longitudinal Control (Alpha)" +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "openpilot Unavailable" +msgstr "openpilot Unavailable" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "right" diff --git a/openpilot/selfdrive/ui/translations/app_es.po b/openpilot/selfdrive/ui/translations/app_es.po index 2298fdb5a1..fc231c2469 100644 --- a/openpilot/selfdrive/ui/translations/app_es.po +++ b/openpilot/selfdrive/ui/translations/app_es.po @@ -113,10 +113,6 @@ msgstr "CAMBIAR" msgid "CHECK" msgstr "COMPROBAR" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "MODO CHILL ACTIVADO" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: system/ui/widgets/network.py msgid "CONNECT" @@ -238,7 +234,6 @@ msgid "Early Access: Become a sunnypilot Sponsor" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Activar" @@ -331,6 +326,9 @@ msgid "Follow the prompts to complete the pairing process" msgstr "" #: system/ui/widgets/network.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" msgid "Forget" msgstr "Olvidar" @@ -364,6 +362,8 @@ msgstr "Dirección IP" #: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py @@ -765,6 +765,7 @@ msgstr "finalizando actualización..." msgid "for \"{}\"" msgstr "para \"{}\"" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -781,6 +782,7 @@ msgstr "izquierda" msgid "metered" msgstr "medido" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -793,6 +795,16 @@ msgstr "nunca" msgid "now" msgstr "ahora" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "openpilot Longitudinal Control (Alpha)" +msgstr "Control longitudinal de openpilot (Alpha)" +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "openpilot Unavailable" +msgstr "openpilot no disponible" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "openpilot requiere que el dispositivo esté montado dentro de 4° a izquierda o derecha y dentro de 5° hacia arriba o 9° hacia abajo." + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "derecha" diff --git a/openpilot/selfdrive/ui/translations/app_fr.po b/openpilot/selfdrive/ui/translations/app_fr.po index ac5b4deea5..be3bfd28bd 100644 --- a/openpilot/selfdrive/ui/translations/app_fr.po +++ b/openpilot/selfdrive/ui/translations/app_fr.po @@ -113,10 +113,6 @@ msgstr "CHANGER" msgid "CHECK" msgstr "VÉRIFIER" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "MODE CHILL ACTIVÉ" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: system/ui/widgets/network.py msgid "CONNECT" @@ -238,7 +234,6 @@ msgid "Early Access: Become a sunnypilot Sponsor" msgstr "Accès anticipé : Devenez parrain sunnypilot" #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Activer" @@ -331,6 +326,9 @@ msgid "Follow the prompts to complete the pairing process" msgstr "Suivez les instructions pour terminer le processus d'association" #: system/ui/widgets/network.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" msgid "Forget" msgstr "Oublier" @@ -365,6 +363,9 @@ msgstr "Adresse IP" #: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" msgstr "Si le statut de parrainage n'a pas été mis à jour, veuillez contacter un modérateur sur le forum communautaire à https://community.sunnypilot.ai" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" @@ -765,6 +766,7 @@ msgstr "finalisation de la mise à jour..." msgid "for \"{}\"" msgstr "pour \"{}\"" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -781,6 +783,7 @@ msgstr "gauche" msgid "metered" msgstr "limité" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -793,6 +796,16 @@ msgstr "jamais" msgid "now" msgstr "maintenant" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "openpilot Longitudinal Control (Alpha)" +msgstr "Contrôle longitudinal openpilot (Alpha)" +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "openpilot Unavailable" +msgstr "openpilot indisponible" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "openpilot exige que l'appareil soit monté à moins de 4° à gauche ou à droite et à moins de 5° vers le haut ou 9° vers le bas." + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "droite" diff --git a/openpilot/selfdrive/ui/translations/app_ja.po b/openpilot/selfdrive/ui/translations/app_ja.po index 1d701bd8b4..74bb52f307 100644 --- a/openpilot/selfdrive/ui/translations/app_ja.po +++ b/openpilot/selfdrive/ui/translations/app_ja.po @@ -113,10 +113,6 @@ msgstr "変更" msgid "CHECK" msgstr "確認" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "チルモードON" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: system/ui/widgets/network.py msgid "CONNECT" @@ -238,7 +234,6 @@ msgid "Early Access: Become a sunnypilot Sponsor" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "有効化" @@ -331,6 +326,9 @@ msgid "Follow the prompts to complete the pairing process" msgstr "" #: system/ui/widgets/network.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" msgid "Forget" msgstr "削除" @@ -364,6 +362,8 @@ msgstr "IPアドレス" #: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py @@ -765,6 +765,7 @@ msgstr "アップデートを終了しています..." msgid "for \"{}\"" msgstr "「{}」向け" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -781,6 +782,7 @@ msgstr "左" msgid "metered" msgstr "従量" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -793,6 +795,16 @@ msgstr "なし" msgid "now" msgstr "今" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "openpilot Longitudinal Control (Alpha)" +msgstr "openpilot 縦制御(アルファ)" +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "openpilot Unavailable" +msgstr "openpilotは利用できません" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "openpilotでは、デバイスの取り付け角度が左右±4°、上方向5°以内、下方向9°以内である必要があります。" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "右" diff --git a/openpilot/selfdrive/ui/translations/app_ko.po b/openpilot/selfdrive/ui/translations/app_ko.po index d5c5eb34a2..6c75e62ca7 100644 --- a/openpilot/selfdrive/ui/translations/app_ko.po +++ b/openpilot/selfdrive/ui/translations/app_ko.po @@ -113,10 +113,6 @@ msgstr "변경" msgid "CHECK" msgstr "확인" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "안정적 모드 켜짐" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: system/ui/widgets/network.py msgid "CONNECT" @@ -238,7 +234,6 @@ msgid "Early Access: Become a sunnypilot Sponsor" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "사용" @@ -331,6 +326,9 @@ msgid "Follow the prompts to complete the pairing process" msgstr "" #: system/ui/widgets/network.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" msgid "Forget" msgstr "삭제" @@ -364,6 +362,8 @@ msgstr "IP 주소" #: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py @@ -765,6 +765,7 @@ msgstr "업데이트 마무리 중..." msgid "for \"{}\"" msgstr "\"{}\"용" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -781,6 +782,7 @@ msgstr "왼쪽" msgid "metered" msgstr "종량제" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -793,6 +795,16 @@ msgstr "없음" msgid "now" msgstr "지금" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "openpilot Longitudinal Control (Alpha)" +msgstr "openpilot 롱컨 제어(알파)" +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "openpilot Unavailable" +msgstr "openpilot 사용 불가" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "openpilot은 장치를 좌우 4°, 위쪽 5°, 아래쪽 9° 이내로 장착해야 합니다." + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "오른쪽" diff --git a/openpilot/selfdrive/ui/translations/app_pt-BR.po b/openpilot/selfdrive/ui/translations/app_pt-BR.po index 5244d9f63f..6b534334fe 100644 --- a/openpilot/selfdrive/ui/translations/app_pt-BR.po +++ b/openpilot/selfdrive/ui/translations/app_pt-BR.po @@ -113,10 +113,6 @@ msgstr "ALTERAR" msgid "CHECK" msgstr "VERIFICAR" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "MODO CHILL ATIVO" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: system/ui/widgets/network.py msgid "CONNECT" @@ -238,7 +234,6 @@ msgid "Early Access: Become a sunnypilot Sponsor" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Ativar" @@ -331,6 +326,9 @@ msgid "Follow the prompts to complete the pairing process" msgstr "" #: system/ui/widgets/network.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" msgid "Forget" msgstr "Esquecer" @@ -364,6 +362,8 @@ msgstr "Endereço IP" #: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py @@ -765,6 +765,7 @@ msgstr "finalizando atualização..." msgid "for \"{}\"" msgstr "para \"{}\"" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -781,6 +782,7 @@ msgstr "à esquerda" msgid "metered" msgstr "limitados" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -793,6 +795,16 @@ msgstr "nunca" msgid "now" msgstr "agora" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "openpilot Longitudinal Control (Alpha)" +msgstr "Controle Longitudinal do openpilot (Alpha)" +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "openpilot Unavailable" +msgstr "openpilot Indisponível" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "o openpilot requer que o dispositivo seja montado dentro de 4° para a esquerda ou direita e dentro de 5° para cima ou 9° para baixo." + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "à direita" diff --git a/openpilot/selfdrive/ui/translations/app_th.po b/openpilot/selfdrive/ui/translations/app_th.po index 4897809528..8d9a0e717a 100644 --- a/openpilot/selfdrive/ui/translations/app_th.po +++ b/openpilot/selfdrive/ui/translations/app_th.po @@ -113,10 +113,6 @@ msgstr "เปลี่ยน" msgid "CHECK" msgstr "ตรวจสอบ" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "เปิดโหมด Chill" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: system/ui/widgets/network.py msgid "CONNECT" @@ -238,7 +234,6 @@ msgid "Early Access: Become a sunnypilot Sponsor" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "เปิดใช้งาน" @@ -331,6 +326,9 @@ msgid "Follow the prompts to complete the pairing process" msgstr "" #: system/ui/widgets/network.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" msgid "Forget" msgstr "ลืม" @@ -364,6 +362,8 @@ msgstr "ที่อยู่ IP" #: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py @@ -765,6 +765,7 @@ msgstr "กำลังสรุปการอัปเดต..." msgid "for \"{}\"" msgstr "สำหรับ \"{}\"" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "กม./ชม" @@ -781,6 +782,7 @@ msgstr "ซ้าย" msgid "metered" msgstr "คิดค่าบริการตามปริมาณ" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "ไมล์/ชม." @@ -793,6 +795,16 @@ msgstr "ไม่เคย" msgid "now" msgstr "ตอนนี้" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "openpilot Longitudinal Control (Alpha)" +msgstr "การควบคุมตามยาวของ openpilot (อัลฟา)" +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "openpilot Unavailable" +msgstr "openpilot ไม่พร้อมใช้งาน" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "openpilot ต้องติดตั้งอุปกรณ์ให้อยู่ในช่วงเอียงซ้ายหรือขวาไม่เกิน 4° และเอียงขึ้นไม่เกิน 5° หรือเอียงลงไม่เกิน 9°" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "ขวา" diff --git a/openpilot/selfdrive/ui/translations/app_tr.po b/openpilot/selfdrive/ui/translations/app_tr.po index a6da586bbd..e8d51a3940 100644 --- a/openpilot/selfdrive/ui/translations/app_tr.po +++ b/openpilot/selfdrive/ui/translations/app_tr.po @@ -113,10 +113,6 @@ msgstr "DEĞİŞTİR" msgid "CHECK" msgstr "KONTROL ET" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "CHILL MODU AÇIK" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: system/ui/widgets/network.py msgid "CONNECT" @@ -238,7 +234,6 @@ msgid "Early Access: Become a sunnypilot Sponsor" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Etkinleştir" @@ -331,6 +326,9 @@ msgid "Follow the prompts to complete the pairing process" msgstr "" #: system/ui/widgets/network.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" msgid "Forget" msgstr "Unut" @@ -364,6 +362,8 @@ msgstr "IP Adresi" #: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py @@ -765,6 +765,7 @@ msgstr "güncelleme tamamlanıyor..." msgid "for \"{}\"" msgstr "\"{}\" için" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -781,6 +782,7 @@ msgstr "sol" msgid "metered" msgstr "ölçülü" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -793,6 +795,16 @@ msgstr "asla" msgid "now" msgstr "şimdi" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "openpilot Longitudinal Control (Alpha)" +msgstr "openpilot Boylamsal Kontrol (Alfa)" +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "openpilot Unavailable" +msgstr "openpilot Kullanılamıyor" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "openpilot, cihazın sağa/sola 4° ve yukarı 5° veya aşağı 9° içinde monte edilmesini gerektirir." + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "sağ" diff --git a/openpilot/selfdrive/ui/translations/app_uk.po b/openpilot/selfdrive/ui/translations/app_uk.po index 43d78fad1c..d6126daa6a 100644 --- a/openpilot/selfdrive/ui/translations/app_uk.po +++ b/openpilot/selfdrive/ui/translations/app_uk.po @@ -113,10 +113,6 @@ msgstr "ЗМІНИТИ" msgid "CHECK" msgstr "ПЕРЕВІРИТИ" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "СПОКІЙНИЙ РЕЖИМ" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: system/ui/widgets/network.py msgid "CONNECT" @@ -238,7 +234,6 @@ msgid "Early Access: Become a sunnypilot Sponsor" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Увімкнути" @@ -331,6 +326,9 @@ msgid "Follow the prompts to complete the pairing process" msgstr "" #: system/ui/widgets/network.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" msgid "Forget" msgstr "Забути" @@ -364,6 +362,8 @@ msgstr "IP-адреса" #: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py @@ -765,6 +765,7 @@ msgstr "завершую..." msgid "for \"{}\"" msgstr "для \"{}\"" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "км/год" @@ -781,6 +782,7 @@ msgstr "вліво" msgid "metered" msgstr "обмеж." +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "миль/год" @@ -793,6 +795,16 @@ msgstr "ніколи" msgid "now" msgstr "зараз" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "openpilot Longitudinal Control (Alpha)" +msgstr "Поздовжнє керування openpilot (Альфа)" +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "openpilot Unavailable" +msgstr "openpilot Недоступний" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "Для роботи openpilot потрібно, щоб пристрій був встановлений з нахилом не більше 4° вліво або вправо та не більше 5° вгору або 9° вниз. openpilot постійно калібрується, тому скидання калібрування потрібне рідко." + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "вправо" diff --git a/openpilot/selfdrive/ui/translations/app_zh-CHS.po b/openpilot/selfdrive/ui/translations/app_zh-CHS.po index b01ba69392..bd613d3b4e 100644 --- a/openpilot/selfdrive/ui/translations/app_zh-CHS.po +++ b/openpilot/selfdrive/ui/translations/app_zh-CHS.po @@ -113,10 +113,6 @@ msgstr "更改" msgid "CHECK" msgstr "检查" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "安稳模式已开启" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: system/ui/widgets/network.py msgid "CONNECT" @@ -238,7 +234,6 @@ msgid "Early Access: Become a sunnypilot Sponsor" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "启用" @@ -331,6 +326,9 @@ msgid "Follow the prompts to complete the pairing process" msgstr "" #: system/ui/widgets/network.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" msgid "Forget" msgstr "忘记" @@ -364,6 +362,8 @@ msgstr "IP 地址" #: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py @@ -765,6 +765,7 @@ msgstr "正在完成更新..." msgid "for \"{}\"" msgstr "用于“{}”" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -781,6 +782,7 @@ msgstr "左" msgid "metered" msgstr "计量" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -793,6 +795,16 @@ msgstr "从不" msgid "now" msgstr "现在" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "openpilot Longitudinal Control (Alpha)" +msgstr "openpilot 纵向控制(Alpha)" +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "openpilot Unavailable" +msgstr "openpilot 无法使用" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "openpilot 要求设备安装在左右 4°、上 5° 或下 9° 以内。" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "右" diff --git a/openpilot/selfdrive/ui/translations/app_zh-CHT.po b/openpilot/selfdrive/ui/translations/app_zh-CHT.po index 3cdcfe4e20..59019c3a35 100644 --- a/openpilot/selfdrive/ui/translations/app_zh-CHT.po +++ b/openpilot/selfdrive/ui/translations/app_zh-CHT.po @@ -113,10 +113,6 @@ msgstr "變更" msgid "CHECK" msgstr "檢查" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "安穩模式已開啟" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: system/ui/widgets/network.py msgid "CONNECT" @@ -238,7 +234,6 @@ msgid "Early Access: Become a sunnypilot Sponsor" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "啟用" @@ -331,6 +326,9 @@ msgid "Follow the prompts to complete the pairing process" msgstr "" #: system/ui/widgets/network.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" msgid "Forget" msgstr "忘記" @@ -364,6 +362,8 @@ msgstr "IP 位址" #: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py @@ -765,6 +765,7 @@ msgstr "正在完成更新..." msgid "for \"{}\"" msgstr "適用於「{}」" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -781,6 +782,7 @@ msgstr "左" msgid "metered" msgstr "計量" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -793,6 +795,16 @@ msgstr "從不" msgid "now" msgstr "現在" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "openpilot Longitudinal Control (Alpha)" +msgstr "openpilot 縱向控制(Alpha)" +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "openpilot Unavailable" +msgstr "openpilot 無法使用" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "openpilot 要求裝置安裝在左右 4°、上 5° 或下 9° 以內。" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "右" diff --git a/openpilot/selfdrive/ui/translations/auto_translate.sh b/openpilot/selfdrive/ui/translations/auto_translate.sh index 7238426c75..858e60eb26 100755 --- a/openpilot/selfdrive/ui/translations/auto_translate.sh +++ b/openpilot/selfdrive/ui/translations/auto_translate.sh @@ -4,7 +4,7 @@ set -euo pipefail DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd)" ROOT="$DIR/../../../" -cd $DIR +cd "$DIR" ./update_translations.py command -v codex >/dev/null || { diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index a101f93d57..0a92c97670 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -12,8 +12,9 @@ from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.ui.lib.prime_state import PrimeState from openpilot.system.ui.lib.application import gui_app from openpilot.common.hardware import HARDWARE, PC -from openpilot.common.hardware.usb import TYPEC_CC_ORIENTATION_PATH, get_usb_state, is_chestnut_usb_id, read_int +from openpilot.common.hardware.usb import cable_connected, get_usb_state, is_chestnut_usb_id from openpilot.selfdrive.modeld.helpers import chestnut_compiled +from openpilot.selfdrive.selfdrived.alertmanager import OFFROAD_ALERTS from openpilot.selfdrive.ui.sunnypilot.ui_state import UIStateSP, DeviceSP @@ -69,6 +70,8 @@ class UIState(UIStateSP): "selfdriveState", "longitudinalPlan", "gpsLocationExternal", + "gpsLocation", + "navInstruction", "carOutput", "carControl", "vehicleParameters", @@ -88,14 +91,16 @@ class UIState(UIStateSP): # Core state variables self.is_metric: bool = self.params.get_bool("IsMetric") + self.initial_cruise_speed: int = self.params.get("InitialCruiseSpeedKph", return_default=True) 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.experimental_mode_confirmed: bool = self.params.get_bool("ExperimentalModeConfirmed") + self.navigation_enabled: bool = self.params.get_bool("NavigationEnabled") + self.nav_destination_set: bool = self.params.get("NavDestination") is not None self.chestnut_present: bool = False self.chestnut_compiled: bool = chestnut_compiled() self.chestnut_active: bool | None = None self.chestnut_loading: bool = False + self.chestnut_alert: bool = False self.usb_connected: bool = False self.usb_connected_ts: float | None = None self.usb_disconnected_ts: float | None = None @@ -210,7 +215,7 @@ 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 + self.chestnut_present = self.sm["deviceState"].chestnutPresent or (self.chestnut_compiled and self.usb_connected) for callback in self._offroad_transition_callbacks: callback() @@ -221,7 +226,8 @@ class UIState(UIStateSP): 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 + self.chestnut_state = (ChestnutState.FAILED if self.chestnut_alert else + ChestnutState.READY if detected and self.chestnut_compiled else ChestnutState.UNCOMPILED if detected else ChestnutState.DISCONNECTED) return @@ -230,10 +236,10 @@ class UIState(UIStateSP): 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_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_active is False: self.chestnut_state = ChestnutState.FAILED else: @@ -252,15 +258,15 @@ class UIState(UIStateSP): self.recording_audio = self.params.get_bool("RecordAudio") and self.started self.is_metric = self.params.get_bool("IsMetric") + self.initial_cruise_speed = self.params.get("InitialCruiseSpeedKph", return_default=True) self.always_on_dm = self.params.get_bool("AlwaysOnDM") - self.experimental_mode = self.params.get_bool("ExperimentalMode") - self.experimental_mode_confirmed = self.params.get_bool("ExperimentalModeConfirmed") - if not self.chestnut_compiled: - self.chestnut_compiled = chestnut_compiled() + self.navigation_enabled = self.params.get_bool("NavigationEnabled") + self.nav_destination_set = self.params.get("NavDestination") is not None self.chestnut_active = self.params.get("ChestnutActive") self.chestnut_loading = self.params.get_bool("ChestnutLoading") + self.chestnut_alert = any(self.params.get(k) is not None for k, a in OFFROAD_ALERTS.items() if k.startswith("Offroad_Chestnut") and a["severity"] >= 0) now = time.monotonic() - if read_int(TYPEC_CC_ORIENTATION_PATH) != 0: + if cable_connected(): self.usb_disconnected_ts = None if not self.usb_connected: self.usb_connected = True diff --git a/openpilot/selfdrive/ui/widgets/exp_mode_button.py b/openpilot/selfdrive/ui/widgets/exp_mode_button.py deleted file mode 100644 index 0b5bff7da4..0000000000 --- a/openpilot/selfdrive/ui/widgets/exp_mode_button.py +++ /dev/null @@ -1,65 +0,0 @@ -import pyray as rl -from openpilot.common.params import Params -from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE -from openpilot.system.ui.lib.multilang import tr -from openpilot.system.ui.widgets import Widget - - -class ExperimentalModeButton(Widget): - def __init__(self): - super().__init__() - - self.img_width = 80 - self.horizontal_padding = 25 - self.button_height = 125 - - self.params = Params() - self.experimental_mode = self.params.get_bool("ExperimentalMode") - - self.chill_pixmap = gui_app.texture("icons/couch.png", self.img_width, self.img_width) - self.experimental_pixmap = gui_app.texture("icons/experimental_grey.png", self.img_width, self.img_width) - - def show_event(self): - super().show_event() - self.experimental_mode = self.params.get_bool("ExperimentalMode") - - def _get_gradient_colors(self): - alpha = 0xCC if self.is_pressed else 0xFF - - if self.experimental_mode: - return rl.Color(255, 155, 63, alpha), rl.Color(219, 56, 34, alpha) - else: - return rl.Color(20, 255, 171, alpha), rl.Color(35, 149, 255, alpha) - - def _draw_gradient_background(self, rect): - start_color, end_color = self._get_gradient_colors() - rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), int(rect.width), int(rect.height), - start_color, end_color) - - def _render(self, rect): - rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) - self._draw_gradient_background(rect) - rl.draw_rectangle_rounded_lines_ex(self._rect, 0.19, 10, 5, rl.BLACK) - rl.end_scissor_mode() - - # Draw vertical separator line - line_x = rect.x + rect.width - self.img_width - (2 * self.horizontal_padding) - separator_color = rl.Color(0, 0, 0, 77) # 0x4d = 77 - rl.draw_line_ex(rl.Vector2(line_x, rect.y), rl.Vector2(line_x, rect.y + rect.height), 3, separator_color) - - # Draw text label (left aligned) - text = tr("EXPERIMENTAL MODE ON") if self.experimental_mode else tr("CHILL MODE ON") - text_x = rect.x + self.horizontal_padding - text_y = rect.y + rect.height / 2 - 45 * FONT_SCALE // 2 # Center vertically - - rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), text, rl.Vector2(int(text_x), int(text_y)), 45, 0, rl.BLACK) - - # Draw icon (right aligned) - icon_x = rect.x + rect.width - self.horizontal_padding - self.img_width - icon_y = rect.y + (rect.height - self.img_width) / 2 - icon_rect = rl.Rectangle(icon_x, icon_y, self.img_width, self.img_width) - - # Draw current mode icon - current_icon = self.experimental_pixmap if self.experimental_mode else self.chill_pixmap - source_rect = rl.Rectangle(0, 0, current_icon.width, current_icon.height) - rl.draw_texture_pro(current_icon, source_rect, icon_rect, rl.Vector2(0, 0), 0, rl.WHITE) diff --git a/openpilot/selfdrive/ui/widgets/offroad_alerts.py b/openpilot/selfdrive/ui/widgets/offroad_alerts.py index 88ccc5df7e..e2b62834ce 100644 --- a/openpilot/selfdrive/ui/widgets/offroad_alerts.py +++ b/openpilot/selfdrive/ui/widgets/offroad_alerts.py @@ -263,6 +263,8 @@ class OffroadAlert(AbstractAlert): def _build_alerts(self): self.sorted_alerts = [] for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True): + if key == "Offroad_Pairing": + continue severity = config.get("severity", 0) alert_data = AlertData(key=key, text="", severity=severity) self.sorted_alerts.append(alert_data) diff --git a/openpilot/sunnypilot/mapd/live_map_data/osm_map_data.py b/openpilot/sunnypilot/mapd/live_map_data/osm_map_data.py index e6cbce0674..3073b372a2 100644 --- a/openpilot/sunnypilot/mapd/live_map_data/osm_map_data.py +++ b/openpilot/sunnypilot/mapd/live_map_data/osm_map_data.py @@ -38,7 +38,10 @@ class OsmMapData(BaseMapData): if self.last_bearing is not None: params['bearing'] = self.last_bearing - self.mem_params.put("LastGPSPosition", json.dumps(params), block=True) + # mapd reads its position from the memory params as JSON, so write it for the mapd binary. + # LastGPSPosition is registered as BYTES (ubloxd owns the persistent copy for pigeond), + # so the JSON payload has to be encoded here. + self.mem_params.put("LastGPSPosition", json.dumps(params).encode(), block=True) def get_current_speed_limit(self) -> float: return float(self.mem_params.get("MapSpeedLimit") or 0.0) diff --git a/openpilot/sunnypilot/mapd/mapd_installer.py b/openpilot/sunnypilot/mapd/mapd_installer.py index ec3cf94e7e..b4789e8c81 100755 --- a/openpilot/sunnypilot/mapd/mapd_installer.py +++ b/openpilot/sunnypilot/mapd/mapd_installer.py @@ -20,7 +20,6 @@ from openpilot.common.hardware.hw import Paths from openpilot.common.spinner import Spinner from openpilot.common.version import is_prebuilt from openpilot.sunnypilot.mapd import MAPD_PATH, MAPD_BIN_DIR -import openpilot.system.sentry as sentry VERSION = "v1.12.0" URL = f"https://github.com/pfeiferj/openpilot-mapd/releases/download/{VERSION}/mapd" @@ -136,9 +135,7 @@ class MapdInstallManager: f"Boot will continue in {5 - i}s...") time.sleep(1) - sentry.init(sentry.SentryProject.SELFDRIVE) traceback.print_exc() - sentry.capture_exception() if __name__ == "__main__": diff --git a/openpilot/sunnypilot/mapd/mapd_manager.py b/openpilot/sunnypilot/mapd/mapd_manager.py index 899b0c2bd9..d6acd529a7 100755 --- a/openpilot/sunnypilot/mapd/mapd_manager.py +++ b/openpilot/sunnypilot/mapd/mapd_manager.py @@ -121,8 +121,9 @@ def update_osm_db() -> None: if not mem_params.get("OSMDownloadBounds"): mem_params.put("OSMDownloadBounds", "", block=True) + # mapd reads LastGPSPosition from the memory params as JSON, and it is registered as BYTES upstream if not mem_params.get("LastGPSPosition"): - mem_params.put("LastGPSPosition", "{}", block=True) + mem_params.put("LastGPSPosition", b"{}", block=True) def main_thread(): diff --git a/openpilot/sunnypilot/mapd/tests/test_osm_map_data.py b/openpilot/sunnypilot/mapd/tests/test_osm_map_data.py new file mode 100644 index 0000000000..20b755bcb9 --- /dev/null +++ b/openpilot/sunnypilot/mapd/tests/test_osm_map_data.py @@ -0,0 +1,70 @@ +""" +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 + +import openpilot.cereal.messaging as messaging +from openpilot.cereal import log +from openpilot.common.params import Params +from openpilot.common.test import OpenpilotTestCase +from openpilot.sunnypilot.mapd.live_map_data.osm_map_data import OsmMapData + + +class TestOsmMapData(OpenpilotTestCase): + """The mapd binary reads its position from the memory params as JSON, so mapd_manager has to feed it.""" + + LATITUDE = 32.875134 + LONGITUDE = -117.209777 + ALTITUDE = 42.0 + BEARING = 90.0 + + def setup_method(self): + self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else Params() + self.mem_params.remove("LastGPSPosition") + self.map_data = OsmMapData() + self.pm = messaging.PubMaster(['liveLocationKalman']) + + def location_message(self, status: log.LiveLocationKalman.Status, position_valid: bool): + msg = messaging.new_message('liveLocationKalman', valid=True) + location = msg.liveLocationKalman + location.status = status + location.positionGeodetic = {'value': [self.LATITUDE, self.LONGITUDE, self.ALTITUDE], 'valid': position_valid} + location.calibratedOrientationNED = {'value': [0.0, 0.0, math.radians(self.BEARING)], 'valid': True} + return msg + + def send_location(self, status: log.LiveLocationKalman.Status, position_valid: bool) -> None: + # the first exchange synchronizes the PUB/SUB connection, the second one is actually received + self.pm.send('liveLocationKalman', self.location_message(status, position_valid)) + self.map_data.sm.update(1000) + self.pm.send('liveLocationKalman', self.location_message(status, position_valid)) + self.map_data.sm.update(1000) + + def read_position(self) -> dict: + value = self.mem_params.get("LastGPSPosition") + self.assertIsNotNone(value, "LastGPSPosition was not written for mapd") + return json.loads(value) + + def test_writes_position_for_mapd(self): + self.send_location(log.LiveLocationKalman.Status.valid, True) + self.map_data.tick() + + position = self.read_position() + self.assertAlmostEqual(position['latitude'], self.LATITUDE, delta=1e-6) + self.assertAlmostEqual(position['longitude'], self.LONGITUDE, delta=1e-6) + self.assertAlmostEqual(position['bearing'], self.BEARING, delta=1e-6) + + def test_invalid_localizer_keeps_last_known_position(self): + self.send_location(log.LiveLocationKalman.Status.valid, True) + self.map_data.tick() + + self.send_location(log.LiveLocationKalman.Status.uninitialized, False) + self.map_data.tick() + + position = self.read_position() + self.assertAlmostEqual(position['latitude'], self.LATITUDE, delta=1e-6) + self.assertAlmostEqual(position['longitude'], self.LONGITUDE, delta=1e-6) diff --git a/openpilot/sunnypilot/modeld_v2/compile_modeld.py b/openpilot/sunnypilot/modeld_v2/compile_modeld.py index 1e88769928..732c2d54a1 100755 --- a/openpilot/sunnypilot/modeld_v2/compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/compile_modeld.py @@ -7,41 +7,84 @@ See the LICENSE.md file in the root directory for more details. """ import argparse +import codecs +import gc import math +import multiprocessing as mp import os +import pickle import tempfile import time from functools import partial -from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob +from typing import Any + import numpy as np -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.sunnypilot.modeld_v2.helpers import PlainJit, patch_fetch_fw, read_parts, write_bundle +from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit +import openpilot.sunnypilot.modeld_v2.stock_dependencies as stock +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info -import openpilot.selfdrive.modeld.compile_modeld as stock from tinygrad import dtypes from tinygrad.device import Device from tinygrad.engine.jit import TinyJit +from tinygrad.helpers import Context +from tinygrad.nn.onnx import OnnxRunner, OnnxPBParser from tinygrad.tensor import Tensor +os.environ['GMMU'] = '0' + +patch_fetch_fw() + 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'] -nv12_copy_size = stock.nv12_copy_size + +class MetadataOnnxPBParser(OnnxPBParser): + def _parse_ModelProto(self) -> dict: + obj: dict[str, Any] = {"graph": {"input": [], "output": []}, "metadata_props": []} + self.opset_imports = {} + for fid, wire_type in self._parse_message(self.reader.len): + match fid: + case 7: + obj["graph"] = self._parse_GraphProto() + case 14: + obj["metadata_props"].append(self._parse_StringStringEntryProto()) + case _: + self.reader.skip_field(wire_type) + return obj + +def get_name_and_shape(value_info: dict[str, Any]) -> tuple[str, tuple[int, ...]]: + shape = tuple(int(dim) if isinstance(dim, int) else 0 for dim in value_info["parsed_type"].shape) + name = value_info["name"] + return name, shape + +def get_metadata_value_by_name(model: dict[str, Any], name: str) -> str | Any: + for prop in model["metadata_props"]: + if prop["key"] == name: + return prop["value"] + return None + +def make_metadata_dict(model_path): + with Context(DEV='CPU'): + model = MetadataOnnxPBParser(model_path).parse() + output_slices_raw = get_metadata_value_by_name(model, 'output_slices') + assert output_slices_raw is not None, 'output_slices not found in metadata' + + output_slices = pickle.loads(codecs.decode(output_slices_raw.encode(), "base64")) + if 'hidden_state' not in output_slices: + output_slices['hidden_state'] = slice(0, 512) + + meta_dict = { + 'model_checkpoint': get_metadata_value_by_name(model, 'model_checkpoint'), + 'output_slices': output_slices, + '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"]), + } + del model + gc.collect() + return meta_dict + def _detect_desire_key(shapes: dict) -> str | None: return next((key for key in shapes if key.startswith('desire')), None) @@ -79,6 +122,10 @@ def get_policy_npy_shapes(input_shapes: dict, is_supercombo: bool = False) -> tu return shapes, sizes +def host_view(tensor: Tensor) -> np.ndarray: # QCOM -> CPU round trip of tfm takes 5ms longer than host memory view + return np.frombuffer(tensor._buffer()._host_mv(), dtype=np.float32).reshape(tensor.shape) + + 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) @@ -114,7 +161,7 @@ def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = D '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(), + 'packed_npy_inputs': Tensor(packed_npy_inputs, device=device).realize(), } if features_buffer: @@ -123,9 +170,14 @@ def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = D 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')}) + for key in ('tfm', 'big_tfm'): + if key not in npy_arrays: + npy_arrays[key] = np.eye(3, dtype=np.float32) + queues[key] = Tensor(npy_arrays[key], device=device).realize() - return queues, npy_arrays + packed = host_view(queues['packed_npy_inputs']) + npy_arrays |= {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed, split_indices) if sizes else [], strict=True)} + return queues, npy_arrays | {k: host_view(queues[k]) for k in ('tfm', 'big_tfm')} def make_split_input_queues(vision_input_shapes: dict, policy_input_shapes: dict, @@ -147,8 +199,8 @@ def make_warp_queues(device=Device.DEFAULT): 'tfm': np.zeros((3, 3), dtype=np.float32), 'big_tfm': np.zeros((3, 3), dtype=np.float32), } - queues = {k: Tensor(v, device='NPY').realize() for k, v in npy.items()} - return queues, npy + queues = {k: Tensor(v, device=device).realize() for k, v in npy.items()} + return queues, {k: host_view(v) for k, v in queues.items()} def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, frame_skip: int, input_shapes: dict): @@ -164,8 +216,7 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, is_supercombo = vision_runner is None npy_shapes, npy_sizes = get_policy_npy_shapes(input_shapes, is_supercombo=is_supercombo) - def run_policy(warped, img_q, big_img_q, feat_q, packed_npy_inputs, **kwargs): - desire_q = kwargs['desire_q'] + def run_policy(warped, img_q, big_img_q, feat_q, desire_q=None, packed_npy_inputs=None, **kwargs): packed_npy_inputs_dev = packed_npy_inputs.to(Device.DEFAULT) warped_dev = warped.to(Device.DEFAULT) Tensor.realize(packed_npy_inputs_dev, warped_dev) @@ -209,51 +260,86 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, return run_policy -def compile_jit(jit, input_keys, make_queues, make_random_inputs=None, benchmark_runs: int = 1): - SEED = 42 - def random_inputs_run(fn, seed, n_runs, test_val=None, test_buffers=None, expect_match=True): - queues_res = make_queues(Device.DEFAULT) - input_queues, npy = queues_res[0], queues_res[1] - frame_views = queues_res[2] if len(queues_res) > 2 else {} - rng = np.random.default_rng(seed) - Tensor.manual_seed(seed) +class _CheckFrameRecorder: + def __init__(self, jit): + self.jit, self.frame = jit, None - for i in range(n_runs): - for v in npy.values(): - v[:] = rng.standard_normal(v.shape).astype(v.dtype) - for v in frame_views.values(): - v[:] = rng.integers(0, 256, size=v.shape, dtype=np.uint8) - Device.default.synchronize() - random_inputs = make_random_inputs(rng=rng) if make_random_inputs is not None else {} - st = time.perf_counter() - 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") + def __call__(self, *args, **kwargs): + if self.frame is not None or args or self.jit.cnt < 2: + return self.jit(*args, **kwargs) + self.frame, result = capture_frame(self.jit, kwargs, None) + return result - if i == 0: - 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: - match = all(np.array_equal(a, b) for a, b in zip(val, test_val, strict=True)) - assert match == expect_match, f"outputs {'differ from' if expect_match else 'match'} baseline (seed={seed})" - 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 val, buffers +def compile_jit(jit_or_fn, input_keys_or_make_inputs, make_queues=None, make_random_inputs=None, + benchmark_runs: int = 1, clear_refs=None): + if callable(input_keys_or_make_inputs) and make_queues is None: + fn = jit_or_fn + make_inputs = input_keys_or_make_inputs + jit = TinyJit(fn, prune=True) + + def run_eval(f, seed, count): + args, kwargs = make_inputs(seed) + result = None + for i in range(count): + Device.default.synchronize() + st = time.perf_counter() + f(*args, **kwargs) + Device.default.synchronize() + print(f" [{i+1}/{count}] {(time.perf_counter()-st)*1e3:.2f} ms") + if i == 0: + result = [t.numpy().copy() for t in kwargs['output_buffers'].values()] + return result + else: + jit = jit_or_fn + input_keys = input_keys_or_make_inputs + assert make_queues is not None + queues_maker = make_queues + + def run_eval(f, seed, count): + queues_res = queues_maker(Device.DEFAULT) + input_queues, npy = queues_res[0], queues_res[1] + frame_views = queues_res[2] if len(queues_res) > 2 else {} + rng = np.random.default_rng(seed) + Tensor.manual_seed(seed) + + for i in range(count): + for v in npy.values(): + v[:] = rng.standard_normal(v.shape).astype(v.dtype) + for v in frame_views.values(): + v[:] = rng.integers(0, 256, size=v.shape, dtype=np.uint8) + Device.default.synchronize() + random_inputs = make_random_inputs() if make_random_inputs is not None else {} + st = time.perf_counter() + outs = f(**{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}/{count}] 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 if isinstance(outs, tuple) else [outs])] if outs is not None else [] + return val print('capture + replay') - test_val, test_buffers = random_inputs_run(jit, SEED, 3) - print(f'pickle round trip ({benchmark_runs} runs per seed)') - with tempfile.TemporaryFile(dir=".") as f: - dump_oob(jit, f) - f.seek(0) - loaded_jit = load_oob(f) - random_inputs_run(loaded_jit, SEED, benchmark_runs, test_val, test_buffers, expect_match=True) - random_inputs_run(loaded_jit, SEED+1, benchmark_runs, test_val, test_buffers, expect_match=False) - return jit + gc.collect() + recorder = _CheckFrameRecorder(jit) + with record_kernel_linear() as recorded: + test_val = run_eval(recorder, 42, 3) + assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}" + part = serialize_jit(jit, recorded[0], recorder.frame) + print(f'plain artifact round trip ({benchmark_runs} runs per seed)') + if clear_refs: + clear_refs() + gc.collect() + loaded_jit = PlainJit(part[0], memoryview(part[1])) + + for seed in (42, 43): + reference = test_val if seed == 42 else run_eval(jit_or_fn, seed, 1) + actual = run_eval(loaded_jit, seed, benchmark_runs) + for ref, val in zip(reference, actual, strict=True): + np.testing.assert_array_equal(ref, val) + return part def _parse_size(size_str: str) -> tuple[int, int]: @@ -261,19 +347,6 @@ def _parse_size(size_str: str) -> tuple[int, int]: return int(width), int(height) -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 _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)]: @@ -283,6 +356,20 @@ def _load_policy_runners(args: argparse.Namespace) -> tuple[list, list]: return runners, keys +def _compile_warp_resolution_worker(cam_w, cam_h, model_w, model_h, benchmark_runs, result_path): + nv12 = stock.NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) + WARP_DEV = os.getenv('WARP_DEV', Device.DEFAULT) + make_random_warp_inputs = partial(make_random_images, keys=['frame', 'big_frame'], shape=nv12.size, device=WARP_DEV) + warp = TinyJit(stock.make_warp(nv12, model_w, model_h), prune=True) + + def cleanup_warp(): + nonlocal warp + warp = None + compiled_jit = compile_jit(warp, WARP_INPUTS, make_warp_queues, make_random_inputs=make_random_warp_inputs, + benchmark_runs=benchmark_runs, clear_refs=cleanup_warp) + write_bundle(result_path, {(cam_w, cam_h): compiled_jit}, {'device': Device.DEFAULT}) + + if __name__ == "__main__": if 'USB' in os.getenv('DEV', '') or os.getenv('CHESTNUT'): from openpilot.system.hardware.chestnut.flash import link_up @@ -293,11 +380,6 @@ if __name__ == "__main__": 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') @@ -316,36 +398,47 @@ if __name__ == "__main__": 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) - + is_unified_supercombo = False if args.model_type == 'supercombo': assert args.supercombo_onnx model_metadata = make_metadata_dict(args.supercombo_onnx) + derived_frame_skip = args.frame_skip or derive_frame_skip({}, model_metadata['input_shapes']) + if derived_frame_skip != 1 and os.getenv('CHESTNUT'): + is_unified_supercombo = True + + if is_unified_supercombo: output_data['metadata'] = {'model': model_metadata, **model_metadata} output_data['input_devices'] = {'model': Device.DEFAULT} - output_data['run_model'] = {} - derived_frame_skip = args.frame_skip or derive_frame_skip({}, model_metadata['input_shapes']) model_runner = OnnxRunner(args.supercombo_onnx) - run_policy = stock.make_run_policy(model_runner, model_metadata, derived_frame_skip) - for cam_w, cam_h in args.camera_resolutions: - print(f"Compiling unified run_model JIT for {cam_w}x{cam_h}...") + features_slice = model_metadata['output_slices']['hidden_state'] + run_policy = make_run_policy(None, [model_runner], features_slice, derived_frame_skip, model_metadata['input_shapes']) + for cam_w, cam_h in set(args.camera_resolutions): + print(f"Compiling unified model JIT for {cam_w}x{cam_h}") nv12 = stock.NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) frame_copy_size = stock.nv12_copy_size(nv12.stride, nv12.y_height, nv12.uv_height) make_model_queues = partial(stock.make_input_queues, model_metadata['input_shapes'], derived_frame_skip, frame_copy_size=frame_copy_size) warp = stock.make_warp(nv12, model_w, model_h) run_model_jit = TinyJit(stock.make_run_model(warp, run_policy, model_metadata, frame_copy_size), prune=True) - output_data['run_model'][(cam_w, cam_h)] = compile_jit(run_model_jit, stock.MODELD_INPUTS, make_model_queues, benchmark_runs=args.benchmark_runs) + write_bundle(part_path := f"{args.output}.{cam_w}x{cam_h}", {(cam_w, cam_h): compile_jit(run_model_jit, stock.MODELD_INPUTS, make_model_queues, + benchmark_runs=args.benchmark_runs)}) + output_data[(cam_w, cam_h)] = read_parts(part_path)[0][(cam_w, cam_h)] + os.remove(part_path) + gc.collect() + del model_runner, run_policy + gc.collect() + output_data['metadata']['warp_dev'] = Device.DEFAULT else: 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) @@ -359,33 +452,51 @@ if __name__ == "__main__": 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('policy') - assert feat_meta is not None + all_shapes = { + key: value for meta in output_data['metadata'].values() + if isinstance(meta, dict) and 'input_shapes' in meta 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 isinstance(feat_meta, dict) 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=False) - make_random_model_inputs = partial(make_random_images, keys=['warped'], shape=(2, 6, model_h // 2, model_w // 2), device=Device.DEFAULT) - output_data['run_policy'] = compile_jit(run_policy_jit, POLICY_INPUTS, make_policy_queues, make_random_inputs=make_random_model_inputs) + make_policy_queues = partial(generate_queues_and_npy, all_shapes, derived_frame_skip, is_supercombo=is_supercombo) + WARP_DEV = os.getenv('WARP_DEV', Device.DEFAULT) + make_random_model_inputs = partial(make_random_images, keys=['warped'], shape=(2, 6, model_h // 2, model_w // 2), device=WARP_DEV) - for cam_w, cam_h in args.camera_resolutions: - print(f"Compiling warp JIT for {cam_w}x{cam_h}...") - nv12 = stock.NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - frame_copy_size = stock.nv12_copy_size(nv12.stride, nv12.y_height, nv12.uv_height) - make_random_warp_inputs = partial(make_random_images, keys=['frame', 'big_frame'], shape=frame_copy_size, device=Device.DEFAULT) - warp = TinyJit(stock.make_warp(nv12, model_w, model_h), prune=True) - output_data[(cam_w, cam_h)] = compile_jit(warp, WARP_INPUTS, make_warp_queues, make_random_inputs=make_random_warp_inputs) + def cleanup_policy(): + global run_policy_jit, run_policy_func, vision_runner, policy_runners + run_policy_jit, run_policy_func, vision_runner, policy_runners = None, None, None, None + output_data['run_policy'] = compile_jit(run_policy_jit, POLICY_INPUTS, make_policy_queues, + make_random_inputs=make_random_model_inputs, benchmark_runs=args.benchmark_runs, clear_refs=cleanup_policy) + + ctx = mp.get_context('spawn') + output_data['input_devices'] = {} + tmp_files = [] + for cam_w, cam_h in set(args.camera_resolutions): + print(f"Compiling warp JIT for {cam_w}x{cam_h}") + tmp_res = tempfile.NamedTemporaryFile(delete=False, suffix=".pkl", dir=".") + tmp_res.close() + + p = ctx.Process(target=_compile_warp_resolution_worker, args=(cam_w, cam_h, model_w, model_h, args.benchmark_runs, tmp_res.name)) + p.start() + p.join() + if p.exitcode != 0: + raise RuntimeError(f"Warp compilation worker failed for {cam_w}x{cam_h}") + + tmp_files.append((cam_w, cam_h, tmp_res.name)) + for cam_w, cam_h, tmp_name in tmp_files: + warp_parts, warp_extras = read_parts(tmp_name) + output_data[(cam_w, cam_h)] = warp_parts[(cam_w, cam_h)] + output_data['input_devices']['warp'] = warp_extras['device'] + os.remove(tmp_name) output_data['metadata']['warp_dev'] = Device.DEFAULT - 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) - print(f"Chunked into {len(chunk_targets) - 1} file(s)") + parts = {key: output_data.pop(key) for key in [key for key, value in output_data.items() if isinstance(value, tuple)]} + write_bundle(args.output, parts, output_data) + print(f"Saved model to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB, {len(parts)} JITs)") diff --git a/openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py b/openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py new file mode 100755 index 0000000000..44ff5fed67 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +import argparse +import os +import sys + +import numpy as np + +from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, write_bundle +from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit + + +def initialize_check_inputs(arrays: dict): + rng = np.random.default_rng(7) + for value in arrays.values(): + value[...] = rng.standard_normal(value.shape) if value.dtype.kind == "f" else rng.integers(0, 2 if value.dtype == np.bool_ else 16, value.shape) + + +if __name__ == "__main__": + sys.path.insert(0, os.environ.get("TINYGRAD_PATH", "tinygrad_repo")) + from examples.openpilot.compile_onnx import onnx_metadata + from examples.openpilot.helpers import allocate_inputs, benchmark + from tinygrad import Device, Tensor, TinyJit + from tinygrad.dtype import _to_np_dtype + from tinygrad.nn.onnx import OnnxRunner + + parser = argparse.ArgumentParser(description="Compile a native-architecture ONNX model into a plain tinygrad artifact") + parser.add_argument('onnx') + parser.add_argument('output') + parser.add_argument('--device-input', action='append', default=[]) + parser.add_argument('--benchmark-runs', type=int, default=20) + args = parser.parse_args() + + runner, (properties, output_shapes) = OnnxRunner(args.onnx), onnx_metadata(args.onnx) + device_inputs = runner.graph_inputs.keys() if '*' in args.device_input else args.device_input + metadata = {'metadata': properties} | {f'{kind}_shapes': {name: tuple(d if isinstance(d, int) else 0 for d in shape) for name, shape in shapes.items()} + for kind, shapes in [('input', {name: spec.shape for name, spec in runner.graph_inputs.items()}), + ('output', output_shapes)]} + input_specs = {name: (tuple(s if isinstance(s, int) else 1 for s in spec.shape), np.dtype(_to_np_dtype(spec.dtype)).str, + Device.DEFAULT if name in device_inputs else 'NPY') for name, spec in runner.graph_inputs.items()} + + def model(inputs): + return {name: value.contiguous() for name, value in runner({name: value.to(Device.DEFAULT) for name, value in inputs.items()}).items()} + output_specs = {name: (value.shape, np.dtype(_to_np_dtype(value.dtype)).name, Device.DEFAULT) for name, value in model(allocate_inputs(input_specs)).items()} + + @TinyJit(prune=True) + def jit(output_buffers, **inputs): + Tensor.realize(*(output_buffers[name].assign(value) for name, value in model(inputs).items())) + + check_inputs = allocate_inputs(input_specs, initialize_check_inputs) | {'output_buffers': allocate_inputs(output_specs)} + with record_kernel_linear() as recorded: + for i in range(2 + args.benchmark_runs): + benchmark(jit, cb=lambda t, i=i: print(f" [{i + 1}/{2 + args.benchmark_runs}] {t * 1e3:.2f} ms"), **check_inputs) + assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}" + + frame, _ = capture_frame(jit, check_inputs, 'output_buffers') + part = serialize_jit(jit, recorded[0], frame) + PlainJit(part[0], memoryview(part[1])) + write_bundle(args.output, {'run': part}, {'metadata': metadata, 'input_specs': input_specs, 'output_specs': output_specs}) + print(f"Saved model to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB, {len(part[0]['calls'])} kernels)") diff --git a/openpilot/sunnypilot/modeld_v2/compile_warp.py b/openpilot/sunnypilot/modeld_v2/compile_warp.py new file mode 100755 index 0000000000..d7ee412d04 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/compile_warp.py @@ -0,0 +1,43 @@ +#!/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 numpy as np + +from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, write_bundle +from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit + + +def main(): + parser = argparse.ArgumentParser(description="Compile the two-frame yuv420 camera warp into a plain tinygrad artifact") + parser.add_argument("--frame", required=True, help="width,height,stride,y_height,uv_height,buffer_size") + parser.add_argument("--warp-to", required=True, help="model input WxH") + parser.add_argument("--output", required=True) + args = parser.parse_args() + + from examples.openpilot.compile_warp import make_frame_prepare, parse_frame, parse_size + from tinygrad import Device, Tensor, TinyJit + frame = parse_frame(args.frame) + warp = make_frame_prepare(frame, *parse_size(args.warp_to)) + run = TinyJit(lambda input_frame, M_inv: Tensor.stack(*(warp(input_frame[i], M_inv[i]) for i in range(2))), prune=True) + rng = np.random.default_rng(42) + inputs = {"input_frame": Tensor(rng.integers(0, 256, (2, frame.size), dtype=np.uint8), device=Device.DEFAULT).realize(), + "M_inv": Tensor((rng.standard_normal((2, 3, 3)) * 8).astype(np.float32), device=Device.DEFAULT).realize()} + with record_kernel_linear() as recorded: + run(**inputs) + run(**inputs) + check, _ = capture_frame(run, inputs) + assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}" + part = serialize_jit(run, recorded[0], check) + PlainJit(*part) + specs = {"input_frame": ((2, frame.size), "|u1", Device.DEFAULT), "M_inv": ((2, 3, 3), " tuple[int, int | None]: + from tinygrad.uop.ops import Ops + start, size = 0, None + while u.op in (Ops.AFTER, Ops.BITCAST, Ops.SHRINK): + if u.op is Ops.SHRINK: + start, size = start + u.src[1].arg, u.src[2].arg if size is None else size + u = u.src[0] + return start, size + + +def _address(v): + from tinygrad.uop.ops import Ops + add = 0 + while v.op is not Ops.GETADDR: + add += v.src[1].arg if v.op is Ops.ADD else 0 + v = v.src[0] + start, size = _view(v.src[0]) + return _root(v.src[0]), add + start, size + + +def _buffer(u): + from tinygrad.uop.ops import Ops + return u.buffer if u.op is Ops.BUFFER else getattr(u.arg, "buffer", None) + + +def _patched(buf, topo) -> tuple[bytearray, dict]: + from tinygrad.uop.ops import Ops + blob, rows = bytearray(buf.max_numel() * buf.dtype.itemsize), {} + for u in topo: + if u.op is not Ops.STORE or _root(u.src[0]) is not buf: + continue + if (value := u.src[1]).op is Ops.BINARY or value.src[0].op is Ops.BINARY: + data = (value if value.op is Ops.BINARY else value.src[0]).arg + blob[:len(data)] = data + else: + view, offsets = u.src[0].src + rows |= {_view(view)[0] + o.arg * view.dtype.itemsize: w for o, w in zip(offsets.src, value.src, strict=True)} + return blob, rows + + +def _evaluate(u, env): + from tinygrad.uop.ops import GroupOp, Ops, exec_alu + if u.op in (Ops.AFTER, Ops.CAST, Ops.BITCAST): + return _evaluate(u.src[0], env) + if u.op is Ops.CONST: + return u.arg + if u.op in (Ops.PARAM, Ops.RANGE): + return env.get(u.arg.slot if u.op is Ops.PARAM else u) + if u.op is Ops.LOAD: + table, index = _root(u.src[0].src[0]), _evaluate(u.src[0].src[1], env) + return struct.unpack_from(u.dtype.fmt, table.arg, index * u.dtype.itemsize)[0] if table.op is Ops.BINARY and index is not None else None + if u.op in GroupOp.ALU and None not in (values := [_evaluate(x, env) for x in u.src]): + return exec_alu(u.op, u.dtype, values, *([False] if "truncate_output" in inspect.signature(exec_alu).parameters else [])) + return None + + +def _input_pokes(submit, kernargs) -> dict[int, tuple[int, int]]: + from tinygrad.uop.ops import Ops + outer = dict(enumerate(submit.src[1:])) + kernargs_params = {i for i, a in outer.items() if a.op is Ops.GETADDR and _root(a.src[0]) is kernargs} + inputs = {i: _root(a.src[0]).arg.slot for i, a in outer.items() if a.op is Ops.GETADDR and _root(a.src[0]).op is Ops.PARAM} + pokes = {} + for call in (u for u in submit.src[0].src[0].toposort() if u.op is Ops.CALL and len(u.src) > 3): + _, address, value, *_ = call.src[1:] + used = list({x.arg.slot for x in value.toposort() if x.op is Ops.PARAM} & inputs.keys()) + if len(used) != 1 or not kernargs_params & {x.arg.slot for x in address.toposort() if x.op is Ops.PARAM}: + continue + ranges = list(dict.fromkeys(x for x in (*address.toposort(), *value.toposort()) if x.op is Ops.RANGE)) + for trip in itertools.product(*[range(int(r.vmax) + 1) for r in ranges]): + env = dict.fromkeys(outer, 0) | dict(zip(ranges, trip, strict=True)) + if (offset := _evaluate(address, env)) % 8 == 0: + pokes[offset] = (inputs[used[0]], _evaluate(value, env)) + return pokes + + +def _elf(image: bytes, rodata: int) -> bytes: + names, offset, data, headers = b"\0.text\0.rodata\0.shstrtab\0", 64, b"", [] + for name, kind, address, content in [(0, 0, 0, b""), (1, 1, ALIGNMENT, image[:rodata]), (7, 1, ALIGNMENT + rodata, image[rodata:]), (15, 3, 0, names)]: + headers.append(struct.pack(" tuple[dict, memoryview]: + import tinygrad.runtime.autogen.am.pm4_nv as pm4 + import tinygrad.runtime.autogen.amd_gpu as amd_gpu + from tinygrad.dtype import dtypes + from tinygrad.engine.jit import _prepare_jit_inputs + from tinygrad.helpers import unwrap + from tinygrad.uop.ops import Ops + submit = jit.captured._linear.src[0].without_after + kernels, topo = submit.arg.aux.kernels, list(jit.captured._linear.toposort(enter_calls=True)) + allocs = {str(name): u for u in topo if u.op is Ops.ALLOC and (name := getattr(u, "tag", None) or u.arg.name) is not None} + (commands, patched), kernargs = _patched(allocs["cmdbuf_compute_0"], topo), allocs["kernargs_compute_0"] + argument_words, pokes = _patched(kernargs, topo)[1], _input_pokes(submit, kernargs) + sh = {name: getattr(amd_gpu, f"reg{name}") + amd_gpu.GC_BASE__INST0_SEG0 - pm4.PACKET3_SET_SH_REG_START + for name in ("COMPUTE_PGM_LO", "COMPUTE_USER_DATA_0", "COMPUTE_NUM_THREAD_X")} if hasattr(amd_gpu, "regCOMPUTE_PGM_LO") else \ + {"COMPUTE_PGM_LO": 0x20c, "COMPUTE_USER_DATA_0": 0x240, "COMPUTE_NUM_THREAD_X": 0x207} + + dispatches, registers, offset = [], {}, 0 + while offset < len(commands): + word = lambda o: patched.get(o, struct.unpack_from("> 30 != 3: + offset += 4 + continue + count, opcode = ((header >> 16) & 0x3fff) + 1, (header >> 8) & 0xff + body = [word(offset + 4 + 4 * i) for i in range(count)] + if opcode == pm4.PACKET3_SET_SH_REG: + registers |= {body[0] + i: v for i, v in enumerate(body[1:])} + elif opcode == pm4.PACKET3_DISPATCH_DIRECT: + local = [registers[sh["COMPUTE_NUM_THREAD_X"] + i] for i in range(3)] + dispatches.append((registers[sh["COMPUTE_PGM_LO"]], registers[sh["COMPUTE_USER_DATA_0"]], local, body[:3])) + offset += 4 + 4 * count + assert len(dispatches) == len(kernels), f"decoded {len(dispatches)} dispatches, the JIT has {len(kernels)} kernels" + + sink = tempfile.TemporaryFile(dir=".") + blob, buffers, programs = Blob(sink), {}, {} + u8 = repr(dtypes.uint8) + + def store(array) -> dict: + array = np.ascontiguousarray(array) + return {"data": blob.add(array.tobytes()), "dtype": array.dtype.str, "shape": list(array.shape)} + + calls, written, needs_init = [], set(), set() + for (program, arguments, local, grid), kernel in zip(dispatches, kernels, strict=True): + image_alloc, entry, _ = _address(program) + if image_alloc not in programs: + image = bytes(_patched(image_alloc, topo)[0]) + rodata = next(r for r in range(0, len(image) - 24, 64) if r + struct.unpack_from(" tuple[dict[str, int], int]: + offsets, offset = {}, 0 + for name, shape in ({"tfm": (2, 3, 3)} | shapes).items(): + offsets[name], offset = offset, offset + -(-math.prod(shape) * 4 // 128) * 128 + return offsets | {"frames": offset}, offset + 2 * frame_size + + +class _Blobs: # the model's and the warp's blobs as one address space, for a part whose ranges span both files + def __init__(self, *blobs): + self.blobs, self.starts = blobs, list(itertools.accumulate([0] + [len(blob) for blob in blobs[:-1]])) + + def __getitem__(self, span: slice): + index = bisect.bisect_right(self.starts, span.start) - 1 + return self.blobs[index][span.start - self.starts[index]:span.stop - self.starts[index]] + + +def fused_with_warp(cam_w: int, cam_h: int, chestnut: bool): # load_model's extend: a native model runs upload, warp and model as one submit + return partial(frame_part, f"{BASEDIR}/openpilot/sunnypilot/modeld_v2/models/{'big_' if chestnut else ''}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl") + + +def frame_part(warp_path: str, parts: dict, extras: dict) -> dict: + if "input_specs" not in extras: # a legacy model warps in its own parts + return {} + (model, model_blob), (warp, warp_blob) = parts["run"], read_parts(warp_path)[0]["run"] + states = sorted(name for name in extras["input_specs"] if f"next_{name}" in extras["metadata"]["output_shapes"]) + host = {name: shape for name, (shape, _, _) in extras["input_specs"].items() if name not in states and name != "new_img"} + frame_size = next(s["numel"] for s in warp["inputs"] if s["name"] == "input_frame") // 2 + offsets, packed = pack_layout(host, frame_size) + shift, upload, u8 = len(model_blob), len(model["buffers"]) + len(warp["buffers"]), repr(_dtype("dtypes.uint8")) + + def view(name: str, nbytes: int, dtype: str) -> dict: + return {"buffer": upload, "offset": offsets[name], "nbytes": nbytes, "dtype": dtype} + outputs = sorted(s["slot"] for s in model["inputs"] if s["name"] is None) + names = sorted(["host", *states]) + slots = {s["slot"]: names.index(s["name"]) for s in model["inputs"] if s["name"] in states} | \ + {slot: len(names) + i for i, slot in enumerate(outputs)} + returned = warp["returns"]["tensors"][0] + model_inputs = {s["slot"]: {"input": slots[s["slot"]]} if s["slot"] in slots else + {k: v for k, v in returned.items() if k != "shape"} | {"buffer": returned["buffer"] + len(model["buffers"])} if s["name"] == "new_img" else + view(s["name"], s["numel"] * _dtype(s["dtype"]).itemsize, s["dtype"]) for s in model["inputs"]} + warp_inputs = {s["slot"]: view("frames" if s["name"] == "input_frame" else "tfm", s["numel"] * _dtype(s["dtype"]).itemsize, s["dtype"]) + for s in warp["inputs"]} + + def remap(calls: list, inputs: dict, buffers: int, lib: int) -> list: + return [call | ({"lib": [call["lib"][0] + lib, call["lib"][1] + lib]} if "lib" in call else {}) | + {"args": [dict(inputs[a["input"]]) if "input" in a else a | {"buffer": a["buffer"] + buffers} for a in call["args"]]} for call in calls] + header = {"input_names": names, "returns": {"kind": "NoneType", "tensors": []}, "check": None, + "inputs": [{"slot": names.index("host"), "name": "host", "dtype": u8, "numel": packed, "device": "NPY"}] + + [s | {"slot": slots[s["slot"]]} for s in model["inputs"] if s["slot"] in slots], + "buffers": model["buffers"] + [b | ({"init": [b["init"][0] + shift, b["init"][1] + shift]} if "init" in b else {}) for b in warp["buffers"]] + + [{"nbytes": packed, "device": model["inputs"][0]["device"], "dtype": u8}], + "calls": [{"kind": "copy", "args": [{"buffer": upload, "offset": 0, "nbytes": packed, "dtype": u8}, {"input": names.index("host")}]}] + + remap(warp["calls"], warp_inputs, len(model["buffers"]), shift) + remap(model["calls"], model_inputs, 0, 0)} + return {"run": (header, _Blobs(model_blob, warp_blob))} # replaces the model's run: one set of buffers, no separate warp diff --git a/openpilot/sunnypilot/modeld_v2/get_model_metadata.py b/openpilot/sunnypilot/modeld_v2/get_model_metadata.py index 838b1e9f40..d24f592671 100755 --- a/openpilot/sunnypilot/modeld_v2/get_model_metadata.py +++ b/openpilot/sunnypilot/modeld_v2/get_model_metadata.py @@ -1,13 +1,8 @@ -#!/usr/bin/env python3 -import sys -import pathlib +from typing import Any import codecs import pickle -from typing import Any - from tinygrad.nn.onnx import OnnxPBParser - class MetadataOnnxPBParser(OnnxPBParser): def _parse_ModelProto(self) -> dict: obj: dict[str, Any] = {"graph": {"input": [], "output": []}, "metadata_props": []} @@ -21,35 +16,33 @@ class MetadataOnnxPBParser(OnnxPBParser): self.reader.skip_field(wire_type) return obj +def _get_name_and_shape(value_info: dict[str, Any]) -> tuple[str, tuple[int, ...]]: + shape = tuple(int(dim) if isinstance(dim, int) else 1 for dim in value_info["parsed_type"].shape) + return value_info["name"], shape -def get_name_and_shape(value_info: dict[str, Any]) -> tuple[str, tuple[int, ...]]: - shape = tuple(int(dim) if isinstance(dim, int) else 0 for dim in value_info["parsed_type"].shape) - name = value_info["name"] - return name, shape - - -def get_metadata_value_by_name(model: dict[str, Any], name: str) -> str | Any: +def _get_metadata_value_by_name(model: dict[str, Any], name: str) -> str | None: for prop in model["metadata_props"]: if prop["key"] == name: return prop["value"] return None +def make_metadata_dict(onnx_path): + model = MetadataOnnxPBParser(onnx_path).parse() + output_slices_raw = _get_metadata_value_by_name(model, 'output_slices') -if __name__ == "__main__": - model_path = pathlib.Path(sys.argv[1]) - model = MetadataOnnxPBParser(model_path).parse() - output_slices = get_metadata_value_by_name(model, 'output_slices') - assert output_slices is not None, 'output_slices not found in metadata' + if output_slices_raw: + output_slices = pickle.loads(codecs.decode(output_slices_raw.encode(), "base64")) + else: + output_slices = {} - 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"]), + input_shapes = dict(_get_name_and_shape(x) for x in model["graph"]["input"]) + metadata = {prop["key"]: prop["value"] for prop in model["metadata_props"]} + + if 'hidden_state' not in output_slices: + output_slices['hidden_state'] = slice(0, 512) + + return { + 'input_shapes': input_shapes, + 'output_slices': output_slices, + 'metadata_props': metadata } - - metadata_path = model_path.parent / (model_path.stem + '_metadata.pkl') - with open(metadata_path, 'wb') as f: - pickle.dump(metadata, f) - - print(f'saved metadata to {metadata_path}') diff --git a/openpilot/sunnypilot/modeld_v2/helpers.py b/openpilot/sunnypilot/modeld_v2/helpers.py index 5beb52ac7f..7b79c1736d 100644 --- a/openpilot/sunnypilot/modeld_v2/helpers.py +++ b/openpilot/sunnypilot/modeld_v2/helpers.py @@ -5,96 +5,333 @@ 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 bisect +import builtins +import contextlib +import dataclasses +import functools +import hashlib import io -import struct +import itertools +import json +import math +import mmap +import os import pickle -import inspect -import importlib -import enum +import struct + +MAGIC = b"SPTGPLN1" +ALIGNMENT = 256 +METADATA_TYPES = ("slice", "tuple", "list", "dict", "set", "frozenset", "complex") +RUNTIME_ENVIRONMENT = ("GMMU", "AMD_AQL", "AMD_DISABLE_SDMA", "GRAPH_ONE_KERNEL") -def _pad_args(func, args, kwargs): +def _patch_system_flock_acquire(): try: - sig = inspect.signature(func) - except Exception: - return args, kwargs - params = list(sig.parameters.values()) - if inspect.isfunction(func) and params and params[0].name in ('cls', 'self'): - params = params[1:] + from tinygrad.runtime.support.system import System + original_flock_acquire = System.flock_acquire + acquired_locks: dict[str, int] = {} - new_args = list(args) - has_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params) - if len(new_args) > len(params) and not has_varargs: - new_args = new_args[:len(params)] - - for i in range(len(new_args), len(params)): - param = params[i] - if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): - continue - val = param.default if param.default is not inspect.Parameter.empty else None - new_args.append(val) - return new_args, kwargs + def flock_acquire(name: str) -> int: + if name in acquired_locks: + return acquired_locks[name] + lock_file_descriptor = original_flock_acquire(name) + acquired_locks[name] = lock_file_descriptor + return lock_file_descriptor + System.flock_acquire = flock_acquire + except (ImportError, AttributeError): + pass +_patch_system_flock_acquire() -def _enum_factory(enum_class): - def factory(*args, **kwargs): - try: - return enum_class(*args, **kwargs) - # OptOps and UOp objects in the .pkl are left over from the compilation phase, - # reassignment does nothing because they aren't tied to the execution graph - # It never executes or evaluates the UOp nodes again. - except ValueError: - return list(enum_class)[0] - factory.__name__ = enum_class.__name__ - factory.__module__ = enum_class.__module__ - return factory +def patch_fetch_fw() -> None: + try: + import zstandard + except ImportError: + return + zstandard.decompress = lambda data, *_: zstandard.ZstdDecompressor().stream_reader(data).read() # ty: ignore[invalid-assignment] -def _dynamic_factory(real_class): - if isinstance(real_class, type) and issubclass(real_class, enum.Enum): - return _enum_factory(real_class) - - def factory(*args, **kwargs): - try: - return real_class(*args, **kwargs) - except TypeError: - new_args, new_kwargs = _pad_args(real_class, args, kwargs) - return real_class(*new_args, **new_kwargs) - - class DynamicMeta(type(real_class)): - def __call__(cls, *args, **kwargs): - return factory(*args, **kwargs) - - class DynamicProxy(real_class, metaclass=DynamicMeta): - __slots__ = () - - def __new__(cls, *args, **kwargs): - return factory(*args, **kwargs) - - DynamicProxy.__name__ = real_class.__name__ - DynamicProxy.__module__ = real_class.__module__ - return DynamicProxy +class ArtifactMismatch(RuntimeError): + pass -class DynamicTinygradUnpickler(pickle.Unpickler): +class Blob: + def __init__(self, sink=None): + self.chunks: list = [] + self.locations: dict[bytes, list[int]] = {} + self.size, self.write = 0, sink.write if sink else self.chunks.append + + def add(self, data) -> list[int]: + if (key := hashlib.blake2b(data, digest_size=16).digest()) not in self.locations: + start = self.size + -self.size % ALIGNMENT + self.write(bytes(start - self.size)), self.write(data) + self.locations[key], self.size = [start, start + memoryview(data).nbytes], start + memoryview(data).nbytes + return self.locations[key] + + +class _MetadataUnpickler(pickle.Unpickler): def find_class(self, module, name): - if module == "tinygrad.ops": - try: - importlib.import_module("tinygrad.uops") - module = "tinygrad.uops" - except ImportError: - pass - real_class = getattr(importlib.import_module(module), name) - if module.startswith("tinygrad"): - return _dynamic_factory(real_class) - return real_class + if module == "builtins" and name in METADATA_TYPES: + return getattr(builtins, name) + raise pickle.UnpicklingError(f"{module}.{name} is not allowed in model metadata") -def load_oob(f): - opcodes = f.read(struct.unpack(' list: + if isinstance(value, (tuple, list)): + return [tensor for item in value for tensor in flatten(item)] + return [] if value is None else [value] + + +def _relocate(value, part_blob: memoryview, blob: Blob): + if isinstance(value, dict): + return {k: blob.add(part_blob[slice(*v)]) if k in ("lib", "init", "data") else _relocate(v, part_blob, blob) for k, v in value.items()} + return [_relocate(v, part_blob, blob) for v in value] if isinstance(value, list) else value + + +def write_bundle(path: str, parts: dict, extras: dict | None = None): + blob = Blob() + headers = {key: _relocate(part_header, memoryview(part_blob), blob) for key, (part_header, part_blob) in parts.items()} + extras_location = blob.add(pickle.dumps(extras or {}, protocol=4)) + entries = [{"key": list(key) if isinstance(key, tuple) else key, "blob": [0, blob.size], "header": header} for key, header in headers.items()] + header = json.dumps({"parts": entries, "extras": extras_location}).encode() + with open(path, "wb") as file: + file.write(MAGIC + struct.pack(" tuple[dict, dict]: + with open(path, "rb") as file: + data = memoryview(mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_COPY)) + if bytes(data[:len(MAGIC)]) != MAGIC: + raise ValueError(f"{path} is not a plain tinygrad model, recompile it with compile_modeld.py") + start = len(MAGIC) + 8 + struct.unpack_from(" tuple[dict, dict, str]: + parts, extras = read_parts(path) + added = extend(parts, extras) if extend else {} + return parts | added, extras, _parts_digest(added) if added else "" + + +def load_model(path: str, device_map: dict | None = None, verify: bool = True, cache: bool = True, extend=None) -> dict: + parts, extras, extra = _extended(path, extend) + if cache and (cached := _read_cache(path, parts, device_map, extra)) is not None: + return extras | cached + shared: dict = {} + jits = {key: PlainJit(header, blob, device_map, verify, shared) for key, (header, blob) in parts.items()} + if cache: + _write_cache(path, jits, device_map, extra) + return extras | jits + + +@functools.cache +def tinygrad_identity() -> str: + import tinygrad + root, digest = os.path.dirname(tinygrad.__file__), hashlib.sha256() + for directory, _, files in sorted(os.walk(root)): + for name in sorted(file for file in files if file.endswith(".py")): + with open(path := os.path.join(directory, name), "rb") as file: + digest.update(f"{os.path.relpath(path, root)}:".encode() + file.read()) + return digest.hexdigest() + + +def _parts_digest(parts: dict) -> str: + digest = hashlib.sha256() + for _, (header, blob) in sorted(parts.items(), key=lambda item: repr(item[0])): + digest.update(json.dumps(header, sort_keys=True).encode()) + for call in header["calls"]: + if call["kind"] == "program": + digest.update(blob[slice(*call["lib"])]) + return digest.hexdigest() + + +def _cache_key(path: str, device_map: dict | None, extra: str = "") -> bytes: + stat = os.stat(path) + return json.dumps({"format": MAGIC.decode(), "tinygrad": tinygrad_identity(), "size": stat.st_size, "mtime_ns": stat.st_mtime_ns, + "extra": extra, "device_map": sorted((device_map or {}).items()), + "env": [os.environ.get(name, "") for name in RUNTIME_ENVIRONMENT]}).encode() + + +def _cache_header_matches(file, path: str, device_map: dict | None, extra: str = "") -> bool: + return file.read(struct.unpack(" bool: # entry point for warmup proc + try: + with open(f"{path}.cache", "rb") as file: + return _cache_header_matches(file, path, device_map) + except (OSError, struct.error): + return False + + +def _read_cache(path: str, parts: dict, device_map: dict | None, extra: str = "") -> dict | None: + try: + with open(f"{path}.cache", "rb") as file: + if not _cache_header_matches(file, path, device_map, extra): + return None + shared: dict = {} + buffers = {repr(key): _allocate_buffers(header, blob, device_map, shared) for key, (header, blob) in parts.items()} + return type("_Unpickler", (pickle.Unpickler,), {"persistent_load": lambda self, pid: buffers[pid[0]][pid[1]]})(file).load() + except Exception: + return None + + +def _write_cache(path: str, jits: dict, device_map: dict | None, extra: str = "") -> None: + from tinygrad.device import Buffer + owners = {id(buffer): (repr(key), index) for key, jit in jits.items() for index, buffer in enumerate(jit.buffers)} + + class _Pickler(pickle.Pickler): + def persistent_id(self, obj): + return owners.get(id(obj)) if isinstance(obj, Buffer) else None + + key, tmp_path = _cache_key(path, device_map, extra), f"{path}.cache.{os.getpid()}.tmp" + with contextlib.suppress(Exception): + with open(tmp_path, "wb") as file: + file.write(struct.pack(" str: + from tinygrad import Device + device_map = device_map or {} + return Device.canonicalize(device_map.get(recorded, device_map.get(recorded.split(":", 1)[0], recorded))) + + +def _upload(buffer, data: memoryview, chunk: int = 16 << 20) -> None: + from tinygrad.device import Buffer + source, chunk = Buffer("NPY", len(data), opaque=memoryview(bytearray(data)) if data.readonly else data), min(chunk, len(data)) + for offset in sorted({*range(0, len(data) - chunk, chunk), len(data) - chunk}): + buffer.view(chunk, offset).ensure_allocated().copy_from(source.view(chunk, offset).ensure_allocated()) + + +def _allocate_buffers(header: dict, blob: memoryview, device_map: dict | None, shared: dict) -> list: + import numpy as np + from tinygrad.device import Buffer + written = {call["args"][i]["buffer"] for call in header["calls"] for i in (call["outs"] if call["kind"] == "program" else (0,)) + if "buffer" in call["args"][i]} + entries = [(_device(entry["device"], device_map), blob[slice(*entry["init"])] if "init" in entry else None, entry) for entry in header["buffers"]] + keys = [(device, entry["dtype"], np.frombuffer(data, np.uint8).ctypes.data, len(data)) if data and index not in written else object() + for index, (device, data, entry) in enumerate(entries)] + for device in {key[0] for key in keys if isinstance(key, tuple) and key not in shared}: + if shared.setdefault(("mapped", device), Buffer(device, 1).ensure_allocated().get_storage().host is not None): + continue + spans = [(entry["init"], entry["nbytes"], key) for (_, _, entry), key in zip(entries, keys, strict=True) + if isinstance(key, tuple) and key[0] == device and key not in shared] + files = getattr(blob, "starts", [0]) # a part read from several files (frame_fusion's) is uploaded a block per file + for _, group in itertools.groupby(sorted(spans, key=lambda span: span[0][0]), key=lambda span: bisect.bisect_right(files, span[0][0])): + group = list(group) + start, end = min(span[0] for span, _, _ in group), max(span[1] for span, _, _ in group) + _upload(block := Buffer(device, end - start).ensure_allocated(), blob[start:end]) + shared |= {key: block.view(nbytes, span[0] - start).ensure_allocated() for span, nbytes, key in group} + buffers = [] + for (device, data, entry), key in zip(entries, keys, strict=True): + if key not in shared: + shared[key] = Buffer(device, entry["nbytes"], initial_value=pickle.PickleBuffer(data) if data else None).ensure_allocated() + buffers.append(shared[key]) + return buffers + + +def _fields(what, /, **values): # a tinygrad dataclass built or replaced with the fields it has today: a dropped field is fine, a new required one is not + known = {field.name for field in dataclasses.fields(what)} + values = {name: value for name, value in values.items() if name in known} + return what(**values) if isinstance(what, type) else dataclasses.replace(what, **values) + + +def _program_uop(call: dict, lib: bytes): + from tinygrad.helpers import Target + from tinygrad.uop.ops import KernelInfo, Ops, ProgramInfo, UOp + parameters = [] + for name, compact_slot, dtype, shape in call["signature"]: + parameter = UOp.param(call["globals"][compact_slot], _dtype(dtype), math.prod(shape)) + image = (shape[0], shape[1]) if len(shape) == 3 and shape[2] == 4 else None + parameters.append(parameter.replace(arg=_fields(parameter.arg, name=name, image=image))) + info = _fields(ProgramInfo, global_size=tuple(call["global_size"]), local_size=tuple(call["local_size"]), globals=tuple(call["globals"]), + outs=tuple(call["outs"]), ins=tuple(call["ins"]), target=_fields(Target, **call["target"])) + return UOp(Ops.PROGRAM, src=(UOp(Ops.SINK, arg=KernelInfo(name=call["name"])), UOp(Ops.LINEAR, src=tuple(parameters)), + UOp(Ops.SOURCE, arg=""), UOp(Ops.BINARY, arg=lib)), arg=info) + + +class _Jit: + def __init__(self, captured, input_names: list, buffers: list): + self.captured, self.input_names, self.buffers = captured, input_names, buffers + + def __call__(self, *args, **kwargs): + import tinygrad.engine.jit as jit + input_uops, var_vals, names, _ = jit._prepare_jit_inputs(args, kwargs) + if names != self.input_names: + raise ValueError(f"expected inputs {self.input_names}, got {names}") + return self.captured(input_uops, var_vals) + + +class PlainJit(_Jit): + def __init__(self, header: dict, blob: memoryview, device_map: dict | None = None, verify: bool = True, shared: dict | None = None): + import numpy as np + import tinygrad.engine.jit as jit + from tinygrad import Tensor + from tinygrad.device import Buffer + from tinygrad.dtype import dtypes + from tinygrad.uop.ops import Ops, UOp + + inputs = {spec["slot"]: spec for spec in header["inputs"]} + buffers = _allocate_buffers(header, blob, device_map, {} if shared is None else shared) + bases = [UOp.from_buffer(buffer, _dtype(entry["dtype"])) for buffer, entry in zip(buffers, header["buffers"], strict=True)] + + def argument(described: dict): + if "input" in described: + spec = inputs[described["input"]] + return UOp.param(described["input"], _dtype(spec["dtype"]), spec["numel"], _device(spec["device"], device_map)) + base, dtype, offset, nbytes = bases[described["buffer"]], _dtype(described["dtype"]), described["offset"], described["nbytes"] + if offset == 0 and nbytes == header["buffers"][described["buffer"]]["nbytes"] and dtype == base.dtype: + return base + return (base if base.dtype.itemsize == 1 else base.bitcast(dtypes.uint8)).shrink(((offset, offset + nbytes),)).bitcast(dtype) + + calls = [] + for call in header["calls"]: + arguments = [argument(described) for described in call["args"]] + if call["kind"] == "program": + calls.append(_program_uop(call, bytes(blob[slice(*call["lib"])])).call(*arguments)) + else: + calls.append(arguments[0].store_call(arguments[1])) + # stand-ins for JIT inputs: copy staging to GPU is decided from the input buffers, the real ones bind per call + stand_ins = {slot: UOp.from_buffer(Buffer(_device(spec["device"], device_map), spec["numel"] * _dtype(spec["dtype"]).itemsize).ensure_allocated(), + _dtype(spec["dtype"])) for slot, spec in inputs.items()} + input_uops = tuple(stand_ins.get(slot, next(iter(stand_ins.values()), None)) for slot in range(max(stand_ins, default=-1) + 1)) + linear = jit.compile_linear(UOp(Ops.LINEAR, src=tuple(calls)), beam=0, input_uops=input_uops, cache=False) + + returned = [Tensor(argument(t)).reshape(tuple(t["shape"])) for t in header["returns"]["tensors"]] + ret = {"NoneType": None, "Tensor": returned[0] if returned else None, "tuple": tuple(returned), "list": returned}[header["returns"]["kind"]] + super().__init__(jit.CapturedJit(ret, linear, header["input_names"], []), header["input_names"], buffers) + + if not verify or not (check := header.get("check")): + return + def array(stored): + return np.frombuffer(blob[slice(*stored["data"])], dtype=np.dtype(stored["dtype"])).reshape(stored["shape"]).copy() + kwargs: dict = {} + for path, stored, device in check["inputs"]: + tensor = Tensor(array(stored), device=_device(device, device_map)).realize() + kwargs[path[0]] = tensor if len(path) == 1 else {**kwargs.get(path[0], {}), path[1]: tensor} + result = self(**kwargs) + outputs = kwargs[check["output_container"]].values() if check["output_container"] else flatten(result) + for index, (got, stored) in enumerate(zip([t.numpy() for t in outputs], check["outputs"], strict=True)): + expected = array(stored) + if got.shape != expected.shape or not np.allclose(got, expected, rtol=1e-3, atol=1e-3, equal_nan=True): + raise ArtifactMismatch(f"output {index} differs from the compile-time reference frame; recompile the model") diff --git a/openpilot/sunnypilot/modeld_v2/model_adapters.py b/openpilot/sunnypilot/modeld_v2/model_adapters.py new file mode 100644 index 0000000000..5d1e68feef --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/model_adapters.py @@ -0,0 +1,208 @@ +""" +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 codecs +import math +from pathlib import Path +import pickle +import numpy as np + +from openpilot.common.basedir import BASEDIR +from openpilot.sunnypilot.modeld_v2.frame_fusion import pack_layout +from openpilot.sunnypilot.modeld_v2.helpers import load_model +from openpilot.sunnypilot.modeld_v2.compile_modeld import (POLICY_INPUTS, derive_frame_skip, + make_split_input_queues, make_supercombo_input_queues) +from openpilot.sunnypilot.modeld_v2.stock_dependencies import make_input_queues as stock_make_input_queues, nv12_copy_size +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info +from tinygrad.device import Buffer +from tinygrad.dtype import DType +from tinygrad.tensor import Tensor +from tinygrad.uop.ops import UOp + + +def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int) -> Tensor: + view = buffer.view(math.prod(shape) * dtype.itemsize, offset).ensure_allocated() + return Tensor(UOp.from_buffer(view, dtype)).reshape(shape) + + +class BaseModelAdapter: + def __init__(self, jits, cam_w, cam_h, model_device, queue_device, warp_device, chestnut=False): + self.jits = jits + self.DEV = model_device + self.QUEUE_DEV = queue_device + self.WARP_DEV = warp_device + self.chestnut = chestnut + self.cam_w = cam_w + self.cam_h = cam_h + self._combined_model_type = 'supercombo' + self._policy_slices_list = [] + self._has_on_policy = False + self.policy_output_slices = {} + self._policy_keys = [] + self.full_frames = {} + self._blob_cache = {} + self.nv12_info = get_nv12_info(cam_w, cam_h) + self.is_native = False + + def _init_common(self): + self._desire_key = next((key for key in getattr(self, 'numpy_inputs', {}) if key.startswith('desire')), 'desire') + self._road_key = next((key for key in getattr(self, '_vision_input_names', []) if 'big' not in key), 'img') + self._wide_key = next((key for key in getattr(self, '_vision_input_names', []) if 'big' in key), 'big_img') + self.frame_buf_params = dict.fromkeys(getattr(self, '_vision_input_names', ['img', 'big_img']), self.nv12_info) + + def get_dummy_inputs(self): + dummy_size = getattr(self, 'warp_frame_size', self.frame_copy_size if self.is_native else self.frame_buf_params[self._road_key][3]) + dummy_frames = {k: np.zeros(dummy_size, 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 = {k: np.zeros(v.shape, dtype=v.dtype) for k, v in self.numpy_inputs.items() if k not in ['tfm', 'big_tfm', 'prev_feat']} + return dummy_frames, transforms, dummy_inputs + + def _load_warp(self): + if (self.cam_w, self.cam_h) in self.jits: + self.warp_frame_size = self.nv12_info[3] + return self.jits[(self.cam_w, self.cam_h)] + + warp_name = f'{"big_" if self.chestnut else ""}driving_warp_{self.cam_w}x{self.cam_h}_tinygrad.pkl' + warp = load_model(str(Path(BASEDIR) / "openpilot/sunnypilot/modeld_v2/models" / warp_name), cache=False) + self.warp_frame_size = warp['input_specs']['input_frame'][0][1] + return warp['run'] + + +class LegacyModelAdapter(BaseModelAdapter): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + metadata = self.jits['metadata'] + self.frame_copy_size = nv12_copy_size(*self.nv12_info[:3]) + + if self.chestnut: + self.WARP_DEV = self.DEV + + if 'model' in metadata: + model_metadata = metadata.get('model', metadata) + self.input_shapes = model_metadata['input_shapes'] + self.vision_output_slices = model_metadata['output_slices'] + self._vision_input_names = [key for key in self.input_shapes if 'img' in key] + self.frame_skip = derive_frame_skip({}, self.input_shapes) + if self.chestnut: + self.input_queues, self.numpy_inputs, self.frame_slots = stock_make_input_queues( + self.input_shapes, self.frame_skip, device=self.QUEUE_DEV, frame_copy_size=self.frame_copy_size) + else: + self.input_queues, self.numpy_inputs = make_supercombo_input_queues(self.input_shapes, self.frame_skip, device=self.QUEUE_DEV) + self.run_policy = self.jits['run_policy'] if 'run_policy' in self.jits else self.jits[(self.cam_w, self.cam_h)] + else: + self.run_policy = self.jits['run_policy'] + vision_metadata = metadata['vision'] + policy_keys = [k for k in metadata if k not in ('vision', 'warp_dev')] + self._combined_model_type = 'split' if policy_keys == ['policy'] else 'multi_policy' + self.vision_output_slices = vision_metadata['output_slices'] + self._policy_keys = policy_keys + 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) + self._vision_input_names = [key for key in vision_metadata['input_shapes'] if 'img' in key] + first_policy_meta = metadata[policy_keys[0]] + self.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'], + self.frame_skip, device=self.QUEUE_DEV) + + self.run_warp = self._load_warp() + self._init_common() + if not self.chestnut: + 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() + self.run_warp(**{k: self.input_queues[k] for k in ('tfm', 'big_tfm')}, + frame=frame_tensor, big_frame=big_frame_tensor) + + def copy_frames(self, bufs): + if self.chestnut: + for key in self._vision_input_names: + if key in bufs: + data = bufs[key].data if hasattr(bufs[key], 'data') else bufs[key] + np.copyto(self.frame_slots[key], np.frombuffer(data, dtype=np.uint8, count=self.frame_copy_size)) + else: + for key in bufs.keys(): + data = bufs[key].data if hasattr(bufs[key], 'data') else bufs[key] + ptr = np.frombuffer(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.WARP_DEV) + self.full_frames[key] = self._blob_cache[cache_key] + + def reset_warmup_buffers(self): + for v in self.numpy_inputs.values(): + v[:] = 0 + if not self.chestnut: + self.full_frames.clear() + self._blob_cache.clear() + + def run(self): + if self.chestnut: + return self.run_policy(**{k: self.input_queues[k] for k in POLICY_INPUTS if k in self.input_queues}) + + warped = self.run_warp(**{k: self.input_queues[k] for k in ('tfm', 'big_tfm')}, + frame=self.full_frames[self._road_key], big_frame=self.full_frames[self._wide_key]) + return self.run_policy(**{k: self.input_queues[k] for k in POLICY_INPUTS if k in self.input_queues}, warped=warped) + + +class NativeTinygradAdapter(BaseModelAdapter): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.is_native = True + self.input_specs = self.jits['input_specs'] + + self.model_device = self.input_specs['new_img'][2] + self.input_shapes = {name: (shape, np.dtype(dtype)) for name, (shape, dtype, _) in self.input_specs.items()} + self.state_pairs = {name: f'next_{name}' for name in self.input_shapes if f'next_{name}' in self.jits['metadata']['output_shapes']} + + stride, y_height, uv_height, _ = get_nv12_info(self.cam_w, self.cam_h) + self.frame_copy_size = stride * (y_height + uv_height) + + self._vision_input_names = ['img', 'big_img'] + self.vision_output_slices = pickle.loads(codecs.decode(self.jits['metadata']['metadata']['output_slices'].encode(), 'base64')) + + self.warp_frame_size = self.frame_copy_size + self._init_common() + self.run_model = self.jits['run'] + + self.input_queues = {name: Tensor(np.zeros(shape, dtype=dtype), device=self.model_device).realize() + for name, (shape, dtype) in self.input_shapes.items() if name in self.state_pairs} + shapes = {name: shape for name, (shape, _) in self.input_shapes.items() if name not in self.state_pairs and name != 'new_img'} + offsets, packed = pack_layout(shapes, self.warp_frame_size) + self.packed_input = np.zeros(packed, dtype=np.uint8) + self.host = Tensor(self.packed_input, device='NPY') + self.numpy_inputs = {name: np.ndarray(shape, dtype=np.float32, buffer=self.packed_input, offset=offsets[name]) + for name, shape in ({'tfm': (2, 3, 3)} | shapes).items()} + self.frames = self.packed_input[offsets['frames']:].reshape(2, self.warp_frame_size) + + self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in self.jits['output_specs'].items()} + for name, next_name in self.state_pairs.items(): + state = self.input_queues[name] + self.outputs[next_name] = input_view(state._buffer(), state.shape, state.dtype, 0) + + def copy_frames(self, bufs): + for i, key in enumerate(self._vision_input_names): + if key in bufs: + data = bufs[key].data if hasattr(bufs[key], 'data') else bufs[key] + np.copyto(self.frames[i], np.frombuffer(data, dtype=np.uint8, count=self.warp_frame_size)) + + def reset_warmup_buffers(self) -> None: + self.packed_input[:] = 0 + for key in self.state_pairs: + self.input_queues[key].assign(0).realize() + + def run(self): + self.run_model(host=self.host, **self.input_queues, output_buffers=self.outputs) + return self.outputs['outputs'] + + +def get_model_adapter(jits, cam_w, cam_h, model_device, queue_device, warp_device, chestnut=False): + if 'input_specs' in jits: + return NativeTinygradAdapter(jits, cam_w, cam_h, model_device, queue_device, warp_device, chestnut=chestnut) + else: + return LegacyModelAdapter(jits, cam_w, cam_h, model_device, queue_device, warp_device, chestnut=chestnut) diff --git a/openpilot/sunnypilot/modeld_v2/modeld.py b/openpilot/sunnypilot/modeld_v2/modeld.py index 6e31045b89..dda0f834a4 100755 --- a/openpilot/sunnypilot/modeld_v2/modeld.py +++ b/openpilot/sunnypilot/modeld_v2/modeld.py @@ -7,17 +7,18 @@ See the LICENSE.md file in the root directory for more details. """ from collections.abc import Callable +import gc import os os.environ['GMMU'] = '0' +from tinygrad.device import Device import numpy as np import threading import time from setproctitle import setproctitle -from tinygrad.tensor import Tensor import openpilot.cereal.messaging as messaging from openpilot.common.hardware import COMMA_HARDWARE -from openpilot.selfdrive.modeld.helpers import chestnut_present +from openpilot.selfdrive.modeld.helpers import chestnut_present, wait_for_chestnut from openpilot.cereal import log from opendbc.car.structs import car from openpilot.cereal.services import SERVICE_LIST @@ -25,34 +26,27 @@ from openpilot.cereal.messaging import PubMaster, SubMaster from openpilot.cereal.visionipc import VisionStreamType from msgq.visionipc import VisionIpcClient, VisionBuf from opendbc.car.car_helpers import get_demo_car_params -from openpilot.common.file_chunker import open_file_chunked from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params +from openpilot.common.hardware.usb import cable_connected from openpilot.common.filter_simple import FirstOrderFilter 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.selfdrive.modeld.modeld import ChestnutGpuState -from openpilot.selfdrive.modeld.compile_modeld import ( - MODELD_INPUTS, - make_input_queues as make_stock_input_queues, -) 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.parse_model_outputs import Parser from openpilot.sunnypilot.modeld_v2.constants import ModelConstants, 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, nv12_copy_size, - 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.modeld_v2.helpers import load_oob +from openpilot.sunnypilot.modeld_v2.helpers import load_model +from openpilot.sunnypilot.modeld_v2.frame_fusion import fused_with_warp +from openpilot.sunnypilot.modeld_v2.model_adapters import get_model_adapter from openpilot.sunnypilot.models.helpers import get_active_bundle from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController @@ -61,8 +55,7 @@ BIG_MODEL_TIMEOUT = 60 def _pkl_exists(path): - from openpilot.common.file_chunker import get_manifest_path - return os.path.exists(path) or os.path.exists(get_manifest_path(path)) + return os.path.exists(path) def _find_driving_pkl(bundle): @@ -117,58 +110,25 @@ class ModelState(ModelStateBase): def _init_combined(self, pkl_path, cam_w, cam_h, bundle): cloudlog.warning(f"loading combined pkl: {pkl_path}") - jits = load_oob(open_file_chunked(pkl_path)) + jits = load_model(pkl_path, extend=fused_with_warp(cam_w, cam_h, self.chestnut)) metadata = jits['metadata'] self.WARP_DEV = metadata.get('warp_dev', 'QCOM') if COMMA_HARDWARE else 'CPU' self.DEV = ('AMD' if self.chestnut else 'QCOM') if COMMA_HARDWARE else 'CPU' self.QUEUE_DEV = self.DEV - self.is_run_model = 'run_model' in jits - - nv12_info = get_nv12_info(cam_w, cam_h) - self.frame_copy_size = nv12_copy_size(*nv12_info[:3]) - self.full_frames: dict = {} - self._blob_cache: dict = {} - self.frame_buffers: dict = {} - - if self.is_run_model or 'model' in metadata: - model_metadata = metadata.get('model', metadata) - self.input_shapes = model_metadata['input_shapes'] - 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 = [key for key in self.input_shapes if 'img' in key] - self.frame_skip = derive_frame_skip({}, self.input_shapes) - if self.is_run_model: - self.input_queues, self.numpy_inputs, self.frame_buffers = make_stock_input_queues( - self.input_shapes, self.frame_skip, device=self.DEV, frame_copy_size=self.frame_copy_size) - self.frame_views, self.npy = self.frame_buffers, self.numpy_inputs - self.run_model, self.run_policy, self.warp = jits['run_model'][(cam_w, cam_h)], None, None - else: - self.input_queues, self.numpy_inputs = make_supercombo_input_queues(self.input_shapes, self.frame_skip, device=self.QUEUE_DEV) - self.run_model, self.run_policy, self.warp = None, jits['run_policy'], jits[(cam_w, cam_h)] - else: - self.run_model, self.run_policy, self.warp = None, jits['run_policy'], jits[(cam_w, cam_h)] - vision_metadata = metadata['vision'] - policy_keys = [k for k in metadata if k not in ('vision', 'warp_dev')] - self._combined_model_type = 'split' if policy_keys == ['policy'] else 'multi_policy' - self.vision_output_slices = vision_metadata['output_slices'] - self._policy_keys = policy_keys - 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) - 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) - - 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) - self.frame_buf_params = dict.fromkeys(self._vision_input_names, nv12_info) + self.adapter = get_model_adapter(jits, cam_w, cam_h, self.DEV, self.QUEUE_DEV, self.WARP_DEV, self.chestnut) + self.vision_output_slices = self.adapter.vision_output_slices + self.policy_output_slices = self.adapter.policy_output_slices + self._policy_slices_list = self.adapter._policy_slices_list + self._combined_model_type = self.adapter._combined_model_type + self._vision_input_names = self.adapter._vision_input_names + self.numpy_inputs = self.adapter.numpy_inputs + self._policy_keys = self.adapter._policy_keys + self._has_on_policy = self.adapter._has_on_policy + self._desire_key = self.adapter._desire_key + self._road_key = self.adapter._road_key + self._wide_key = self.adapter._wide_key + self.frame_buf_params = self.adapter.frame_buf_params is_20hz = bundle.is20hz if bundle else self._combined_model_type in ('split', 'multi_policy') if is_20hz: @@ -180,26 +140,10 @@ class ModelState(ModelStateBase): self.parser = Parser() self.prev_desire = np.zeros(self.constants.DESIRE_LEN, dtype=np.float32) - if self.warp is not None: - self.full_frames = {k: Tensor(np.zeros(nv12_info[3], dtype=np.uint8), device=self.WARP_DEV).contiguous().realize() for k in self._vision_input_names} - self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=self.full_frames[self._road_key], big_frame=self.full_frames[self._wide_key]) - def warmup(self) -> None: - dummy_size = self.frame_copy_size if self.is_run_model else self.frame_buf_params[self._road_key][3] - dummy_frames = {k: np.zeros(dummy_size, 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 = {k: np.zeros(v.shape, dtype=v.dtype) for k, v in self.numpy_inputs.items() if k not in ['tfm', 'big_tfm', 'prev_feat']} + dummy_frames, transforms, dummy_inputs = self.adapter.get_dummy_inputs() self.run(dummy_frames, transforms, dummy_inputs) - if self.is_run_model: - self.input_queues, self.numpy_inputs, self.frame_buffers = make_stock_input_queues( - self.input_shapes, self.frame_skip, device=self.DEV, frame_copy_size=self.frame_copy_size) - self.frame_views = self.frame_buffers - self.npy = self.numpy_inputs - else: - for v in self.numpy_inputs.values(): - v[:] = 0 - self.full_frames.clear() - self._blob_cache.clear() + self.adapter.reset_warmup_buffers() self.prev_desire[:] = 0 @property @@ -217,37 +161,29 @@ class ModelState(ModelStateBase): def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray], inputs: dict[str, np.ndarray], after_enqueue: Callable[[], None] | None = None) -> dict[str, np.ndarray] | None: - if self.is_run_model: - for key, buf in bufs.items(): - data = buf.data if hasattr(buf, 'data') else buf - np.copyto(self.frame_buffers[key], np.frombuffer(data, dtype=np.uint8, count=self.frame_copy_size)) - else: - for key, buf in bufs.items(): - ptr = np.frombuffer(buf.data, dtype=np.uint8).ctypes.data - cache_key = (key, ptr) - if cache_key not in self._blob_cache: - self._blob_cache[cache_key] = Tensor.from_blob(ptr, (self.frame_buf_params[key][3],), dtype='uint8', device=self.WARP_DEV) - self.full_frames[key] = self._blob_cache[cache_key] + self.adapter.copy_frames(bufs) 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.numpy_inputs[desire_key].flat if self.adapter.is_native else 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', 'action_t'): if key in self.numpy_inputs and key in inputs: self.numpy_inputs[key][:] = inputs[key] - self.numpy_inputs['tfm'][:, :] = transforms[self._road_key].reshape(3, 3) - self.numpy_inputs['big_tfm'][:, :] = transforms[self._wide_key].reshape(3, 3) - - if self.run_model is not None: - outs, = self.run_model(**{k: self.input_queues[k] for k in MODELD_INPUTS}) - raw_outputs = outs + if self.adapter.is_native: + for i, key in enumerate(self._vision_input_names): + if key in transforms: + self.numpy_inputs['tfm'][i] = transforms[key].reshape(3, 3) else: - assert self.warp is not None and self.run_policy is not None - warped = self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=self.full_frames[self._road_key], big_frame=self.full_frames[self._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) + self.numpy_inputs['tfm'][:, :] = transforms[self._road_key].reshape(3, 3) + self.numpy_inputs['big_tfm'][:, :] = transforms[self._wide_key].reshape(3, 3) + + raw_outputs = self.adapter.run() + if self.chestnut and (err := Device[self.DEV].error_state.host.view(fmt='q')[0]): + raise RuntimeError(f"chestnut failed with {err}") if after_enqueue is not None: after_enqueue() @@ -259,24 +195,25 @@ class ModelState(ModelStateBase): 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 and 'hidden_state' in self.vision_output_slices: - self.numpy_inputs['prev_feat'][:] = model_output[self.vision_output_slices['hidden_state']] + (self.numpy_inputs['prev_feat'].flat if self.adapter.is_native else 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']] + (self.numpy_inputs['prev_feat'].flat if self.adapter.is_native else 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 - 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) + if 'off' in self._policy_keys[i] and self._has_on_policy: + for key in ('plan', 'planplus', 'action'): + if any(key in self._policy_slices_list[j] for j, k in enumerate(self._policy_keys) if 'on' in k.lower()): + parsed.pop(key, None) outputs.update(parsed) @@ -319,19 +256,20 @@ class ModelState(ModelStateBase): def main(demo=False): cloudlog.warning("modeld init") - sentry.set_tag("daemon", PROCESS_NAME) cloudlog.bind(daemon=PROCESS_NAME) setproctitle(PROCESS_NAME) - config_realtime_process(7, 54) - CHESTNUT = chestnut_present() + CHESTNUT = _find_driving_pkl(get_active_bundle(chestnut=True)) is not None and (chestnut_present() or cable_connected()) if CHESTNUT: - os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' + from tinygrad.runtime.ops_amd import AMDDevice + AMDDevice.wait_timeout_ms = 3000 params = Params() params.put_bool("ChestnutLoading", CHESTNUT) params.remove("ChestnutActive") + gc.disable() + # visionipc clients while True: available_streams = VisionIpcClient.available_streams("camerad", block=False) @@ -364,6 +302,7 @@ def main(demo=False): def load_big(): nonlocal big_model try: + wait_for_chestnut() m = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, chestnut=True) m.warmup() big_model = m @@ -373,11 +312,13 @@ def main(demo=False): loader.start() loader.join(BIG_MODEL_TIMEOUT) model = big_model - if model is None: - params.put_bool("ChestnutModelError", True) - params.put_bool("ChestnutActive", model is not None) - if model is not None: - params.remove("ChestnutModelError") + if model is None and not params.get_bool("ChestnutRetry"): + # a crank can cut the chestnut power during the load, retry once with an onroad cycle + cloudlog.error("big model load failed, cycling onroad to retry") + params.put_bool("ChestnutRetry", True) + params.put_bool("OnroadCycleRequested", True) + else: + params.put_bool("ChestnutActive", model is not None) small_model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, chestnut=False) if model is None or CHESTNUT else None if model is None: @@ -386,13 +327,16 @@ def main(demo=False): assert model is not None cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") + config_realtime_process(7, 54) + # messaging - pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutState"] if CHESTNUT else []) + pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutGpuState"] if CHESTNUT else []) pm = PubMaster(pub_socks) - sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) + sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", + "carControl", "controlsState", "lateralDelay"]) publish_state = PublishState() - chestnut_state = ChestnutState(pm, model.chestnut) if CHESTNUT else None + chestnut_state = ChestnutGpuState(pm, model.chestnut) if CHESTNUT else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / model.constants.MODEL_FREQ) @@ -514,13 +458,12 @@ def main(demo=False): mt1 = time.perf_counter() try: send_chestnut = (chestnut_state is not None and - run_count % round(model.constants.MODEL_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0) + run_count % round(model.constants.MODEL_FREQ / SERVICE_LIST['chestnutGpuState'].frequency) == 0) model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None) except Exception: - if not params.get_bool("ChestnutActive"): + if not model.chestnut: raise - cloudlog.exception("chestnut failed, falling back to small") - params.put_bool("ChestnutModelError", True) + cloudlog.exception("big model failed, fall back to small") params.put_bool("ChestnutActive", False) assert small_model is not None model = small_model @@ -537,6 +480,8 @@ def main(demo=False): posenet_send = messaging.new_message('cameraOdometry') mdv2sp_send = messaging.new_message('modelDataV2SP') + if not sm['carControl'].latActive: + prev_action.desiredCurvature = sm['controlsState'].curvature 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, @@ -574,5 +519,5 @@ if __name__ == "__main__": except KeyboardInterrupt: cloudlog.warning(f"child {PROCESS_NAME} got SIGINT") except Exception: - sentry.capture_exception() + cloudlog.exception("modeld exception") raise diff --git a/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1344x760_tinygrad.pkl b/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1344x760_tinygrad.pkl new file mode 100644 index 0000000000..39c5f9b2ba --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1344x760_tinygrad.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1689356ff8e516f87e3d75f6b53e66953a11ee5a97e859b4808c9a16bfe671b +size 3677806 diff --git a/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1928x1208_tinygrad.pkl b/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1928x1208_tinygrad.pkl new file mode 100644 index 0000000000..cef1d05c92 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1928x1208_tinygrad.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:56cc9690e0e9fb719323d2c6646c1305e8bc6bbdfdc807724a605a0d0a37554b +size 7904366 diff --git a/openpilot/sunnypilot/modeld_v2/models/driving_warp_1344x760_tinygrad.pkl b/openpilot/sunnypilot/modeld_v2/models/driving_warp_1344x760_tinygrad.pkl new file mode 100644 index 0000000000..cdda8e8f69 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/models/driving_warp_1344x760_tinygrad.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b22fd6bcb3a3e8a642e6866449dd9c30c12e694ff0741cad9e34e4b00d8c5fa +size 3698799 diff --git a/openpilot/sunnypilot/modeld_v2/models/driving_warp_1928x1208_tinygrad.pkl b/openpilot/sunnypilot/modeld_v2/models/driving_warp_1928x1208_tinygrad.pkl new file mode 100644 index 0000000000..565952f2ff --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/models/driving_warp_1928x1208_tinygrad.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a17fd6acece4fab23784ef0f3f8bba28a049e9599e5e9e5ee7b36e4b1d24a6f +size 7924847 diff --git a/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py b/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py new file mode 100644 index 0000000000..db761a0540 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py @@ -0,0 +1,110 @@ +""" +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 dataclasses +import mmap +import tempfile +from contextlib import contextmanager +from unittest import mock + +import numpy as np + +from openpilot.sunnypilot.modeld_v2.helpers import Blob, flatten + +# Compile time only/ see discountchubbs 4 assistance: turns a captured TinyJit into plain data that helpers.load_model rebuilds. It reads internals of the +# tinygrad that compiles the model, so a tinygrad bump that breaks it blocks new builds, never loading already built models. +@contextmanager +def record_kernel_linear(): + import tinygrad.engine.jit as jit + import tinygrad.engine.realize as realize + recorded, hcq_compile, compile_linear = [], realize.hcq_compile, jit.compile_linear + + def recording_compile_linear(*args, **kwargs): + with mock.patch.object(realize, "hcq_compile", lambda linear, *a, **k: (linear.src and recorded.append(linear), hcq_compile(linear, *a, **k))[1]): + return compile_linear(*args, **kwargs) + + with mock.patch.object(jit, "compile_linear", recording_compile_linear): + yield recorded + + +def capture_frame(jit, kwargs: dict, output_container: str | None = None): + inputs = [([name] if key is None else [name, key], tensor.numpy().copy(), tensor.device) + for name, value in kwargs.items() for key, tensor in (value.items() if isinstance(value, dict) else [(None, value)])] + result = jit(**kwargs) + outputs = kwargs[output_container].values() if output_container else flatten(result) + return {"inputs": inputs, "outputs": [tensor.numpy().copy() for tensor in outputs], "output_container": output_container}, result + + +def kernel_signature(ast, elf) -> list: + from tinygrad.dtype import AddrSpace + if hasattr(ast, "kernel_params"): + if any(p.arg.addrspace is AddrSpace.ALU for p in ast.kernel_params): + raise ValueError(f"{elf.name} takes a variable, which plain artifacts don't support") + compact = {slot: i for i, slot in enumerate(ast.arg.globals)} + return [[p.arg.name, compact[p.arg.slot], repr(p.dtype), [int(x) for x in p._shape]] for p in ast.kernel_params] + if not all(isinstance(slot, int) for _, slot, _, _ in elf.signature): + raise ValueError("TinyELF.signature lost its slots and this tinygrad has no UOp.kernel_params: update kernel_signature") + return [[name, slot, repr(dtype), [int(x) for x in shape]] for name, slot, dtype, shape in elf.signature] + + +def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict, memoryview]: + from tinygrad.uop.ops import Ops + sink = tempfile.TemporaryFile(dir=".") + blob, bases, input_specs = Blob(sink), {}, {} + + def store(array) -> dict: + array = np.ascontiguousarray(array) + return {"data": blob.add(array.tobytes()), "dtype": array.dtype.str, "shape": list(array.shape)} + + def describe(uop) -> dict: + if uop.op is Ops.PARAM: + input_specs.setdefault(uop.arg.slot, {"dtype": repr(uop.dtype), "numel": int(uop.max_numel()), "device": uop.device}) + return {"input": uop.arg.slot} + root, offset = uop, 0 + while root.op in (Ops.AFTER, Ops.BITCAST, Ops.SHRINK): + offset, root = offset + (root.src[1].arg * root.src[0].dtype.itemsize if root.op is Ops.SHRINK else 0), root.src[0] + if root.op is Ops.ALLOC: + base = bases.setdefault(id(root), (len(bases), root, root.dtype)) + return {"buffer": base[0], "offset": offset, "nbytes": int(uop.max_numel()) * uop.dtype.itemsize, "dtype": repr(uop.dtype)} + buffer = uop.buffer + root = buffer._base if buffer._base is not None else buffer + base = bases.setdefault(id(root), (len(bases), root, uop.base.dtype if buffer._base is None else uop.dtype)) + return {"buffer": base[0], "offset": buffer.offset if buffer._base is not None else 0, "nbytes": buffer.nbytes, "dtype": repr(uop.dtype)} + + calls, written, needs_init = [], set(), set() + for call in kernel_linear.src: + ast, arguments = call.src[0], [describe(argument) for argument in call.src[1:]] + if ast.op is Ops.PROGRAM: + elf, info = ast.to_elf(), ast.arg + global_size, local_size = info.launch_dims({}) + calls.append({"kind": "program", "name": elf.name, "lib": blob.add(bytes(elf.lib)), "target": dataclasses.asdict(elf.target), + "signature": kernel_signature(ast, elf), + "global_size": [int(x) for x in global_size], "local_size": [int(x) for x in local_size], + "globals": list(info.globals), "outs": list(info.outs), "ins": list(info.ins), "args": arguments}) + outs, ins = info.outs, info.ins + elif ast.op is Ops.STORE: # a copy: STORE(PARAM dst, PARAM src) + calls.append({"kind": "copy", "args": arguments}) + outs, ins = (0,), (1,) + else: + raise ValueError(f"unsupported call {ast.op} {ast.arg}") + needs_init |= {arguments[i]["buffer"] for i in ins if "buffer" in arguments[i] and arguments[i]["buffer"] not in written} + written |= {arguments[i]["buffer"] for i in outs if "buffer" in arguments[i]} + + ret = jit.captured.ret + returns = {"kind": type(ret).__name__, "tensors": [describe(t.uop.base) | {"shape": [int(x) for x in t.shape]} for t in flatten(ret)]} + from tinygrad.device import Buffer + assert not [base for index, base, _ in bases.values() if index in needs_init and not isinstance(base, Buffer)], "a kernel reads a scratch before writing it" + buffers = [{"nbytes": base.nbytes if isinstance(base, Buffer) else int(base.max_numel()) * dtype.itemsize, "device": base.device, "dtype": repr(dtype)} + | ({"init": blob.add(base.as_memoryview())} if index in needs_init else {}) for index, base, dtype in bases.values()] + names = list(jit.captured.expected_names) + if check is not None: + check = {"inputs": [[path, store(array), device] for path, array, device in check["inputs"]], "outputs": [store(array) for array in check["outputs"]], + "output_container": check["output_container"]} + header = {"input_names": names, "buffers": buffers, "calls": calls, "returns": returns, "check": check, + "inputs": [{"slot": slot, "name": names[slot] if slot < len(names) else None} | input_specs[slot] for slot in sorted(input_specs)]} + with sink: + sink.flush() + return header, memoryview(mmap.mmap(sink.fileno(), 0, access=mmap.ACCESS_READ) if blob.size else b"") diff --git a/openpilot/sunnypilot/modeld_v2/stock_dependencies.py b/openpilot/sunnypilot/modeld_v2/stock_dependencies.py new file mode 100755 index 0000000000..5281829e5d --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/stock_dependencies.py @@ -0,0 +1,184 @@ +""" +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 +from collections import namedtuple +from tinygrad.tensor import Tensor +from tinygrad.helpers import Context +from tinygrad.device import Device + +""" +Frozen in time compile_modeld dependencies to support all models prior to transition to tinygrad compilation. +This file is not meant to be modified. +""" +NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size']) +MODELD_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'] + + +def nv12_copy_size(stride: int, y_height: int, uv_height: int) -> int: + return stride * (y_height + uv_height) + + +def warp_perspective_tinygrad(src_flat, M_inv, dst_shape, src_shape, stride_pad, border_fill_val=None): + w_dst, h_dst = dst_shape + h_src, w_src = src_shape + + x = Tensor.arange(w_dst).reshape(1, w_dst).expand(h_dst, w_dst).reshape(-1) + y = Tensor.arange(h_dst).reshape(h_dst, 1).expand(h_dst, w_dst).reshape(-1) + + src_x = M_inv[0, 0] * x + M_inv[0, 1] * y + M_inv[0, 2] + src_y = M_inv[1, 0] * x + M_inv[1, 1] * y + M_inv[1, 2] + src_w = M_inv[2, 0] * x + M_inv[2, 1] * y + M_inv[2, 2] + + src_x = src_x / src_w + src_y = src_y / src_w + + x_round = Tensor.round(src_x) + y_round = Tensor.round(src_y) + x_nn_clipped = x_round.clip(0, w_src - 1).cast('int') + y_nn_clipped = y_round.clip(0, h_src - 1).cast('int') + idx = y_nn_clipped * (w_src + stride_pad) + x_nn_clipped + sampled = src_flat[idx] + + if border_fill_val is None: + return sampled + + in_bounds = ((x_round >= 0) & (x_round <= w_src - 1) & + (y_round >= 0) & (y_round <= h_src - 1)).cast(sampled.dtype) + return sampled * in_bounds + Tensor(border_fill_val, dtype=sampled.dtype) * (1 - in_bounds) + + +def frames_to_tensor(frames): + H = (frames.shape[0] * 2) // 3 + W = frames.shape[1] + in_img1 = Tensor.cat(frames[0:H:2, 0::2], + frames[1:H:2, 0::2], + frames[0:H:2, 1::2], + frames[1:H:2, 1::2], + frames[H:H+H//4].reshape((H//2, W//2)), + frames[H+H//4:H+H//2].reshape((H//2, W//2)), dim=0).reshape((6, H//2, W//2)) + return in_img1 + + +def make_frame_prepare(nv12: NV12Frame, model_w, model_h): + cam_w, cam_h, stride, y_height, uv_height, _ = nv12 + uv_offset = stride * y_height + stride_pad = stride - cam_w + + def frame_prepare_tinygrad(input_frame, M_inv): + M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=Device.DEFAULT) + uv = input_frame[uv_offset:uv_offset + uv_height * stride].reshape(uv_height, stride) + with Context(SPLIT_REDUCEOP=0): + y = warp_perspective_tinygrad(input_frame[:cam_h*stride], + M_inv, (model_w, model_h), + (cam_h, cam_w), stride_pad).realize() + u = warp_perspective_tinygrad(uv[:cam_h//2, :cam_w:2].flatten(), + M_inv_uv, (model_w//2, model_h//2), + (cam_h//2, cam_w//2), 0).realize() + v = warp_perspective_tinygrad(uv[:cam_h//2, 1:cam_w:2].flatten(), + M_inv_uv, (model_w//2, model_h//2), + (cam_h//2, cam_w//2), 0).realize() + yuv = y.cat(u).cat(v).reshape((model_h * 3 // 2, model_w)) + tensor = frames_to_tensor(yuv) + return tensor + return frame_prepare_tinygrad + + +def _detect_desire_key(shapes: dict) -> str | None: + return next((key for key in shapes if key.startswith('desire')), None) + + +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 get_policy_npy_shapes(input_shapes): + shapes = {} + desire_key = _detect_desire_key(input_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 'features_buffer' in input_shapes: + fb = input_shapes['features_buffer'] + shapes['prev_feat'] = (fb[0], math.prod(fb[2:])) + + sizes = [int(np.prod(size)) for size in shapes.values()] + return shapes, sizes + + +def make_input_queues(input_shapes, frame_skip, device, frame_copy_size): + road_key, _ = _detect_vision_keys(input_shapes) + img = input_shapes[road_key] + fb = input_shapes['features_buffer'] + feat_dim = math.prod(fb[2:]) + dp = input_shapes[_detect_desire_key(input_shapes)] + n_frames = img[1] // 6 + img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img[2], img[3]) + + policy_shapes, _ = get_policy_npy_shapes(input_shapes) + shapes = {'tfm': (3, 3), 'big_tfm': (3, 3)} | policy_shapes + sizes = [math.prod(s) for s in shapes.values()] + packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize + packed_input = np.zeros(packed_npy_size + 2 * frame_copy_size, dtype=np.uint8) + packed_npy_inputs = packed_input[:packed_npy_size].view(np.float32) + frames = packed_input[packed_npy_size:] + frame_views = {'img': frames[:frame_copy_size], 'big_img': frames[frame_copy_size:]} + npy = {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)} + input_queues = { + 'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), + 'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), + 'feat_q': Tensor(np.zeros((frame_skip * fb[1], fb[0], feat_dim), dtype=np.float32), device=device).contiguous().realize(), + 'desire_q': Tensor(np.zeros((frame_skip * dp[1], dp[0], dp[2]), dtype=np.float32), device=device).contiguous().realize(), + 'packed_npy_inputs': Tensor(packed_input, device='NPY').realize(), + } + return input_queues, npy, frame_views + + +def shift_and_sample(buf, new_val, sample_fn): + buf.assign(buf[1:].cat(new_val, dim=0).contiguous()) + return sample_fn(buf) + + +def sample_skip(buf, frame_skip): + return buf[::frame_skip].contiguous().flatten(0, 1).unsqueeze(0) + + +def sample_desire(buf, frame_skip): + return buf.reshape(-1, frame_skip, *buf.shape[1:]).max(1).flatten(0, 1).unsqueeze(0) + + +def make_warp(nv12, model_w, model_h): + frame_prepare = make_frame_prepare(nv12, model_w, model_h) + def warp(tfm, big_tfm, frame, big_frame): + tfm = tfm.to(Device.DEFAULT) + big_tfm = big_tfm.to(Device.DEFAULT) + frame = frame.to(Device.DEFAULT) + big_frame = big_frame.to(Device.DEFAULT) + Tensor.realize(tfm, big_tfm, frame, big_frame) + warped_frame = frame_prepare(frame, tfm).unsqueeze(0) + warped_big_frame = frame_prepare(big_frame, big_tfm).unsqueeze(0) + return Tensor.cat(warped_frame, warped_big_frame) + return warp + + +def make_run_model(warp, run_policy, model_metadata, frame_copy_size): + _, policy_sizes = get_policy_npy_shapes(model_metadata['input_shapes']) + packed_npy_size = (18 + sum(policy_sizes)) * np.dtype(np.float32).itemsize + def run_model(img_q, big_img_q, feat_q, desire_q, packed_npy_inputs): + packed_input = packed_npy_inputs.to(Device.DEFAULT) + Tensor.realize(packed_input) + packed_npy_inputs = packed_input[:packed_npy_size].bitcast('float32') + frame = packed_input[packed_npy_size:packed_npy_size + frame_copy_size] + big_frame = packed_input[packed_npy_size + frame_copy_size:] + tfm, big_tfm, policy_inputs = packed_npy_inputs.split([9, 9, sum(policy_sizes)]) + warped = warp(tfm.reshape(3, 3), big_tfm.reshape(3, 3), frame, big_frame) + return run_policy(warped, img_q, big_img_q, feat_q, desire_q, policy_inputs) + return run_model diff --git a/openpilot/sunnypilot/modeld_v2/tests/helpers.py b/openpilot/sunnypilot/modeld_v2/tests/helpers.py index 8f148f6789..82a6180039 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/helpers.py +++ b/openpilot/sunnypilot/modeld_v2/tests/helpers.py @@ -7,6 +7,8 @@ See the LICENSE.md file in the root directory for more details. import pathlib import tempfile +import codecs +import pickle import openpilot.sunnypilot.models.helpers as helpers import openpilot.sunnypilot.modeld_v2.modeld as modeld_module @@ -161,6 +163,23 @@ ARCHETYPES = { def make_pkl_data(archetype): + if archetype.expected_model_type == 'supercombo': + slices_b64 = codecs.encode(pickle.dumps(archetype.metadata_structure['model']['output_slices']), 'base64').decode() + return { + 'metadata': { + 'metadata': {'output_slices': slices_b64}, + 'model': { + 'input_shapes': archetype.metadata_structure['model']['input_shapes'], + 'output_slices': archetype.metadata_structure['model']['output_slices'] + }, + 'input_shapes': archetype.metadata_structure['model']['input_shapes'], + 'output_slices': archetype.metadata_structure['model']['output_slices'], + 'output_shapes': {} + }, + 'run_policy': _noop_jit, + (CAM_W, CAM_H): _noop_jit + } + return { 'metadata': archetype.metadata_structure, 'run_policy': _noop_jit, @@ -169,10 +188,8 @@ def make_pkl_data(archetype): 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: - dump_oob(make_pkl_data(archetype), f) + pkl_path.write_bytes(pickle.dumps(make_pkl_data(archetype))) return pkl_path @@ -192,6 +209,7 @@ def patch_modeld(monkeypatch): def _patch(bundle): monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None, *, chestnut=None: bundle) monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=None: bundle) + monkeypatch.setattr(modeld_module, 'load_model', lambda path, **_: pickle.loads(pathlib.Path(path).read_bytes())) return _patch diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py b/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py index 88cee8c5b4..db0eaf8703 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py @@ -12,7 +12,7 @@ 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 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 + SPLIT_VISION_INPUT_SHAPES from openpilot.common.test import OpenpilotTestCase # resolved by name from this module when a test asks for them @@ -66,21 +66,6 @@ class TestModelStateCombinedInit(OpenpilotTestCase): class TestStockEquivalence(OpenpilotTestCase): - - def test_split_queue_keys_match_stock(self, model_state_factory): - from openpilot.selfdrive.modeld.compile_modeld import make_input_queues - from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip - - state = model_state_factory(ARCHETYPES['vision_policy_split']) - - frame_skip = derive_frame_skip(SPLIT_VISION_INPUT_SHAPES, SPLIT_POLICY_INPUT_SHAPES) - stock_shapes = {**SPLIT_VISION_INPUT_SHAPES, **SPLIT_POLICY_INPUT_SHAPES, 'action_t': (1, 2)} - stock_queues, stock_npy, _frame_views = make_input_queues(stock_shapes, frame_skip, device='NPY', frame_copy_size=49152) - - # sunnypilot split pipeline has tfm/big_tfm as queues (stock has them in npy only) - assert set(stock_queues.keys()) <= set(state.input_queues.keys()) - assert {'desire', 'traffic_convention'} <= set(state.numpy_inputs.keys()) - 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 @@ -105,20 +90,23 @@ class TestStockEquivalence(OpenpilotTestCase): def test_unified_run_model(self, tmp_path, monkeypatch, patch_modeld): from openpilot.common.hardware import hw - from openpilot.selfdrive.modeld.helpers import dump_oob shapes = {'img': (1, 12, 128, 256), 'big_img': (1, 12, 128, 256), 'features_buffer': (1, 24, 32, 512), 'desire_pulse': (1, 25, 8), 'traffic_convention': (1, 2), 'action_t': (1, 2)} - pkl_data = {'metadata': {'model': {'input_shapes': shapes, 'output_slices': {}}}, - 'run_model': {(CAM_W, CAM_H): tests_helpers._noop_jit}} - with open(tmp_path / 'driving_test_tinygrad.pkl', 'wb') as f: - dump_oob(pkl_data, f) + import codecs + import pickle + slices_b64 = codecs.encode(pickle.dumps({}), 'base64').decode() + pkl_data = { + 'metadata': {'model': {'input_shapes': shapes, 'output_slices': {}}, 'metadata': {'output_slices': slices_b64}, + 'input_shapes': shapes, 'output_slices': {}, 'output_shapes': {}}, + (CAM_W, CAM_H): tests_helpers._noop_jit, + 'run_policy': tests_helpers._noop_jit, + } + (tmp_path / 'driving_test_tinygrad.pkl').write_bytes(pickle.dumps(pkl_data)) bundle = DummyBundle(models=[DummyModel('supercombo', 'driving_test_tinygrad.pkl')]) patch_modeld(bundle) monkeypatch.setattr(hw.Paths, 'model_root', staticmethod(lambda: str(tmp_path))) state = ModelState(cam_w=CAM_W, cam_h=CAM_H) - assert state.is_run_model and state.run_model is not None - assert state.run_policy is None and state.warp is None - assert 'img' in state.frame_views and 'big_img' in state.frame_views + assert state.adapter.run_policy is not None ARCHETYPE_NAMES = list(ARCHETYPES.keys()) @@ -193,7 +181,7 @@ class TestInputQueueCreation(OpenpilotTestCase): 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" + assert len(state.adapter.input_queues) > 0, f"{arch.name}: input_queues empty" @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_npy_contains_transforms(self, archetype_name, model_state_factory): diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py index 7f6636fb95..348b4aca6a 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py @@ -5,16 +5,12 @@ 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 import unittest -from pathlib import Path import numpy as np from openpilot.common.parameterized import parameterized -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.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, _detect_desire_key from openpilot.common.test import OpenpilotTestCase @@ -168,36 +164,6 @@ class TestOutputSlicePreservation(OpenpilotTestCase): "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 @@ -225,6 +191,7 @@ class Test4DFeaturesBuffer(OpenpilotTestCase): class TestStockCompileModeldEquivalence(OpenpilotTestCase): + @unittest.skip("upstream removed selfdrive/modeld/compile_modeld.py — no stock implementation to compare") 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 @@ -243,6 +210,7 @@ class TestStockCompileModeldEquivalence(OpenpilotTestCase): assert sunny_sizes == stock_sizes assert sunny_shapes['prev_feat'] == (1, 512) + @unittest.skip("upstream removed selfdrive/modeld/compile_modeld.py — no stock implementation to compare") 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 @@ -280,5 +248,3 @@ class TestStockCompileModeldEquivalence(OpenpilotTestCase): 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_convert_upstream_pkl.py b/openpilot/sunnypilot/modeld_v2/tests/test_convert_upstream_pkl.py new file mode 100644 index 0000000000..3deffcfc71 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/tests/test_convert_upstream_pkl.py @@ -0,0 +1,121 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import os +import re +import sys +import tempfile +import unittest +from unittest import mock +import numpy as np + +os.environ.setdefault("CHECK_OOB", "0") +os.environ.setdefault("GMMU", "0") +if sys.platform == "darwin" and os.environ.get("DEV", "").startswith("MOCKUSB"): # the mock's VRAM is a memfd, which macOS lacks: an unlinked file is the same + from tinygrad.runtime.autogen import libc + + def _memfd_create(name, flags): + fd, path = tempfile.mkstemp() + os.unlink(path) + return fd + libc.memfd_create = _memfd_create + +from openpilot.common.test import OpenpilotTestCase +from openpilot.sunnypilot.modeld_v2 import convert_upstream_pkl +from openpilot.sunnypilot.modeld_v2.convert_upstream_pkl import convert +from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, load_model +from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame + +IMG, DESIRE, STATE, OUT = (6, 8, 8), (8,), (4, 16), (1, 32) + + +class TestConvertUpstreamPkl(OpenpilotTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + if not os.environ.get("TINYGRAD_TESTS"): + raise unittest.SkipTest("TINYGRAD_TESTS not set. skipping converter test") + from tinygrad import Device, Tensor + assert Device.DEFAULT == "AMD" and getattr(Device["AMD"], "is_usb", False), f"{Device.DEFAULT} is not an AMD device behind the USB bridge" + assert (Tensor([1, 2, 3]) * 2).tolist() == [2, 4, 6] + cls.tmp = tempfile.TemporaryDirectory() + cls.rng = np.random.default_rng(0) + cls.weights = Tensor(cls.rng.standard_normal((16, 32)).astype(np.float32)).realize() + cls.bias = Tensor(cls.rng.standard_normal((1, 32)).astype(np.float32)).realize() + cls.input_specs = {"input_imgs": (IMG, "uint8", Device.DEFAULT), "desire": (DESIRE, "float32", Device.DEFAULT), + "features": (STATE, "float32", Device.DEFAULT)} + cls.output_specs = {"outputs": (OUT, "float32", Device.DEFAULT), "next_features": (STATE, "float32", Device.DEFAULT)} + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + super().tearDownClass() + + def model(self, input_imgs, desire, features, output_buffers): + from tinygrad import Tensor + feat = features.mean(axis=0).reshape(1, 16) + input_imgs.float().mean() + desire.sum() + Tensor.realize(output_buffers["outputs"].assign((feat @ self.weights + self.bias).relu()), + output_buffers["next_features"].assign(features[1:].cat(feat * 2))) + + def inputs(self, seed: int) -> dict: + from examples.openpilot.helpers import allocate_inputs + rng = np.random.default_rng(seed) + + def initialize(arrays: dict): + for value in arrays.values(): + value[...] = rng.standard_normal(value.shape) if value.dtype.kind == "f" else rng.integers(0, 256, value.shape) + return allocate_inputs(self.input_specs, initialize) | {"output_buffers": allocate_inputs(self.output_specs)} + + def jit(self): + from tinygrad import TinyJit + run = TinyJit(self.model, prune=True) + kwargs = self.inputs(1) + run(**kwargs) + run(**kwargs) + return run + + def assert_matches_eager(self, plain, seed: int): + fresh, expected = self.inputs(seed), self.inputs(seed) + plain(**fresh) + self.model(**expected) + for name in self.output_specs: + assert np.array_equal(fresh["output_buffers"][name].numpy(), expected["output_buffers"][name].numpy()), name + + def convert(self, jit) -> tuple[dict, memoryview]: + from tinygrad import Device + kwargs = self.inputs(2) + frame, _ = capture_frame(jit, kwargs, "output_buffers") + header, blob = convert(jit, kwargs, frame, Device[Device.DEFAULT].renderer.target) + kernels = jit.captured._linear.src[0].without_after.arg.aux.kernels + assert [call["name"] for call in header["calls"]] == [re.sub(r"\x1b\[[0-9;]*m", "", kernel[1]) for kernel in kernels] + assert all(re.fullmatch(r"\w+", call["name"]) for call in header["calls"]), "kernel names: failure point 1" + assert [(tuple(call["outs"]), tuple(call["ins"])) for call in header["calls"]] == [tuple(kernel[6]) for kernel in kernels], "outs/ins: failure point 1" + bound = {argument["input"] for call in header["calls"] for argument in call["args"] if "input" in argument} + assert bound == {spec["slot"] for spec in header["inputs"]}, f"input slots bound by pokes {sorted(bound)}: failure point 4" + assert [spec["name"] for spec in header["inputs"]][:len(self.input_specs)] == sorted(self.input_specs) + return header, blob + + def test_converted_jit_matches_eager(self): # the submit decoded into plain calls, replayed against its own frame + jit = self.jit() + plain = PlainJit(*self.convert(jit)) + self.assert_matches_eager(plain, 3) + self.assert_matches_eager(plain, 4) + + def test_pickle_round_trip_through_main(self): + from examples.openpilot.helpers import dump_pickle, load_pickle + from tinygrad.engine.realize import lower_and_compile + pkl, out = os.path.join(self.tmp.name, "tiny_tinygrad.pkl"), os.path.join(self.tmp.name, "tiny_plain.pkl") + dump_pickle({"run": self.jit(), "input_specs": self.input_specs, "output_specs": self.output_specs, "metadata": {"name": "tiny"}}, pkl) + + artifact = load_pickle(pkl, out_of_band=True) + artifact["run"].captured._linear = lower_and_compile(artifact["run"].captured._linear) + self.assert_matches_eager(PlainJit(*self.convert(artifact["run"])), 5) + + with mock.patch.object(sys, "argv", ["convert_upstream_pkl", pkl, out]): + convert_upstream_pkl.main() + bundle = load_model(out, cache=False, verify=True) + assert bundle["metadata"] == {"name": "tiny"} and bundle["input_specs"] == self.input_specs and bundle["output_specs"] == self.output_specs + self.assert_matches_eager(bundle["run"], 6) diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py b/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py index 1cc8410f06..9d431c1803 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py @@ -5,13 +5,11 @@ This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ -import io +import pickle import requests -from openpilot.common.file_chunker import get_chunk_name from openpilot.common.hardware import hw from openpilot.common.test import OpenpilotTestCase -from openpilot.selfdrive.modeld.helpers import dump_oob import openpilot.sunnypilot.modeld_v2.modeld as modeld_module from openpilot.sunnypilot.modeld_v2.tests import helpers as tests_helpers from openpilot.sunnypilot.modeld_v2.tests.helpers import DummyModel, DummyBundle, CAM_W, CAM_H @@ -43,14 +41,11 @@ class TestFallback(OpenpilotTestCase): small_json = requests.get(ModelFetcher.MODEL_URL).json() small_bundle = ModelParser.parse_models(small_json)[-1] - buf = io.BytesIO() - dump_oob(tests_helpers.make_pkl_data(tests_helpers.ARCHETYPES['supercombo_non20hz']), buf) - oob_bytes = buf.getvalue() + oob_bytes = pickle.dumps(tests_helpers.make_pkl_data(tests_helpers.ARCHETYPES['supercombo_non20hz'])) + monkeypatch.setattr(modeld_module, 'load_model', lambda path, **_: pickle.loads(open(path, 'rb').read())) for bundle in (big_bundle, small_bundle): - artifact = bundle.models[0].artifact - for i in range(len(artifact.chunks)): - (tmp_path / get_chunk_name(artifact.fileName, i, len(artifact.chunks))).write_bytes(oob_bytes if i == 0 else b"") + (tmp_path / bundle.models[0].artifact.fileName).write_bytes(oob_bytes) monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=None: small_bundle) assert modeld_module.ModelState(CAM_W, CAM_H, chestnut=False).chestnut is False diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_models.py b/openpilot/sunnypilot/modeld_v2/tests/test_models.py index 2051d4ed7f..d24b629e77 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_models.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_models.py @@ -7,37 +7,14 @@ See the LICENSE.md file in the root directory for more details. import os import unittest -from unittest.mock import patch -from openpilot.common.file_chunker import open_file_chunked -from openpilot.sunnypilot.modeld_v2.helpers import load_oob -from tinygrad.device import Device + +from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, load_model -class TestLegacyModels(unittest.TestCase): - def test_legacy_model_load(self): - base_name = os.environ.get("MODEL_BASE_NAME") - if not base_name: - raise unittest.SkipTest("MODEL_BASE_NAME env var not set, skipping integration test.") - chunk_dir = os.environ.get("MODEL_CHUNK_DIR", "/tmp/model_chunks") - base_path = os.path.join(chunk_dir, base_name) - - try: - f = open_file_chunked(base_path) - except Exception as error: - self.fail(f"Failed to open chunked file {base_path}: {error}") - self.addCleanup(f.close) - - real_getitem = Device.__class__.__getitem__ - - def safe_getitem(device_self, ix): - if ix == "QCOM" and not os.path.exists("/dev/kgsl-3d0"): - return real_getitem(device_self, "CPU") - if ix == "AMD" and not os.path.exists("/dev/kfd"): - return real_getitem(device_self, "CPU") - return real_getitem(device_self, ix) - - with patch.object(Device.__class__, "__getitem__", safe_getitem): - obj = load_oob(f) - - assert isinstance(obj, dict), "Parsed object is not a dictionary" - assert "metadata" in obj, "Metadata key is missing" +class TestRegistryModels(unittest.TestCase): + def test_model_loads(self): + if not (base_name := os.environ.get("MODEL_BASE_NAME")): + self.skipTest("MODEL_BASE_NAME env var not set, skipping integration test.") + bundle = load_model(os.path.join(os.environ.get("MODEL_DIR", "/tmp/models"), base_name), {"QCOM": "CPU", "AMD": "CPU"}, verify=False, cache=False) + jits = [value for value in bundle.values() if isinstance(value, PlainJit)] + assert "metadata" in bundle and jits and all(len(jit.captured.linear.src) > 0 for jit in jits) diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_plain_artifact.py b/openpilot/sunnypilot/modeld_v2/tests/test_plain_artifact.py new file mode 100644 index 0000000000..80bebc119b --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/tests/test_plain_artifact.py @@ -0,0 +1,162 @@ +""" +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 dataclasses +import functools +import inspect +import os +import sys +import tempfile +from unittest import mock +import numpy as np +import unittest + +import openpilot.sunnypilot.modeld_v2.helpers as helpers +from openpilot.common.test import OpenpilotTestCase +from openpilot.sunnypilot.modeld_v2 import compile_warp +from openpilot.sunnypilot.modeld_v2.frame_fusion import frame_part, pack_layout +from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, cache_is_fresh, load_model, read_parts, write_bundle +from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit + +CAM_W, CAM_H, MODEL_W, MODEL_H = 32, 16, 16, 8 +FRAME = f"{CAM_W},{CAM_H},{CAM_W},{CAM_H},{CAM_H // 2},{CAM_W * CAM_H * 3 // 2}" +FRAME_SIZE = CAM_W * CAM_H * 3 // 2 +HOST = {"desire": (8,), "traffic_convention": (2,)} +STATE, OUT = (4, 16), (1, 32) + + +def compile_jit(fn, inputs: dict, output_container: str | None = None) -> tuple[dict, memoryview]: + from tinygrad import TinyJit + run = TinyJit(fn, prune=True) + with record_kernel_linear() as recorded: + run(**inputs) + run(**inputs) + check, _ = capture_frame(run, inputs, output_container) + assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}" + return serialize_jit(run, recorded[0], check) + + +class TestPlainArtifact(OpenpilotTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + if not os.environ.get("TINYGRAD_TESTS"): + raise unittest.SkipTest("TINYGRAD_TESTS env var not set, skipping artifact test.") + from tinygrad import Tensor + cls.tmp = tempfile.TemporaryDirectory() + cls.rng = np.random.default_rng(0) + cls.weights = Tensor(cls.rng.standard_normal((16, 32)).astype(np.float32)).realize() + cls.bias = Tensor(cls.rng.standard_normal((1, 32)).astype(np.float32)).realize() + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + super().tearDownClass() + + def model(self, x, h): # a two input, one weight model: the shape of a policy without the size + return ((x @ self.weights + self.bias).relu() + h.mean()).contiguous() + + def tensors(self, seed: int): + from tinygrad import Tensor + rng = np.random.default_rng(seed) + return {"x": Tensor(rng.standard_normal((1, 16)).astype(np.float32)).realize(), "h": Tensor(rng.standard_normal((4, 16)).astype(np.float32)).realize()} + + def artifact(self, name: str = "model") -> str: + path = os.path.join(self.tmp.name, f"{name}_tinygrad.pkl") + if not os.path.exists(path): + write_bundle(path, {"run": compile_jit(self.model, self.tensors(1))}, {"metadata": {"name": name}}) + return path + + def test_round_trip_matches_jit(self): # compiler -> bundle -> PlainJit, replay verified, then fresh inputs bit exact with eager tinygrad + bundle = load_model(self.artifact(), cache=False, verify=True) + assert bundle["metadata"] == {"name": "model"} and isinstance(bundle["run"], PlainJit) + fresh = self.tensors(2) + assert np.array_equal(bundle["run"](**fresh).numpy(), self.model(**fresh).numpy()) + + def test_replay_check_catches_a_changed_kernel(self): + parts, extras = read_parts(self.artifact()) + header, blob = parts["run"] + poisoned = dict(header, check=dict(header["check"], outputs=[header["check"]["inputs"][0][1]])) + with self.assertRaises(helpers.ArtifactMismatch): + PlainJit(poisoned, blob) + + def test_cache_round_trip_holds_no_weights_and_is_rebuilt_when_stale(self): + path = self.artifact("cached") + expected = load_model(path, cache=False, verify=False)["run"](**self.tensors(3)).numpy() + load_model(path) + assert cache_is_fresh(path) + with open(f"{path}.cache", "rb") as file: + cache = file.read() + assert self.weights.numpy().tobytes() not in cache and len(cache) < os.path.getsize(path) + with mock.patch.object(helpers, "PlainJit", side_effect=AssertionError("a fresh cache must not rebuild")): + cached = load_model(path) + assert np.array_equal(cached["run"](**self.tensors(3)).numpy(), expected) + with mock.patch.object(helpers, "tinygrad_identity", return_value="another tinygrad"): + assert not cache_is_fresh(path) + assert isinstance(load_model(path)["run"], PlainJit) + assert cache_is_fresh(path) + assert not cache_is_fresh(path) # keyed on the tinygrad that wrote it, so the next boot after a bump rebuilds once + os.utime(path) + with mock.patch.object(helpers, "tinygrad_identity", return_value="another tinygrad"): + assert not cache_is_fresh(path) + + def test_fused_with_warp_matches_warp_then_model(self): + from tinygrad import Tensor + warp_path = os.path.join(self.tmp.name, "warp_tinygrad.pkl") + with mock.patch.object(sys, "argv", ["compile_warp", "--frame", FRAME, "--warp-to", f"{MODEL_W}x{MODEL_H}", "--output", warp_path]): + compile_warp.main() + warp = load_model(warp_path, cache=False)["run"] + + def native(new_img, desire, traffic_convention, state_feat_q, output_buffers): + feat = state_feat_q.mean(axis=0).reshape(1, 16) + new_img.float().mean() + desire.sum() + traffic_convention[0] + Tensor.realize(output_buffers["outputs"].assign((feat @ self.weights + self.bias).relu()), + output_buffers["next_state_feat_q"].assign(state_feat_q[1:].cat(feat * 2))) + + def model_inputs(new_img): + return {"new_img": new_img} | {name: Tensor(np.ones(shape, np.float32) * 0.5).realize() for name, shape in HOST.items()} | \ + {"state_feat_q": Tensor(self.rng.standard_normal(STATE).astype(np.float32)).realize(), + "output_buffers": {"outputs": Tensor.zeros(OUT).contiguous().realize(), "next_state_feat_q": Tensor.zeros(STATE).contiguous().realize()}} + frames = Tensor(self.rng.integers(0, 256, (2, FRAME_SIZE), dtype=np.uint8)).realize() + tfm = Tensor(np.tile(np.eye(3, dtype=np.float32), (2, 1, 1))).realize() + path = os.path.join(self.tmp.name, "native_tinygrad.pkl") + part = compile_jit(native, model_inputs(warp(input_frame=frames, M_inv=tfm).realize()), "output_buffers") + extras = {"input_specs": {"new_img": ((2, 6, MODEL_H // 2, MODEL_W // 2), "|u1", "CPU"), "state_feat_q": (STATE, "= {"global_size", "local_size", "globals", "outs", "ins", "target"}, "ProgramInfo changed: update helpers._program_uop" + assert fields[ParamArg] >= {"slot", "dtype", "name", "image"}, "ParamArg changed: update helpers._program_uop" + assert fields[Target] >= {"device", "renderer", "arch"}, "Target changed: update the 'target' key in plain_artifact_compiler.serialize_jit" + assert "signature" in getattr(TinyELF, "__annotations__", {}) or hasattr(UOp, "kernel_params"), \ + "kernel signatures moved: update plain_artifact_compiler.serialize_jit" + assert callable(getattr(UOp, "param", None)) and callable(getattr(UOp, "from_buffer", None)), "UOp.param/from_buffer: update helpers.PlainJit" + assert callable(getattr(UOp, "store_call", None)) and callable(getattr(UOp, "call", None)), "UOp.call/store_call: update helpers.PlainJit" + assert callable(getattr(Buffer, "view", None)) and callable(getattr(Buffer, "ensure_allocated", None)), "Buffer.view: update helpers._upload" + assert callable(getattr(jit, "compile_linear", None)) and callable(getattr(jit, "_prepare_jit_inputs", None)), "engine.jit: update helpers._Jit" + assert "expected_names" in inspect.signature(jit.CapturedJit).parameters, "CapturedJit: update helpers.PlainJit and plain_artifact_compiler.serialize_jit" diff --git a/openpilot/sunnypilot/modeld_v2/warmup.py b/openpilot/sunnypilot/modeld_v2/warmup.py new file mode 100755 index 0000000000..852f2bc4fc --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/warmup.py @@ -0,0 +1,42 @@ +#!/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 os +os.environ['GMMU'] = '0' +import multiprocessing +import time +from openpilot.common.hardware.hw import Paths +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.sunnypilot.modeld_v2.helpers import cache_is_fresh, load_model, read_parts +from openpilot.sunnypilot.models.helpers import get_selected_bundle + + +def main(): + """ + The main entry point for offroad cache creation. + Runs every 10 seconds and checks for a stale qcom model cache. + """ + params, failed, native, spawn = Params(), set(), set(), multiprocessing.get_context("spawn") + while True: + bundle = get_selected_bundle(params, "qcom") + path = os.path.join(Paths.model_root(), bundle.models[0].artifact.fileName) if bundle and bundle.models else "" + if os.path.isfile(path) and not cache_is_fresh(path) and (attempt := (path, os.stat(path).st_mtime_ns)) not in failed | native: + if "input_specs" in read_parts(path)[1]: # native models are fused + native.add(attempt) + else: + (worker := spawn.Process(target=load_model, args=(path,), daemon=True)).start() + worker.join() + if not cache_is_fresh(path): + cloudlog.error(f"model cache warmup failed for {path} with exit code {worker.exitcode}") + failed.add(attempt) + time.sleep(10) + + +if __name__ == "__main__": + main() diff --git a/openpilot/sunnypilot/models/default_model.py b/openpilot/sunnypilot/models/default_model.py index 5aacea12b2..6ff56320a0 100755 --- a/openpilot/sunnypilot/models/default_model.py +++ b/openpilot/sunnypilot/models/default_model.py @@ -22,7 +22,7 @@ DEFAULT_MODEL_NAME_PATH = os.path.join(BASEDIR, "openpilot", "sunnypilot", "mode MODEL_HASH_PATH = os.path.join(BASEDIR, "openpilot", "sunnypilot", "models", "tests", "model_hash") BIG_MODEL_HASH_PATH = os.path.join(BASEDIR, "openpilot", "sunnypilot", "models", "tests", "big_model_hash") SUPERCOMBO_ONNX_PATH = os.path.join(BASEDIR, "openpilot", "selfdrive", "modeld", "models", "driving_supercombo.onnx") -BIG_SUPERCOMBO_ONNX_PATH = os.path.join(BASEDIR, "openpilot", "selfdrive", "modeld", "models", "big_driving_supercombo.onnx") +BIG_DRIVING_PKL_PATH = os.path.join(BASEDIR, "openpilot", "selfdrive", "modeld", "models", "big_driving_tinygrad.pkl") def _read_model_name_fields(): @@ -47,18 +47,17 @@ def update_model_hash(): print(f"Generated and updated new combined model hash to {MODEL_HASH_PATH}") - if os.path.exists(BIG_SUPERCOMBO_ONNX_PATH): - import subprocess - rel = os.path.relpath(BIG_SUPERCOMBO_ONNX_PATH, os.getcwd()) - pointer = subprocess.check_output(["git", "show", f"HEAD:{rel}"], text=True) - oid = next(l.split(":", 1)[1] for l in pointer.splitlines() if l.startswith("oid sha256:")) - big_fingerprint = f"{oid}:{fields.get('DEFAULT_BIG_MODEL', '')}:{fields.get('DEFAULT_BIG_MODEL_REF', '')}" - big_combined_hash = hashlib.sha256(big_fingerprint.encode()).hexdigest() + import subprocess + rel = os.path.relpath(BIG_DRIVING_PKL_PATH, os.getcwd()) + pointer = subprocess.check_output(["git", "show", f"HEAD:{rel}"], text=True) + oid = next(l.split(":", 1)[1] for l in pointer.splitlines() if l.startswith("oid sha256:")) + big_fingerprint = f"{oid}:{fields.get('DEFAULT_BIG_MODEL', '')}:{fields.get('DEFAULT_BIG_MODEL_REF', '')}" + big_combined_hash = hashlib.sha256(big_fingerprint.encode()).hexdigest() - with open(BIG_MODEL_HASH_PATH, "w") as f: - f.write(big_combined_hash) + with open(BIG_MODEL_HASH_PATH, "w") as f: + f.write(big_combined_hash) - print(f"Generated and updated new big model hash to {BIG_MODEL_HASH_PATH}") + print(f"Generated and updated new big model hash to {BIG_MODEL_HASH_PATH}") def get_ref_for_name(url: str, name: str) -> str: diff --git a/openpilot/sunnypilot/models/fetcher.py b/openpilot/sunnypilot/models/fetcher.py index 16fa378c4e..0c40c9e598 100644 --- a/openpilot/sunnypilot/models/fetcher.py +++ b/openpilot/sunnypilot/models/fetcher.py @@ -6,12 +6,10 @@ 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,35 +24,11 @@ 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 @@ -138,8 +112,8 @@ class ModelCache: class ModelFetcher: """Handles fetching and caching of model data from remote source""" - MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v22.json" - MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v25.json" + MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v25.json" + MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v28.json" MODEL_SOURCES = { "qcom": (MODEL_URL, ""), @@ -270,5 +244,3 @@ if __name__ == "__main__": model_overrides = {override.key: override.value for override in bundle.overrides} print(f"Bundle: {bundle.internalName}, Type: {model.type}, Status: {bundle.status}, Overrides: {model_overrides}") print(f"Artifact: {model.artifact.fileName}, Download URI: {model.artifact.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 a1a392c5ee..0909f99bd1 100644 --- a/openpilot/sunnypilot/models/helpers.py +++ b/openpilot/sunnypilot/models/helpers.py @@ -16,7 +16,7 @@ from openpilot.common.hardware.hw import Paths from openpilot.selfdrive.modeld.helpers import chestnut_present # SET ME TO THE EXACT JSON VERSION WE SET IN SUNNYPILOT_MODELS REPO -REQUIRED_JSON_VERSION = 19 +REQUIRED_JSON_VERSION = 21 CUSTOM_MODEL_PATH = Paths.model_root() ModelManager = custom.ModelManagerSP @@ -29,9 +29,8 @@ _LAST_VALIDATED_RAW: dict[str, dict | None] = {} def _compute_hash(file_path: str) -> str | None: - from openpilot.common.file_chunker import open_file_chunked try: - with open_file_chunked(file_path) as file: + with open(file_path, 'rb') as file: return hashlib.file_digest(file, "sha256").hexdigest().lower() except FileNotFoundError: return None @@ -58,20 +57,11 @@ 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),): - 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)) + artifact = getattr(model, 'artifact', None) + if artifact and getattr(artifact, 'fileName', None) and getattr(artifact, 'downloadUri', None): + if sha256 := getattr(artifact.downloadUri, 'sha256', None): + artifacts.append((artifact.fileName, sha256)) return artifacts diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index 035f59891c..f1cca741bd 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -7,7 +7,10 @@ See the LICENSE.md file in the root directory for more details. import asyncio import os +import re +import threading import time +from concurrent.futures import ThreadPoolExecutor import requests from openpilot.common.params import Params @@ -22,12 +25,101 @@ from openpilot.sunnypilot.models.helpers import (ACTIVE_BUNDLE_KEYS, get_active_ # (connect, read) seconds. read is per-request inactivity, not a total cap DOWNLOAD_TIMEOUT = (30, 30) +# HuggingFace throttles each connection to ~1-2 MB/s but not the connection count; 12 saturates the device link +MAX_CONCURRENT_PIECES = 12 +PIECE_SIZE = 8 * 1024 * 1024 +PIECE_ATTEMPTS = 3 +PIECE_BACKOFF = 1.0 # seconds, growing linearly: waits 1 s then 2 s between a piece's three attempts +TRANSIENT_HTTP_STATUS = frozenset({408, 429, 500, 502, 503, 504}) +REPORT_INTERVAL = 0.5 # seconds between progress publications +# byte ranges count encoded bytes but iter_content yields decoded ones, so ask for an unencoded body +NO_COMPRESSION = {"Accept-Encoding": "identity"} +SPEED_SMOOTHING = 0.7 # weight of the previous speed sample in the published speed +SPEED_FLOOR = 0.05 * 1024 ** 2 # bytes/s; anything slower would only display as 0.0 MB/s, so it reads as no rate class DownloadCancelled(Exception): pass +def _is_transient(e: BaseException) -> bool: + if isinstance(e, requests.HTTPError): + return e.response is not None and e.response.status_code in TRANSIENT_HTTP_STATUS + return isinstance(e, (requests.ConnectionError, requests.Timeout, requests.exceptions.ChunkedEncodingError)) + + +class _Progress: + def __init__(self): + self._bytes = 0 + self._lock = threading.Lock() + + def add_bytes(self, n: int) -> None: + with self._lock: + self._bytes += n + + def snapshot(self) -> int: + with self._lock: + return self._bytes + + +def _piece_ranges(total: int, piece_size: int) -> list[tuple[int, int]]: + return [(start, min(start + piece_size, total)) for start in range(0, total, piece_size)] + + +def _prepare_target(path: str, total: int) -> None: + with open(path, "wb") as f: + f.truncate(total) + + +def _content_range_total(response: requests.Response) -> int | None: + if response.status_code != 206: + return None + match = re.fullmatch(r"bytes \d+-\d+/(\d+)", response.headers.get("Content-Range", "")) + return int(match.group(1)) if match else None + + +def _probe_size(url: str) -> tuple[int, bool]: + with requests.get(url, headers={**NO_COMPRESSION, "Range": "bytes=0-0"}, stream=True, timeout=DOWNLOAD_TIMEOUT) as response: + response.raise_for_status() + total = _content_range_total(response) + if total is not None: + return total, True + return int(response.headers.get("Content-Length", 0)), False + + +def _fetch_piece(url: str, path: str, index: int, start: int, end: int | None, progress: _Progress, + cancel: threading.Event, block_size: int) -> None: + headers = {**NO_COMPRESSION, "Range": f"bytes={start}-{end - 1}"} if end is not None else NO_COMPRESSION + label = f"{os.path.basename(path)} piece {index}" + for attempt in range(PIECE_ATTEMPTS): + written = 0 + try: + with requests.get(url, headers=headers, stream=True, timeout=DOWNLOAD_TIMEOUT) as response: + response.raise_for_status() + if end is not None and response.status_code != 206: + raise ValueError(f"server ignored byte range for {label}") + with open(path, "r+b") as f: + f.seek(start) + for block in response.iter_content(chunk_size=block_size): + if cancel.is_set(): + raise DownloadCancelled("Download cancelled") + f.write(block) + written += len(block) + progress.add_bytes(len(block)) + if end is None: + f.truncate() # unknown length: the body defines the file size + if end is not None and written != end - start: + raise requests.exceptions.ConnectionError(f"short read: {written} of {end - start} bytes") + return + except requests.RequestException as e: + progress.add_bytes(-written) # the piece restarts from scratch + if not _is_transient(e) or attempt == PIECE_ATTEMPTS - 1: + raise + cloudlog.warning(f"retrying {label} after {type(e).__name__}: {e}") + if cancel.wait(PIECE_BACKOFF * (1 + attempt)): + raise DownloadCancelled("Download cancelled") from None + + class ModelManagerSP: """Manages model downloads and status reporting""" @@ -41,7 +133,7 @@ class ModelManagerSP: 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, chestnut=self.chestnut_present) - self._chunk_size = 128 * 1000 # 128 KB chunks + self._block_size = 128 * 1000 # 128 KB network read blocks self._download_start_times: dict[str, float] = {} # Track start time per model self._download_ref: bytes | str | None = None @@ -56,7 +148,6 @@ class ModelManagerSP: 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: @@ -65,9 +156,9 @@ class ModelManagerSP: artifact.downloadProgress.status = source_artifact.downloadProgress.status artifact.downloadProgress.progress = source_artifact.downloadProgress.progress artifact.downloadProgress.eta = source_artifact.downloadProgress.eta + artifact.downloadProgress.speed = source_artifact.downloadProgress.speed def _calculate_eta(self, filename: str, progress: float) -> int: - """Calculate ETA based on elapsed time and current progress""" if filename not in self._download_start_times or progress <= 0: return 60 # Default ETA for new downloads @@ -75,82 +166,73 @@ class ModelManagerSP: if elapsed_time <= 0: return 60 - # If we're at X% after Y seconds, we can estimate total time as (Y / X) * 100 total_estimated_time = (elapsed_time / progress) * 100 eta = total_estimated_time - elapsed_time - return max(1, int(eta)) # Return at least 1 second if download is ongoing - async def _download_file(self, url: str, path: str, model) -> None: - """Downloads a file with progress tracking""" - self._download_start_times[model.fileName] = time.monotonic() + def _set_progress(self, artifact, status, progress: float, eta: int = 0, speed: float = 0.0) -> None: + artifact.downloadProgress.status = status + artifact.downloadProgress.progress = progress + artifact.downloadProgress.eta = eta + artifact.downloadProgress.speed = speed + self._sync_artifact_progress(artifact) + self._report_status() - 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 + def _publish_progress(self, artifact, done_bytes: int, total: int, speed: float) -> None: + # held at 99 until the file passes its hash check + progress = min(99.0, done_bytes / total * 100) if total > 0 else 0.0 + if speed > 0 and total > 0: + eta = max(1, int((total - done_bytes) / speed)) + else: + eta = self._calculate_eta(artifact.fileName, progress) + self._set_progress(artifact, custom.ModelManagerSP.DownloadStatus.downloading, progress, eta, speed) - 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) + async def _report_until_done(self, tasks: list[asyncio.Future], artifact, progress: _Progress, total: int, + started: tuple[int, float]) -> None: + pending = set(tasks) + last_bytes, last_time = started + speed = 0.0 + while pending: + done, pending = await asyncio.wait(pending, timeout=REPORT_INTERVAL) + for task in done: + task.result() # re-raises a worker failure + if self._download_interrupted(): + raise DownloadCancelled("Download cancelled") + now = time.monotonic() + done_bytes = progress.snapshot() + if (dt := now - last_time) > 0: + instant = max(0.0, (done_bytes - last_bytes) / dt) # bytes/s this tick; clamped since retried pieces can lower the byte count + speed = instant if speed == 0 else SPEED_SMOOTHING * speed + (1 - SPEED_SMOOTHING) * instant + if speed < SPEED_FLOOR: # a stall decays toward 0 without reaching it, and a near-0 speed overflows the eta + speed = 0.0 + last_time, last_bytes = now, done_bytes + self._publish_progress(artifact, done_bytes, total, speed) - 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() - - # 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") - - manifest_path = get_manifest_path(base_path) + async def _download_file(self, url: str, path: str, artifact) -> None: self._download_start_times[artifact.fileName] = time.monotonic() + loop = asyncio.get_running_loop() - # 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: # noqa: ASYNC230 - for data in response.iter_content(chunk_size=self._chunk_size): - f.write(data) - chunk_downloaded += len(data) - if self._download_interrupted(): - raise DownloadCancelled("Download cancelled") - intra = chunk_downloaded / max(chunk_size, 1) - progress = min(99.0, ((completed + intra) / num_chunks) * 100) - artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading - artifact.downloadProgress.progress = progress - artifact.downloadProgress.eta = self._calculate_eta(artifact.fileName, progress) - self._sync_artifact_progress(artifact) - self._report_status() - completed += 1 + total, ranged = await loop.run_in_executor(None, _probe_size, url) + pieces: list[tuple[int, int | None]] = list(_piece_ranges(total, PIECE_SIZE)) if ranged else [(0, None)] + await loop.run_in_executor(None, _prepare_target, path, total) + progress = _Progress() + cancel = threading.Event() + started = progress.snapshot(), time.monotonic() # before any worker can land bytes - with open(manifest_path, 'w') as f: # noqa: ASYNC230 - f.write(str(num_chunks)) - if os.path.isfile(base_path): # noqa: ASYNC240 - os.remove(base_path) + with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_PIECES) as pool: + tasks = [loop.run_in_executor(pool, _fetch_piece, url, path, i, start, end, progress, cancel, self._block_size) + for i, (start, end) in enumerate(pieces)] + try: + await self._report_until_done(tasks, artifact, progress, total, started) + except BaseException as e: + cancel.set() + if isinstance(e, DownloadCancelled): + artifact.downloadProgress.canceling = True + self._set_progress(artifact, custom.ModelManagerSP.DownloadStatus.downloading, artifact.downloadProgress.progress) + for task in tasks: + task.cancel() # drops queued pieces; running ones see the event at their next block + await asyncio.gather(*tasks, return_exceptions=True) + raise del self._download_start_times[artifact.fileName] async def _process_artifact(self, artifact, destination_path: str) -> None: @@ -163,76 +245,44 @@ class ModelManagerSP: expected_hash = artifact.downloadUri.sha256 filename = artifact.fileName full_path = os.path.join(destination_path, filename) + status = custom.ModelManagerSP.DownloadStatus + artifact.downloadProgress.canceling = False try: - # progress counts only valid chunks so a resumed download continues the - # bar from where verification left it, instead of falling back to zero - is_cached = False - valid_chunks: set[int] = set() - if len(artifact.chunks) > 0: - from openpilot.common.file_chunker import get_chunk_name - num_chunks = len(artifact.chunks) - 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 - self._sync_artifact_progress(artifact) - self._report_status() + self._set_progress(artifact, status.verifying, 0) + if await verify_file(full_path, expected_hash): + self._set_progress(artifact, status.cached, 100) return - 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}") + self._set_progress(artifact, status.downloading, 0) + await self._download_file(url, full_path, artifact) + self._set_progress(artifact, status.verifying, 99) + 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 - artifact.downloadProgress.eta = 0 - self._sync_artifact_progress(artifact) - self._report_status() + self._set_progress(artifact, status.downloaded, 100) 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.status = status.failed artifact.downloadProgress.eta = 0 + artifact.downloadProgress.speed = 0 self._sync_artifact_progress(artifact) if self.selected_bundle: - self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed + self.selected_bundle.status = status.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): # noqa: ASYNC240 - os.remove(f) - artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed + if os.path.isfile(full_path): # noqa: ASYNC240 + os.remove(full_path) + artifact.downloadProgress.status = status.failed artifact.downloadProgress.eta = 0 + artifact.downloadProgress.speed = 0 self._sync_artifact_progress(artifact) if self.selected_bundle: - self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed + self.selected_bundle.status = status.failed self._report_status() self._download_start_times.pop(artifact.fileName, None) raise @@ -272,6 +322,7 @@ class ModelManagerSP: artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached artifact.downloadProgress.progress = 100 artifact.downloadProgress.eta = 0 + artifact.downloadProgress.speed = 0 else: seen_artifacts.add(artifact.fileName) await self._process_artifact(artifact, destination_path) @@ -331,8 +382,7 @@ class ModelManagerSP: if get_selected_bundle(self.params, "chestnut") is not None and get_selected_bundle(self.params, "qcom") is None: if self.params.get("ModelManager_DownloadRef") is None: from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL_REF - if DEFAULT_MODEL_REF: - self.params.put("ModelManager_DownloadRef", DEFAULT_MODEL_REF) + self.params.put("ModelManager_DownloadRef", DEFAULT_MODEL_REF) self._process_download_requests() @@ -348,12 +398,7 @@ class ModelManagerSP: rk.keep_time() def clear_model_cache(self) -> None: - """ - Clears the model cache directory of all files except those in the active model bundle. - """ - - # Get list of files used by both slots' selected bundles (either may become - # the truly active bundle depending on hardware availability) + """ Clears the model cache directory of all files except those in the active model bundle.""" active_files = [] for source in ACTIVE_BUNDLE_KEYS: if selected_bundle := get_selected_bundle(self.params, source): @@ -361,12 +406,10 @@ class ModelManagerSP: 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() try: for filename in os.listdir(model_dir): - base = filename.split('.chunk')[0] if '.chunk' in filename else filename - if base not in active_files and filename not in active_files: + if filename not in active_files: file_path = os.path.join(model_dir, filename) if os.path.isfile(file_path): os.remove(file_path) diff --git a/openpilot/sunnypilot/models/model_name.py b/openpilot/sunnypilot/models/model_name.py index 3420391824..bd8e9ac265 100644 --- a/openpilot/sunnypilot/models/model_name.py +++ b/openpilot/sunnypilot/models/model_name.py @@ -1,4 +1,4 @@ DEFAULT_MODEL = "CD210" DEFAULT_MODEL_REF = "5b6436a90cf6902b8aaa71c2b6f3d7164d8ae391" -DEFAULT_BIG_MODEL = "BMRLNAP Model v4" -DEFAULT_BIG_MODEL_REF = "f877d7a0ccc3cce943c76e285214c020cd65c899" +DEFAULT_BIG_MODEL = "Mountain Dew Model v3" +DEFAULT_BIG_MODEL_REF = "1e219d7785cfb7b7343c7f2366347fc86e7ce5f2" diff --git a/openpilot/sunnypilot/models/tests/big_model_hash b/openpilot/sunnypilot/models/tests/big_model_hash index 308096afe4..18d5e67d8b 100644 --- a/openpilot/sunnypilot/models/tests/big_model_hash +++ b/openpilot/sunnypilot/models/tests/big_model_hash @@ -1 +1 @@ -2c814f08a2c51323b87839fbf8d2c2a9853a2b5536271b3d67f7b7a2de7f9374 \ No newline at end of file +330fdf61b5519642ebccd93aa50f2d7c4ba6c08c5a10c36a2cf155e498700cef \ No newline at end of file diff --git a/openpilot/sunnypilot/models/tests/model_hash b/openpilot/sunnypilot/models/tests/model_hash index ffe44d3bf9..95a9bafd30 100644 --- a/openpilot/sunnypilot/models/tests/model_hash +++ b/openpilot/sunnypilot/models/tests/model_hash @@ -1 +1 @@ -c5be11d2fb1115be953c541f30c50f7c71a00bc4a0e128e19aa11b60689317fc \ No newline at end of file +afa673add3ffdff2607c4968912a0183c5883fd0159d56218a3c22a508a2ebd5 \ No newline at end of file diff --git a/openpilot/sunnypilot/models/tests/test_default_model.py b/openpilot/sunnypilot/models/tests/test_default_model.py index 71a18fc612..570548c0a6 100644 --- a/openpilot/sunnypilot/models/tests/test_default_model.py +++ b/openpilot/sunnypilot/models/tests/test_default_model.py @@ -10,7 +10,7 @@ import subprocess from openpilot.sunnypilot import get_file_hash from openpilot.sunnypilot.models.default_model import MODEL_HASH_PATH, SUPERCOMBO_ONNX_PATH, BIG_MODEL_HASH_PATH, \ - BIG_SUPERCOMBO_ONNX_PATH, _read_model_name_fields + BIG_DRIVING_PKL_PATH, _read_model_name_fields import hashlib from openpilot.common.test import OpenpilotTestCase @@ -36,12 +36,9 @@ class TestDefaultModel(OpenpilotTestCase): assert combined_hash == current_hash, "Run openpilot/sunnypilot/models/default_model.py to update the default model name and hash" - def test_compare_big_onnx_hashes(self): - if not os.path.exists(BIG_SUPERCOMBO_ONNX_PATH): - self.skipTest("big_driving_supercombo.onnx not present") - + def test_compare_big_pkl_hashes(self): fields = _read_model_name_fields() - oid = _get_lfs_oid(os.path.relpath(BIG_SUPERCOMBO_ONNX_PATH, os.getcwd())) + oid = _get_lfs_oid(os.path.relpath(BIG_DRIVING_PKL_PATH, os.getcwd())) big_fingerprint = f"{oid}:{fields.get('DEFAULT_BIG_MODEL', '')}:{fields.get('DEFAULT_BIG_MODEL_REF', '')}" combined_hash = hashlib.sha256(big_fingerprint.encode()).hexdigest() diff --git a/openpilot/sunnypilot/models/tests/test_manager_download.py b/openpilot/sunnypilot/models/tests/test_manager_download.py index b995d4fbd1..3446441ee2 100644 --- a/openpilot/sunnypilot/models/tests/test_manager_download.py +++ b/openpilot/sunnypilot/models/tests/test_manager_download.py @@ -6,9 +6,16 @@ See the LICENSE.md file in the root directory for more details. """ import asyncio +import concurrent.futures +import contextlib +import dataclasses import hashlib import http.server +import math import os +import random +import re +import requests import tempfile import threading import time @@ -16,356 +23,195 @@ 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, +from openpilot.sunnypilot.models.helpers import (ACTIVE_BUNDLE_KEYS, get_active_bundle, get_active_source, get_selected_bundle, resolve_bundle_by_ref, validate_active_bundles) -from openpilot.sunnypilot.models.manager import ModelManagerSP +from openpilot.sunnypilot.models.manager import DownloadCancelled, ModelManagerSP -CHUNK_BODIES = [b'A' * 5000, b'B' * 5000, b'C' * 3000] -WHOLE_BODY = b'Z' * 9000 +FILE_NAME = 'driving_test_tinygrad.pkl' +# fixed-seed random bytes: every piece differs, so a misplaced piece changes the file +WHOLE_BODY = random.Random(0).randbytes(10_240) +PIECE_SIZE = 1000 # bytes per test piece -> 11 pieces, the last one short +NUM_PIECES = math.ceil(len(WHOLE_BODY) / PIECE_SIZE) 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() +def piece_range(index: int) -> tuple[int, int]: + start = index * PIECE_SIZE + return start, min(start + PIECE_SIZE, len(WHOLE_BODY)) - 1 + + +@dataclasses.dataclass +class ServerState: + request_ranges: list[tuple[int, int] | None] = dataclasses.field(default_factory=list) # None: no (honored) Range header + request_encodings: list[str | None] = dataclasses.field(default_factory=list) # Accept-Encoding of every request + fail_ranges: dict[tuple[int, int], int] = dataclasses.field(default_factory=dict) # range -> HTTP status, every time + fail_times: dict[tuple[int, int], int] = dataclasses.field(default_factory=dict) # range -> how many more requests 503 + short_once: set[tuple[int, int]] = dataclasses.field(default_factory=set) # ranges whose first answer is cut short + stall_ranges: set[tuple[int, int]] = dataclasses.field(default_factory=set) stall_event: threading.Event | None = None + stall_released_by_event: bool | None = None + support_ranges: bool = True + corrupt: bool = False + + +class DownloadHandler(http.server.BaseHTTPRequestHandler): + state = ServerState() 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) + state = self.state + body = WHOLE_BODY + if state.corrupt: + # flip every bit of one mid-file byte: same size, so only the hash check can catch it + body = body[:5000] + bytes([body[5000] ^ 0xFF]) + body[5001:] - status = type(self).fail_paths.get(self.path) - if status: - self.send_response(status) - self.end_headers() - return + rng = None + match = re.fullmatch(r'bytes=(\d+)-(\d+)', self.headers.get('Range', '')) + if match and state.support_ranges: + rng = (int(match.group(1)), int(match.group(2))) + state.request_ranges.append(rng) + state.request_encodings.append(self.headers.get('Accept-Encoding')) - body = self._body_for(self.path) - if body is None: - self.send_response(404) - self.end_headers() - return + if rng is not None: + status = state.fail_ranges.get(rng) + if status is None and state.fail_times.get(rng, 0) > 0: + state.fail_times[rng] -= 1 + status = 503 + if status is not None: + self.send_response(status) + self.end_headers() + return - self.send_response(200) - self.send_header('Content-Length', str(len(body))) + if rng is None: + payload = body + self.send_response(200) + else: + start, end = rng + payload = body[start:end + 1] + self.send_response(206) + self.send_header('Content-Range', f'bytes {start}-{end}/{len(body)}') + if rng in state.short_once: + state.short_once.discard(rng) + payload = payload[:100] + self.send_header('Content-Length', str(len(payload))) 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]) + if rng in state.stall_ranges: + # write a little, then hold the connection until the test releases it + self.wfile.write(payload[:100]) self.wfile.flush() - if type(self).stall_event is not None: - type(self).stall_event.wait(timeout=5) - self.wfile.write(body[100:]) + if state.stall_event is not None: + state.stall_released_by_event = state.stall_event.wait(timeout=5) + self.wfile.write(payload[100:]) else: - self.wfile.write(body) + self.wfile.write(payload) + + +@contextlib.contextmanager +def threaded_server(handler): + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), handler) + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + yield f'http://127.0.0.1:{server.server_port}' + finally: + server.shutdown() + server.server_close() + thread.join() + + +class InlineExecutor(concurrent.futures.Executor): + def submit(self, fn, /, *args, **kwargs): + future: concurrent.futures.Future = concurrent.futures.Future() + future.set_result(fn(*args, **kwargs)) # the piece lands before the reporter starts + return future class ManagerDownloadTestBase(OpenpilotTestCase): def setUp(self): super().setUp() - DownloadHandler.request_paths = [] - DownloadHandler.fail_paths = {} - DownloadHandler.stall_paths = set() - DownloadHandler.stall_event = None + self.server = DownloadHandler.state = ServerState() + self.base_url = self.enterContext(threaded_server(DownloadHandler)) self._tmp = tempfile.TemporaryDirectory() self.addCleanup(self._tmp.cleanup) self.dest = self._tmp.name + for name, value in (('PIECE_SIZE', PIECE_SIZE), ('REPORT_INTERVAL', 0.05), ('PIECE_BACKOFF', 0.01), ('SPEED_FLOOR', 100)): + self.enterContext(mock.patch.object(manager_module, name, value)) + self.reported: list[float] = [] + self.reported_statuses: list[int] = [] self.manager = ModelManagerSP.__new__(ModelManagerSP) - self.manager.params = mock.MagicMock() - self.manager.params.get.return_value = b'0' # not cancelled + self.params = self.manager.params = mock.MagicMock() + self.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.pm = self.manager.pm = mock.MagicMock() + self.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._block_size = 256 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)) + status = artifact.downloadProgress.status + self.reported_statuses.append(getattr(status, 'raw', status)) # .raw: _DynamicEnum equals its int but does not hash like it - def make_artifact(self, chunked: bool): + def make_artifact(self): 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) + artifact.fileName = FILE_NAME + artifact.downloadUri.uri = self.base_url + '/' + FILE_NAME + 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))] + @property + def path(self) -> str: + return os.path.join(self.dest, FILE_NAME) - 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}" + def piece_requests(self) -> list[tuple[int, int]]: + return [r for r in self.server.request_ranges if r is not None and r != (0, 0)] + def stall(self, index: int) -> threading.Event: + self.server.stall_ranges = {piece_range(index)} + self.server.stall_event = threading.Event() + return self.server.stall_event -class TestManagerDownload(ManagerDownloadTestBase): - """Exercises the real _download_file / _download_chunked against a local server.""" + def download_file(self): + artifact = self.make_artifact() + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, self.path, artifact)) + return artifact - 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 process_artifact(self): + artifact = self.make_artifact() + asyncio.run(self.manager._process_artifact(artifact, self.dest)) + return artifact - 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 download_bundle(self, source: str): + asyncio.run(self.manager._download_bundle(self._bundle, self.dest, source)) - 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 read_path(self) -> bytes: + with open(self.path, 'rb') as f: + return f.read() def _make_params_with_store(self): params = mock.MagicMock() @@ -381,86 +227,182 @@ class TestManagerDownload(ManagerDownloadTestBase): params.put.side_effect = put return params, store - def test_download_writes_qcom_slot(self): - """A download resolved to the qcom source writes the qcom active bundle slot only.""" - def body(): - artifact = self.make_artifact(chunked=True) - self._bundle.ref = "test-ref" - self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION - params, store = self._make_params_with_store() - self.manager.params = params - asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "qcom")) - assert "ModelManager_ActiveBundle" in store, "qcom download must write the qcom slot" - assert "ModelManager_ActiveBundleChestnut" not in store, "qcom download must not touch the chestnut slot" - assert self.manager.selected_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded - assert self.manager.active_bundle is not None and self.manager.active_bundle.ref == "test-ref" - assert self.manager.active_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded - chunk_names = [get_chunk_name(artifact.fileName, i, len(artifact.chunks)) for i in range(len(artifact.chunks))] - missing = [c for c in chunk_names if not os.path.isfile(os.path.join(self.dest, c))] - assert missing == [], f"chunks missing from the cache: {missing}" - self.run_with_server(body) +class TestManagerDownload(ManagerDownloadTestBase): + def test_download(self): + artifact = self.download_file() + assert self.read_path() == WHOLE_BODY + assert sorted(self.piece_requests()) == [piece_range(i) for i in range(NUM_PIECES)], "pieces must tile the file once" + assert self.server.request_encodings == ['identity'] * (NUM_PIECES + 1), "ranges count encoded bytes, so ask for none" + assert self.reported and self.reported == sorted(self.reported), "progress only rises" + assert max(self.reported) <= 99.0, "progress holds below 100 until the hash check" + assert artifact.fileName not in self.manager._download_start_times + assert artifact.downloadProgress.speed > 0, "a running download shows its speed" - def test_download_writes_chestnut_slot(self): - """A download resolved to the chestnut source writes the chestnut active bundle slot only.""" - def body(): - self.make_artifact(chunked=True) - self._bundle.ref = "big-ref" - self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION - params, store = self._make_params_with_store() - self.manager.params = params - asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "chestnut")) + def test_speed_counts_bytes_landed_before_the_first_report(self): + self.enterContext(mock.patch.object(manager_module, 'ThreadPoolExecutor', lambda max_workers: InlineExecutor())) + artifact = self.download_file() + assert artifact.downloadProgress.speed > 0 - 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) + def test_eta_comes_from_speed(self): + artifact = self.make_artifact() + self.manager._publish_progress(artifact, done_bytes=400, total=1000, speed=100) + assert artifact.downloadProgress.eta == 6 + + def test_stalled_speed_drops_to_zero(self): + stall_event = self.stall(0) + seen_speed = threading.Event() + + def release_once_zero(*args): + if self.artifact.downloadProgress.speed > 0: + seen_speed.set() + elif seen_speed.is_set(): + stall_event.set() + self.pm.send.side_effect = release_once_zero + self.download_file() + assert self.server.stall_released_by_event is True, "the stalled speed never reached exactly 0" + assert self.artifact.downloadProgress.speed > 0, "the speed must recover once data flows again" + + def test_pieces_download_in_parallel(self): + stall_event = self.stall(0) + others = {piece_range(i) for i in range(1, NUM_PIECES)} + + def release_when_others_requested(): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if others <= set(self.piece_requests()): + stall_event.set() + return + time.sleep(0.01) + + threading.Thread(target=release_when_others_requested).start() + self.download_file() + assert self.server.stall_released_by_event is True, "other pieces did not download while one was stalled" + + def test_fallback_when_server_ignores_ranges(self): + self.server.support_ranges = False + self.download_file() + assert self.read_path() == WHOLE_BODY + assert self.server.request_ranges == [None, None], "expected the probe plus one whole-body request" + assert self.server.request_encodings == ['identity', 'identity'] + + def test_transient_failures_are_retried(self): + self.server.fail_times = {piece_range(2): 1} + self.server.short_once = {piece_range(4)} + self.download_file() + assert self.read_path() == WHOLE_BODY + assert self.piece_requests().count(piece_range(2)) == 2 + assert self.piece_requests().count(piece_range(4)) == 2 + + def test_permanent_failure_is_not_retried(self): + self.server.fail_ranges = {piece_range(1): 404} + with self.assertRaises(requests.exceptions.HTTPError): + self.download_file() + assert self.piece_requests().count(piece_range(1)) == 1 + + def test_removing_download_ref_cancels(self): + stall_event = self.stall(3) + refs = iter([b"ref"]) + self.params.get.side_effect = lambda key: next(refs, None) # removed after the first poll + self.manager._download_ref = b"ref" + + def release_once_canceling(*args): + # the "canceling" report comes after the cancel event is set, so the piece can't finish first + if self.artifact.downloadProgress.canceling: + stall_event.set() + self.pm.send.side_effect = release_once_canceling + with self.assertRaises(DownloadCancelled): + self.download_file() + assert self.server.stall_released_by_event is True, "the cancel must have caught the piece mid-transfer" + end = piece_range(3)[1] + 1 + assert self.read_path()[end - 100:end] == bytes(100), "the cancelled piece must stop before its last bytes land" + + def test_replaced_download_ref_queues_instead_of_canceling(self): + self.params.get.return_value = b"other-ref" + self.manager._download_ref = b"ref" + self.download_file() + assert self.read_path() == WHOLE_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.""" +class TestProcessArtifact(ManagerDownloadTestBase): + def test_download_writes_only_its_slot(self): + ds = custom.ModelManagerSP.DownloadStatus + for source, other in (("qcom", "chestnut"), ("chestnut", "qcom")): # bernoulli + with self.subTest(source=source): + self.make_artifact() + self._bundle.ref = f"{source}-ref" + self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION + self.manager.params, store = self._make_params_with_store() + self.download_bundle(source) + assert ACTIVE_BUNDLE_KEYS[source] in store and ACTIVE_BUNDLE_KEYS[other] not in store + assert self.manager.selected_bundle.status == ds.downloaded + assert self.artifact.downloadProgress.progress == 100 + assert self.artifact.downloadProgress.eta == 0 and self.artifact.downloadProgress.speed == 0 + assert self.read_path() == WHOLE_BODY + os.remove(self.path) - def test_manager_imports(self): - assert manager_module.ModelManagerSP is not None + def test_hash_mismatch_discards_file(self): + self.server.corrupt = True + with self.assertRaises(ValueError): + self.process_artifact() + assert not os.path.isfile(self.path) + assert self.artifact.downloadProgress.status == custom.ModelManagerSP.DownloadStatus.failed - 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_probe_failure_moves_past_verifying(self): + self.server.fail_ranges = {(0, 0): 404} # the size probe + with self.assertRaises(requests.exceptions.HTTPError): + self.process_artifact() + ds = custom.ModelManagerSP.DownloadStatus + assert self.reported_statuses == [ds.verifying, ds.downloading, ds.failed] - 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" + def test_cached_file_skips_network(self): + with open(self.path, 'wb') as f: + f.write(WHOLE_BODY) + self.process_artifact() + assert self.server.request_ranges == [], "cached file must not hit the network" + ds = custom.ModelManagerSP.DownloadStatus + assert self.reported_statuses == [ds.verifying, ds.cached] + + def test_cached_bundle_cancel_skips_slot_write(self): + with open(self.path, 'wb') as f: + f.write(WHOLE_BODY) + self.make_artifact() + self.manager.params, store = self._make_params_with_store() + store["ModelManager_DownloadRef"] = None # removed -> cancelled + self.manager._download_ref = b"ref" + with self.assertRaises(DownloadCancelled): + self.download_bundle("qcom") + assert ACTIVE_BUNDLE_KEYS["qcom"] not in store + assert os.path.isfile(self.path), "cancel must not delete a cached model" + + def test_download_ref_is_released_only_if_unchanged(self): + for current, removed in ((b"new-ref", False), (b"ref", True)): + self.params = self.manager.params = mock.MagicMock() + self.params.get.return_value = current + self.manager._download_ref = b"ref" + self.manager._release_download_ref() + assert self.params.remove.called == removed + + +def raw_bundle(ref: str, runner: int | None = None) -> dict: + bundle = custom.ModelManagerSP.ModelBundle.new_message() + bundle.ref = ref + bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION + if runner is not None: + bundle.runner = runner + return bundle.to_dict() 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 test_ref_resolves_to_its_slot(self): + small, big = (custom.ModelManagerSP.ModelBundle(**raw_bundle(ref)) for ref in ("small", "big")) + catalog = {"qcom": [small], "chestnut": [big]} + assert resolve_bundle_by_ref("small", catalog) == (small, "qcom") + assert resolve_bundle_by_ref("big", catalog) == (big, "chestnut") + assert resolve_bundle_by_ref("nope", catalog) 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, @@ -481,309 +423,127 @@ def manifest_bundle(short_name: str, ref: str, index: int = 0, is_big: bool = Fa } -def fresh_sync_time() -> int: - return int(time.monotonic() * 1e9) +def manifest_params(qcom_manifest, chestnut_manifest=None): + values = { + "ModelManager_ModelsCache": qcom_manifest, + "ModelManager_ModelsCache_Chestnut": chestnut_manifest or {"bundles": [manifest_bundle("big", "bbb", is_big=True)]}, + "ModelManager_LastSyncTime": int(time.monotonic() * 1e9), + "ModelManager_LastSyncTime_Chestnut": int(time.monotonic() * 1e9), + } + params = mock.MagicMock() + params.get.side_effect = values.get + return params -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 +class TestModelFetcher(OpenpilotTestCase): + def refetch(self, fetcher, *bundles): + fetched = fetcher.model_parser.parse_models({"bundles": list(bundles)}) + return mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched) 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): + def test_unknown_source_has_no_bundles(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_get_cached_bundles_reads_its_source(self): + params = manifest_params({"bundles": [manifest_bundle("small", "aaa")]}) + assert [b.ref for b in get_cached_bundles(params, "qcom")] == ["aaa"] + assert [b.ref for b in get_cached_bundles(params, "chestnut")] == ["bbb"] + 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"] + params.put.assert_any_call("ModelManager_ActiveJson", {"qcom": ModelFetcher.MODEL_URL, "chestnut": ModelFetcher.MODEL_URL_CHESTNUT}, block=True) 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) + fetcher = ModelFetcher(manifest_params({"bundles": [manifest_bundle("small", "aaa")]})) 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_unusable_cache_is_refetched(self): + stale = {**manifest_bundle("small", "aaa"), "minimum_selector_version": "16"} # parses to zero valid bundles + corrupt = {"short_name": "broken"} # missing required fields + for cached in (stale, corrupt): + with self.subTest(cached=cached): + fetcher = ModelFetcher(manifest_params({"bundles": [cached]})) + with self.refetch(fetcher, manifest_bundle("small2", "ddd")) as fetch: + assert [bundle.ref for bundle in fetcher.get_bundles_for_source("qcom")] == ["ddd"] + fetch.assert_called_once_with("qcom") - 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: + def test_mismatched_cache_is_refetched_once(self): + fetcher = ModelFetcher(manifest_params({"bundles": [manifest_bundle("big", "bbb", is_big=True)]})) + with self.refetch(fetcher, manifest_bundle("big", "bbb", is_big=True)) 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"] + assert [bundle.ref for bundle in first] == [bundle.ref for bundle in second] == ["bbb"] -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 ref_of(bundle) -> str | None: + return None if bundle is None else bundle.ref + +def slot_params(qcom=None, chestnut=None): + params = mock.MagicMock() + params.get.side_effect = {"ModelManager_ActiveBundle": qcom, "ModelManager_ActiveBundleChestnut": chestnut}.get + return params + + +class TestActiveBundle(OpenpilotTestCase): def setUp(self): super().setUp() helpers._LAST_VALIDATED_RAW.clear() - @staticmethod - def _raw_bundle(ref: str, runner: int | None = None) -> dict: - bundle = custom.ModelManagerSP.ModelBundle.new_message() - bundle.ref = ref - bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION - if runner is not None: - bundle.runner = runner - return bundle.to_dict() + def test_active_source(self): + for gpu, source in ((False, "qcom"), (True, "chestnut")): + with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=gpu): + assert get_active_source() == source + for state, source in ( + ((True, False, False, True), "chestnut"), # offroad + ((True, False, True, False), "chestnut"), # onroad, big model loading + ((True, True, False, False), "chestnut"), # onroad, big model running + ((True, False, False, False), "qcom"), # onroad, big model idle + ((True, None, False, False), "qcom"), # onroad, big model state not known yet + ((False, False, False, True), "qcom"), # offroad, no GPU + ): + chestnut, active, loading, offroad = state + assert get_active_source(chestnut=chestnut, chestnut_active=active, chestnut_loading=loading, offroad=offroad) == source, state - 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_active_bundle_is_the_active_slot(self): + params = slot_params(qcom=raw_bundle("small"), chestnut=raw_bundle("big")) + assert ref_of(get_selected_bundle(params, "qcom")) == "small" + assert ref_of(get_selected_bundle(params, "chestnut")) == "big" + for gpu, ref in ((False, "small"), (True, "big")): + with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=gpu): + assert ref_of(get_active_bundle(params)) == ref + with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=True): + assert get_active_bundle(slot_params(qcom=raw_bundle("small"))) is None, \ + "an empty chestnut slot must not fall back to the qcom pick" def test_empty_catalog_does_not_reset_slot(self): - params = self._params(qcom=self._raw_bundle("small")) + params = slot_params(qcom=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"))], + big_raw = raw_bundle("big", runner=tinygrad) + params = slot_params(qcom=raw_bundle("gone"), chestnut=big_raw) + catalog = {"qcom": [custom.ModelManagerSP.ModelBundle(**raw_bundle("other"))], "chestnut": [custom.ModelManagerSP.ModelBundle(**big_raw)]} with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=True): validate_active_bundles(params, catalog) params.remove.assert_called_once_with("ModelManager_ActiveBundle") - runner_puts = [call for call in params.put.call_args_list if call.args[0] == "ModelRunnerTypeCache"] - assert [call.args[1] for call in runner_puts] == [tinygrad] - - -class TestActiveBundleSelection(OpenpilotTestCase): - """The effective active bundle is the active source's slot: chestnut when a GPU is - present, qcom otherwise. An empty active slot means the hardware default (stock - runner), never the other slot's pick - modeld_v2 requires a real bundle.""" - - @staticmethod - def _raw_bundle(ref: str) -> dict: - bundle = custom.ModelManagerSP.ModelBundle.new_message() - bundle.ref = ref - bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION - return bundle.to_dict() - - def _params(self, qcom=None, chestnut=None): - params = mock.MagicMock() - - def get(key, *args, **kwargs): - if key == "ModelManager_ActiveBundle": - return qcom - if key == "ModelManager_ActiveBundleChestnut": - return chestnut - return None - - params.get.side_effect = get - return params - - def test_selected_bundle_is_per_slot(self): - params = self._params(qcom=self._raw_bundle("small"), chestnut=self._raw_bundle("big")) - assert get_selected_bundle(params, "qcom").ref == "small" - assert get_selected_bundle(params, "chestnut").ref == "big" - - def test_no_gpu_uses_qcom_slot(self): - params = self._params(qcom=self._raw_bundle("small"), chestnut=self._raw_bundle("big")) - with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=False): - assert get_active_bundle(params).ref == "small" - - def test_gpu_uses_chestnut_slot(self): - params = self._params(qcom=self._raw_bundle("small"), chestnut=self._raw_bundle("big")) - with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=True): - assert get_active_bundle(params).ref == "big" - - def test_gpu_without_big_selection_is_hardware_default(self): - params = self._params(qcom=self._raw_bundle("small"), chestnut=None) - with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=True): - assert get_active_bundle(params) is None - - -class TestEffectiveSource(OpenpilotTestCase): - """One gate decides the active source. With no flags it is runtime truth (GPU - attached); display callers (mici) pass the ui_state flags, which additionally - require the big model to be loading, active, or the device offroad. The active - bundle is simply the selected bundle of that source.""" - - @staticmethod - def _raw_bundle(ref: str) -> dict: - bundle = custom.ModelManagerSP.ModelBundle.new_message() - bundle.ref = ref - bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION - return bundle.to_dict() - - def test_runtime_no_gpu(self): - with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=False): - assert get_active_source() == "qcom" - - def test_runtime_gpu_present(self): - with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=True): - assert get_active_source() == "chestnut" - - def test_display_offroad_gpu_present_shows_big(self): - assert get_active_source(chestnut=True, chestnut_active=False, chestnut_loading=False, offroad=True) == "chestnut" - - def test_display_onroad_gpu_loading_shows_big(self): - assert get_active_source(chestnut=True, chestnut_active=False, chestnut_loading=True, offroad=False) == "chestnut" - - def test_display_onroad_gpu_active_shows_big(self): - assert get_active_source(chestnut=True, chestnut_active=True, chestnut_loading=False, offroad=False) == "chestnut" - - def test_display_onroad_gpu_idle_shows_small(self): - assert get_active_source(chestnut=True, chestnut_active=False, chestnut_loading=False, offroad=False) == "qcom" - - def test_display_active_none_is_idle(self): - assert get_active_source(chestnut=True, chestnut_active=None, chestnut_loading=False, offroad=False) == "qcom" - - def test_active_bundle_follows_source(self): - params = mock.MagicMock() - params.get.side_effect = lambda key: {"ModelManager_ActiveBundle": self._raw_bundle("small"), - "ModelManager_ActiveBundleChestnut": self._raw_bundle("big")}.get(key) - with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=False): - assert get_active_bundle(params).ref == "small" - assert get_selected_bundle(params, get_active_source(chestnut=True, chestnut_active=False, - chestnut_loading=False, offroad=True)).ref == "big" + params.put.assert_called_once_with("ModelRunnerTypeCache", tinygrad, block=True) @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 @@ -795,17 +555,12 @@ class TestLiveModelManifest(OpenpilotTestCase): 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}") - + try: + r = session.head(url, timeout=15, allow_redirects=True) + if r.status_code != 200: + dead.append(f"{bundle.get('short_name')}: HTTP {r.status_code} {url}") + except requests.RequestException as e: + dead.append(f"{bundle.get('short_name')}: {type(e).__name__} {url}") assert not dead, "unreachable model URLs:\n" + "\n".join(dead) diff --git a/openpilot/sunnypilot/selfdrive/car/cruise_helpers.py b/openpilot/sunnypilot/selfdrive/car/cruise_helpers.py deleted file mode 100644 index aa689ec904..0000000000 --- a/openpilot/sunnypilot/selfdrive/car/cruise_helpers.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. - -This file is part of sunnypilot and is licensed under the MIT License. -See the LICENSE.md file in the root directory for more details. -""" - -from openpilot.cereal import custom -from opendbc.car.structs import car -from opendbc.car import structs -from openpilot.common.params import Params - -ButtonType = car.CarState.ButtonEvent.Type -EventNameSP = custom.OnroadEventSP.EventName - -DISTANCE_LONG_PRESS = 50 - - -class CruiseHelper: - def __init__(self, CP: structs.CarParams): - self.CP = CP - self.params = Params() - - self.button_frame_counts = {ButtonType.gapAdjustCruise: 0} - self._experimental_mode = False - self.experimental_mode_switched = False - - def update(self, CS, events, experimental_mode) -> None: - if self.CP.openpilotLongitudinalControl: - if CS.cruiseState.available: - self.update_button_frame_counts(CS) - - # toggle experimental mode once on distance button hold - self.update_experimental_mode(events, experimental_mode) - - def update_button_frame_counts(self, CS) -> None: - for button in self.button_frame_counts: - if self.button_frame_counts[button] > 0: - self.button_frame_counts[button] += 1 - - for button_event in CS.buttonEvents: - button = button_event.type.raw - if button in self.button_frame_counts: - self.button_frame_counts[button] = int(button_event.pressed) - - def update_experimental_mode(self, events, experimental_mode) -> None: - if self.button_frame_counts[ButtonType.gapAdjustCruise] >= DISTANCE_LONG_PRESS and not self.experimental_mode_switched: - self._experimental_mode = not experimental_mode - self.params.put_bool("ExperimentalMode", self._experimental_mode) - events.add(EventNameSP.experimentalModeSwitched) - self.experimental_mode_switched = True diff --git a/openpilot/sunnypilot/selfdrive/car/interfaces.py b/openpilot/sunnypilot/selfdrive/car/interfaces.py index 4988e863b1..264f5168d6 100644 --- a/openpilot/sunnypilot/selfdrive/car/interfaces.py +++ b/openpilot/sunnypilot/selfdrive/car/interfaces.py @@ -13,18 +13,9 @@ from openpilot.common.swaglog import cloudlog from openpilot.sunnypilot.selfdrive.controls.lib.nnlc.helpers import get_nn_model_path from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.helpers import set_speed_limit_assist_availability -import openpilot.system.sentry as sentry - from openpilot.sunnypilot.sunnylink.statsd import STATSLOGSP -def log_fingerprint(CP: structs.CarParams) -> None: - if CP.carFingerprint == "MOCK": - sentry.capture_fingerprint_mock() - else: - sentry.capture_fingerprint(CP.carFingerprint, CP.brand) - - def _enforce_torque_lateral_control(CP: structs.CarParams, params: Params | None = None, enabled: bool = False) -> bool: if params is None: params = Params() @@ -90,7 +81,6 @@ def _cleanup_unsupported_params(CP: structs.CarParams, CP_SP: structs.CarParamsS if not CP.openpilotLongitudinalControl and CP_SP.pcmCruiseSpeed: cloudlog.warning("openpilot Longitudinal Control and ICBM not available, cleaning up params") - params.remove("DynamicExperimentalControl") params.remove("CustomAccIncrementsEnabled") params.remove("SmartCruiseControlVision") params.remove("SmartCruiseControlMap") diff --git a/openpilot/sunnypilot/selfdrive/car/tests/test_cruise_mode.py b/openpilot/sunnypilot/selfdrive/car/tests/test_cruise_mode.py deleted file mode 100644 index b5cd9f6f87..0000000000 --- a/openpilot/sunnypilot/selfdrive/car/tests/test_cruise_mode.py +++ /dev/null @@ -1,67 +0,0 @@ -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 - -ButtonEvent = car.CarState.ButtonEvent -ButtonType = car.CarState.ButtonEvent.Type - - -@parameterized_class(('openpilot_longitudinal',), [(True,)]) -class TestCruiseHelper(OpenpilotTestCase): - def setup_method(self): - self.CP = car.CarParams(openpilotLongitudinalControl=self.openpilot_longitudinal) - self.cruise_helper = CruiseHelper(self.CP) - self.cruise_helper.experimental_mode_switched = False - self.events = Events() - - def reset(self): - for _ in range(2): - CS = car.CarState(cruiseState={"available": False}) - CS.buttonEvents = [ButtonEvent(type=ButtonType.gapAdjustCruise, pressed=False)] - self.cruise_helper._experimental_mode = False - self.cruise_helper.experimental_mode_switched = False - self.cruise_helper.update(CS, self.events, False) - - - def test_gap_adjust_cruise_long_press_toggle_mode(self) -> None: - for pressed in (True, False): - for experimental_mode in (True, False): - self.reset() - self.cruise_helper._experimental_mode = experimental_mode - toggled_mode = not experimental_mode if pressed else experimental_mode - - for i in range(DISTANCE_LONG_PRESS): - CS = car.CarState(cruiseState={"available": True}) - CS.buttonEvents = [ButtonEvent(type=ButtonType.gapAdjustCruise, pressed=pressed)] if i == 0 else [] - self.cruise_helper.update(CS, self.events, experimental_mode) - - # mode should be toggled - assert self.cruise_helper._experimental_mode == toggled_mode - assert self.cruise_helper.experimental_mode_switched is pressed - - # keep holding button after switching mode - for _ in range(DISTANCE_LONG_PRESS): - CS = car.CarState(cruiseState={"available": True}) - CS.buttonEvents = [ButtonEvent(type=ButtonType.gapAdjustCruise, pressed=pressed)] - self.cruise_helper.update(CS, self.events, toggled_mode) - - # mode should not be toggled - assert self.cruise_helper._experimental_mode == toggled_mode - assert self.cruise_helper.experimental_mode_switched is pressed - - def test_gap_adjust_cruise_short_press_toggle_mode(self) -> None: - for pressed in (True, False): - for experimental_mode in (True, False): - self.reset() - self.cruise_helper._experimental_mode = experimental_mode - - for i in range(DISTANCE_LONG_PRESS - 1): - CS = car.CarState(cruiseState={"available": True}) - CS.buttonEvents = [ButtonEvent(type=ButtonType.gapAdjustCruise, pressed=pressed)] if i == 0 else [] - self.cruise_helper.update(CS, self.events, experimental_mode) - - # mode should not be toggled - assert self.cruise_helper._experimental_mode == experimental_mode - assert self.cruise_helper.experimental_mode_switched is False diff --git a/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py b/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py index 56491e3518..e8c02a3360 100644 --- a/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py +++ b/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py @@ -57,7 +57,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): def test_default_behavior_when_disabled(self): """Test that default increments are used when custom ACC is disabled""" self.set_custom_increments(enabled=False, short_inc=5, long_inc=10) - self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS) initial_speed = self.v_cruise_helper.v_cruise_kph @@ -69,7 +69,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): 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) - self.enable(50 * CV.KPH_TO_MS, False, False) + self.enable(50 * CV.KPH_TO_MS) initial_speed = self.v_cruise_helper.v_cruise_kph self.press_button_short(ButtonType.accelCruise) @@ -86,7 +86,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): 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) - self.enable(50 * CV.KPH_TO_MS, False, False) + self.enable(50 * CV.KPH_TO_MS) initial_speed = self.v_cruise_helper.v_cruise_kph self.press_button_long(ButtonType.accelCruise) @@ -103,7 +103,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): 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) - self.enable(50 * CV.KPH_TO_MS, False, False) + self.enable(50 * CV.KPH_TO_MS) initial_speed = self.v_cruise_helper.v_cruise_kph self.press_button_short(button_type) @@ -123,7 +123,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): for initial, increment, expected in test_cases: self.set_custom_increments(enabled=True, short_inc=increment, long_inc=increment) self.reset_cruise_speed_state() - self.enable(initial * CV.KPH_TO_MS, False, False) + self.enable(initial * CV.KPH_TO_MS) self.press_button_short(ButtonType.accelCruise) assert self.v_cruise_helper.v_cruise_kph == expected @@ -132,7 +132,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): """Test that invalid values fallback to safe defaults""" # Test invalid short increment self.set_custom_increments(enabled=True, short_inc=-1, long_inc=5) - self.enable(50 * CV.KPH_TO_MS, False, False) + self.enable(50 * CV.KPH_TO_MS) initial_speed = self.v_cruise_helper.v_cruise_kph self.press_button_short(ButtonType.accelCruise) @@ -141,7 +141,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): # Test invalid long increment self.reset_cruise_speed_state() self.set_custom_increments(enabled=True, short_inc=1, long_inc=99) - self.enable(50 * CV.KPH_TO_MS, False, False) + self.enable(50 * CV.KPH_TO_MS) initial_speed = self.v_cruise_helper.v_cruise_kph self.press_button_long(ButtonType.accelCruise) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/constants.py b/openpilot/sunnypilot/selfdrive/controls/lib/dec/constants.py deleted file mode 100644 index 4586afbc9f..0000000000 --- a/openpilot/sunnypilot/selfdrive/controls/lib/dec/constants.py +++ /dev/null @@ -1,17 +0,0 @@ -class WMACConstants: - # Lead detection parameters - LEAD_WINDOW_SIZE = 6 # Stable detection window - LEAD_PROB = 0.45 # Balanced threshold for lead detection - - # Slow down detection parameters - SLOW_DOWN_WINDOW_SIZE = 5 # Responsive but stable - SLOW_DOWN_PROB = 0.3 # Balanced threshold for slow down scenarios - - # Optimized slow down distance curve - smooth and progressive - SLOW_DOWN_BP = [0., 10., 20., 30., 40., 50., 55., 60.] - SLOW_DOWN_DIST = [32., 46., 64., 86., 108., 130., 145., 165.] - - # Slowness detection parameters - SLOWNESS_WINDOW_SIZE = 10 # Stable slowness detection - SLOWNESS_PROB = 0.55 # Clear threshold for slowness - SLOWNESS_CRUISE_OFFSET = 1.025 # Conservative cruise speed offset diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/dec.py b/openpilot/sunnypilot/selfdrive/controls/lib/dec/dec.py deleted file mode 100644 index fb854edae8..0000000000 --- a/openpilot/sunnypilot/selfdrive/controls/lib/dec/dec.py +++ /dev/null @@ -1,388 +0,0 @@ -""" -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. -""" -# Version = 2025-6-30 - -from openpilot.cereal import messaging -from opendbc.car import structs -from numpy import interp -from openpilot.common.params import Params -from openpilot.common.realtime import DT_MDL -from openpilot.sunnypilot.selfdrive.controls.lib.dec.constants import WMACConstants -from typing import Literal - -# d-e2e, from modeldata.h -TRAJECTORY_SIZE = 33 -SET_MODE_TIMEOUT = 15 - -# Define the valid mode types -ModeType = Literal['acc', 'blended'] - - -class SmoothKalmanFilter: - """Enhanced Kalman filter with smoothing for stable decision making.""" - - def __init__(self, initial_value=0, measurement_noise=0.1, process_noise=0.01, - alpha=1.0, smoothing_factor=0.85): - self.x = initial_value - self.P = 1.0 - self.R = measurement_noise - self.Q = process_noise - self.alpha = alpha - self.smoothing_factor = smoothing_factor - self.initialized = False - self.history = [] - self.max_history = 10 - self.confidence = 0.0 - - def add_data(self, measurement): - if len(self.history) >= self.max_history: - self.history.pop(0) - self.history.append(measurement) - - if not self.initialized: - self.x = measurement - self.initialized = True - self.confidence = 0.1 - return - - self.P = self.alpha * self.P + self.Q - - K = self.P / (self.P + self.R) - effective_K = K * (1.0 - self.smoothing_factor) + self.smoothing_factor * 0.1 - - innovation = measurement - self.x - self.x = self.x + effective_K * innovation - self.P = (1 - effective_K) * self.P - - if abs(innovation) < 0.1: - self.confidence = min(1.0, self.confidence + 0.05) - else: - self.confidence = max(0.1, self.confidence - 0.02) - - def get_value(self): - return self.x if self.initialized else None - - def get_confidence(self): - return self.confidence - - def reset_data(self): - self.initialized = False - self.history = [] - self.confidence = 0.0 - - -class ModeTransitionManager: - """Manages smooth transitions between driving modes with hysteresis.""" - - def __init__(self): - self.current_mode: ModeType = 'acc' - self.mode_confidence = {'acc': 1.0, 'blended': 0.0} - self.transition_timeout = 0 - self.min_mode_duration = 10 - self.mode_duration = 0 - self.emergency_override = False - - def request_mode(self, mode: ModeType, confidence: float = 1.0, emergency: bool = False): - # Emergency override for critical situations (stops, collisions) - if emergency: - self.emergency_override = True - self.current_mode = mode - self.transition_timeout = SET_MODE_TIMEOUT - self.mode_duration = 0 - return - - self.mode_confidence[mode] = min(1.0, self.mode_confidence[mode] + 0.1 * confidence) - for m in self.mode_confidence: - if m != mode: - self.mode_confidence[m] = max(0.0, self.mode_confidence[m] - 0.05) - - # Require minimum duration in current mode (unless emergency) - if self.mode_duration < self.min_mode_duration and not self.emergency_override: - return - - # Hysteresis: higher threshold for mode changes - confidence_threshold = 0.6 if mode != self.current_mode else 0.3 # Lower threshold for faster response - - if self.mode_confidence[mode] > confidence_threshold: - if mode != self.current_mode and self.transition_timeout == 0: - self.transition_timeout = SET_MODE_TIMEOUT - self.current_mode = mode - self.mode_duration = 0 - - def update(self): - if self.transition_timeout > 0: - self.transition_timeout -= 1 - self.mode_duration += 1 - - # Reset emergency override after some time - if self.emergency_override and self.mode_duration > 20: - self.emergency_override = False - - # Gradual confidence decay - for mode in self.mode_confidence: - self.mode_confidence[mode] *= 0.98 - - def get_mode(self) -> ModeType: - return self.current_mode - - -class DynamicExperimentalController: - def __init__(self, CP: structs.CarParams, mpc, params=None): - self._CP = CP - self._mpc = mpc - self._params = params or Params() - self._enabled: bool = self._params.get_bool("DynamicExperimentalControl") - self._active: bool = False - self._frame: int = 0 - self._urgency = 0.0 - - self._mode_manager = ModeTransitionManager() - - # Smooth filters for stable decision making with faster response for critical scenarios - self._lead_filter = SmoothKalmanFilter( - measurement_noise=0.15, - process_noise=0.05, - alpha=1.02, - smoothing_factor=0.8 - ) - - self._slow_down_filter = SmoothKalmanFilter( - measurement_noise=0.1, - process_noise=0.1, - alpha=1.05, - smoothing_factor=0.7 - ) - - self._slowness_filter = SmoothKalmanFilter( - measurement_noise=0.1, - process_noise=0.06, - alpha=1.015, - smoothing_factor=0.92 - ) - - self._mpc_fcw_filter = SmoothKalmanFilter( - measurement_noise=0.2, - process_noise=0.1, - alpha=1.1, - smoothing_factor=0.5 - ) - self._has_lead_filtered = False - self._has_slow_down = False - self._has_slowness = False - self._has_mpc_fcw = False - self._v_ego_kph = 0.0 - self._v_cruise_kph = 0.0 - self._has_standstill = False - self._mpc_fcw_crash_cnt = 0 - self._standstill_count = 0 - # debug - self._endpoint_x = float('inf') - self._expected_distance = 0.0 - self._trajectory_valid = False - - def _read_params(self) -> None: - if self._frame % int(1. / DT_MDL) == 0: - self._enabled = self._params.get_bool("DynamicExperimentalControl") - - def mode(self) -> str: - return self._mode_manager.get_mode() - - def enabled(self) -> bool: - return self._enabled - - def active(self) -> bool: - return self._active - - def set_mpc_fcw_crash_cnt(self) -> None: - """Set MPC FCW crash count""" - self._mpc_fcw_crash_cnt = self._mpc.crash_cnt - - def _update_calculations(self, sm: messaging.SubMaster) -> None: - car_state = sm['carState'] - lead_one = sm['radarState'].leadOne - md = sm['modelV2'] - - self._v_ego_kph = car_state.vEgo * 3.6 - self._v_cruise_kph = car_state.vCruise - self._has_standstill = car_state.standstill - - # standstill detection - if self._has_standstill: - self._standstill_count = min(20, self._standstill_count + 1) - else: - self._standstill_count = max(0, self._standstill_count - 1) - - # Lead detection - self._lead_filter.add_data(float(lead_one.present)) - lead_value = self._lead_filter.get_value() or 0.0 - self._has_lead_filtered = lead_value > WMACConstants.LEAD_PROB - - # MPC FCW detection - fcw_filtered_value = self._mpc_fcw_filter.get_value() or 0.0 - self._mpc_fcw_filter.add_data(float(self._mpc_fcw_crash_cnt > 0)) - self._has_mpc_fcw = fcw_filtered_value > 0.5 - - # Slow down detection - self._calculate_slow_down(md) - - # Slowness detection - if not (self._standstill_count > 5) and not self._has_slow_down: - current_slowness = float(self._v_ego_kph <= (self._v_cruise_kph * WMACConstants.SLOWNESS_CRUISE_OFFSET)) - self._slowness_filter.add_data(current_slowness) - slowness_value = self._slowness_filter.get_value() or 0.0 - - # Hysteresis for slowness - threshold = WMACConstants.SLOWNESS_PROB * (0.8 if self._has_slowness else 1.1) - self._has_slowness = slowness_value > threshold - - def _calculate_slow_down(self, md): - """Calculate urgency based on trajectory endpoint vs expected distance.""" - - # Reset to safe defaults - urgency = 0.0 - self._endpoint_x = float('inf') - self._trajectory_valid = False - - #Require exact trajectory size - position_valid = len(md.position.x) == TRAJECTORY_SIZE - orientation_valid = len(md.orientation.x) == TRAJECTORY_SIZE - - if not (position_valid and orientation_valid): - # Invalid trajectory - this itself might indicate a stop scenario - # Apply moderate urgency for incomplete trajectories at speed - if self._v_ego_kph > 20.0: - urgency = 0.3 - - self._slow_down_filter.add_data(urgency) - urgency_filtered = self._slow_down_filter.get_value() or 0.0 - self._has_slow_down = urgency_filtered > WMACConstants.SLOW_DOWN_PROB - self._urgency = urgency_filtered - return - - # We have a valid full trajectory - self._trajectory_valid = True - - # Use the exact endpoint (33rd point, index 32) - endpoint_x = md.position.x[TRAJECTORY_SIZE - 1] - self._endpoint_x = endpoint_x - - # Get expected distance based on current speed using tuned constants - expected_distance = interp(self._v_ego_kph, - WMACConstants.SLOW_DOWN_BP, - WMACConstants.SLOW_DOWN_DIST) - self._expected_distance = expected_distance - - # Calculate urgency based on trajectory shortage - if endpoint_x < expected_distance: - shortage = expected_distance - endpoint_x - shortage_ratio = shortage / expected_distance - - # Base urgency on shortage ratio - urgency = min(1.0, shortage_ratio * 2.0) - - # Increase urgency for very short trajectories (imminent stops) - critical_distance = expected_distance * 0.3 - if endpoint_x < critical_distance: - urgency = min(1.0, urgency * 2.0) - - # Speed-based urgency adjustment - if self._v_ego_kph > 25.0: - speed_factor = 1.0 + (self._v_ego_kph - 25.0) / 80.0 - urgency = min(1.0, urgency * speed_factor) - - # Apply filtering but with less smoothing for stops - self._slow_down_filter.add_data(urgency) - urgency_filtered = self._slow_down_filter.get_value() or 0.0 - - # Update state with lower threshold for better stop detection - self._has_slow_down = urgency_filtered > (WMACConstants.SLOW_DOWN_PROB * 0.8) - self._urgency = urgency_filtered - - def _radarless_mode(self) -> None: - """Radarless mode decision logic with emergency handling.""" - - # EMERGENCY: MPC FCW - immediate blended mode - if self._has_mpc_fcw: - self._mode_manager.request_mode('blended', confidence=1.0, emergency=True) - return - - # Standstill: use blended - if self._standstill_count > 3: - self._mode_manager.request_mode('blended', confidence=0.9) - return - - # Slow down scenarios: emergency for high urgency, normal for lower urgency - if self._has_slow_down: - if self._urgency > 0.7: - # Emergency: immediate blended mode for high urgency stops - self._mode_manager.request_mode('blended', confidence=1.0, emergency=True) - else: - # Normal: blended with urgency-based confidence - confidence = min(1.0, self._urgency * 1.5) - self._mode_manager.request_mode('blended', confidence=confidence) - return - - # Driving slow: use ACC (but not if actively slowing down) - if self._has_slowness and not self._has_slow_down: - self._mode_manager.request_mode('acc', confidence=0.8) - return - - # Default: ACC - self._mode_manager.request_mode('acc', confidence=0.7) - - def _radar_mode(self) -> None: - """Radar mode with emergency handling.""" - - # EMERGENCY: MPC FCW - immediate blended mode - if self._has_mpc_fcw: - self._mode_manager.request_mode('blended', confidence=1.0, emergency=True) - return - - # If lead detected and not in standstill: always use ACC - if self._has_lead_filtered and not (self._standstill_count > 3): - self._mode_manager.request_mode('acc', confidence=1.0) - return - - # Slow down scenarios: emergency for high urgency, normal for lower urgency - if self._has_slow_down: - if self._urgency > 0.7: - # Emergency: immediate blended mode for high urgency stops - self._mode_manager.request_mode('blended', confidence=1.0, emergency=True) - else: - # Normal: blended with urgency-based confidence - confidence = min(1.0, self._urgency * 1.3) - self._mode_manager.request_mode('blended', confidence=confidence) - return - - # Standstill: use blended - if self._standstill_count > 3: - self._mode_manager.request_mode('blended', confidence=0.9) - return - - # Driving slow: use ACC (but not if actively slowing down) - if self._has_slowness and not self._has_slow_down: - self._mode_manager.request_mode('acc', confidence=0.8) - return - - # Default: ACC - self._mode_manager.request_mode('acc', confidence=0.7) - - def update(self, sm: messaging.SubMaster) -> None: - self._read_params() - - self.set_mpc_fcw_crash_cnt() - - self._update_calculations(sm) - - if self._CP.radarUnavailable: - self._radarless_mode() - else: - self._radar_mode() - - self._mode_manager.update() - self._active = sm['selfdriveState'].experimentalMode and self._enabled - self._frame += 1 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 deleted file mode 100644 index 1f5c577028..0000000000 --- a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dec_planner_gate.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. - -This file is part of sunnypilot and is licensed under the MIT License. -See the LICENSE.md file in the root directory for more details. -""" -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 deleted file mode 100644 index 4fec6eaa52..0000000000 --- a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dynamic_controller.py +++ /dev/null @@ -1,91 +0,0 @@ -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/longitudinal_planner.py b/openpilot/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py index f1e0c36416..a579e61c59 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py @@ -9,7 +9,6 @@ from openpilot.cereal import messaging, custom from opendbc.car import structs from openpilot.common.constants import CV from openpilot.selfdrive.car.cruise import V_CRUISE_MAX -from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController from openpilot.sunnypilot.selfdrive.controls.lib.e2e_alerts_helper import E2EAlertsHelper from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.smart_cruise_control import SmartCruiseControl from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_assist import SpeedLimitAssist @@ -17,7 +16,6 @@ from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_resolve from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP from openpilot.sunnypilot.models.helpers import get_active_bundle -DecState = custom.LongitudinalPlanSP.DynamicExperimentalControl.DynamicExperimentalControlState LongitudinalPlanSource = custom.LongitudinalPlanSP.LongitudinalPlanSource @@ -25,7 +23,6 @@ class LongitudinalPlannerSP: def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParamsSP, mpc): self.events_sp = EventsSP() self.resolver = SpeedLimitResolver() - self.dec = DynamicExperimentalController(CP, mpc) self.scc = SmartCruiseControl() self.resolver = SpeedLimitResolver() self.sla = SpeedLimitAssist(CP, CP_SP) @@ -36,13 +33,6 @@ class LongitudinalPlannerSP: self.output_v_target = 0. self.output_a_target = 0. - def is_e2e(self, sm: messaging.SubMaster) -> bool: - experimental_mode = sm['selfdriveState'].experimentalMode - if not self.dec.active(): - return experimental_mode - - return experimental_mode and self.dec.mode() == "blended" - def update_targets(self, sm: messaging.SubMaster, v_ego: float, a_ego: float, v_cruise: float) -> tuple[float, float]: CS = sm['carState'] v_cruise_cluster_kph = min(CS.vCruiseCluster, V_CRUISE_MAX) @@ -75,7 +65,6 @@ class LongitudinalPlannerSP: def update(self, sm: messaging.SubMaster) -> None: self.events_sp.clear() - self.dec.update(sm) self.e2e_alerts_helper.update(sm, self.events_sp) def publish_longitudinal_plan_sp(self, sm: messaging.SubMaster, pm: messaging.PubMaster) -> None: @@ -89,12 +78,6 @@ class LongitudinalPlannerSP: longitudinalPlanSP.aTarget = float(self.output_a_target) longitudinalPlanSP.events = self.events_sp.to_msg() - # Dynamic Experimental Control - dec = longitudinalPlanSP.dec - dec.state = DecState.blended if self.dec.mode() == 'blended' else DecState.acc - dec.enabled = self.dec.enabled() - dec.active = self.dec.active() - # Smart Cruise Control smartCruiseControl = longitudinalPlanSP.smartCruiseControl # Vision Control 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 f3ed0fc07b..0d85f060e1 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 @@ -2,12 +2,14 @@ import json import math import platform +import openpilot.cereal.messaging as messaging from openpilot.cereal import custom +from openpilot.common.gps import get_gps_location_service 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 import PARAMS_UPDATE_PERIOD -from openpilot.sunnypilot.navd.helpers import coordinate_from_param, Coordinate +from openpilot.sunnypilot.navd.helpers import Coordinate from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control import MIN_V MapState = VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.MapState @@ -84,7 +86,8 @@ class SmartCruiseControlMap: self.target_lon = 0.0 self.frame = -1 - self.last_position = coordinate_from_param("LastGPSPosition", self.mem_params) or Coordinate(0.0, 0.0) + self.gps_location_service = get_gps_location_service(self.params) + self.last_position = Coordinate(0.0, 0.0) self.target_velocities = velocities_from_param("MapTargetVelocities", self.mem_params) or [] def get_v_target_from_control(self) -> float: @@ -100,8 +103,9 @@ class SmartCruiseControlMap: if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: self.enabled = self.params.get_bool("SmartCruiseControlMap") - def update_calculations(self) -> None: - self.last_position = coordinate_from_param("LastGPSPosition", self.mem_params) or Coordinate(0.0, 0.0) + def update_calculations(self, sm: messaging.SubMaster) -> None: + gps = sm[self.gps_location_service] + self.last_position = Coordinate(gps.latitude, gps.longitude) lat = self.last_position.latitude lon = self.last_position.longitude @@ -243,7 +247,7 @@ class SmartCruiseControlMap: return enabled, active - def update(self, long_enabled: bool, long_override: bool, v_ego, a_ego, v_cruise) -> None: + def update(self, sm: messaging.SubMaster, long_enabled: bool, long_override: bool, v_ego, a_ego, v_cruise) -> None: self.long_enabled = long_enabled self.long_override = long_override self.v_ego = v_ego @@ -251,7 +255,7 @@ class SmartCruiseControlMap: self.v_cruise = v_cruise self.update_params() - self.update_calculations() + self.update_calculations(sm) self.is_enabled, self.is_active = self._update_state_machine() diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/smart_cruise_control.py b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/smart_cruise_control.py index ff6c706634..62b0dcf590 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/smart_cruise_control.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/smart_cruise_control.py @@ -15,5 +15,5 @@ class SmartCruiseControl: self.map = SmartCruiseControlMap() def update(self, sm: messaging.SubMaster, long_enabled: bool, long_override: bool, v_ego: float, a_ego: float, v_cruise: float) -> None: - self.map.update(long_enabled, long_override, v_ego, a_ego, v_cruise) + self.map.update(sm, long_enabled, long_override, v_ego, a_ego, v_cruise) self.vision.update(sm, long_enabled, long_override, v_ego, a_ego, v_cruise) 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 a9e5cdd992..720a3195e2 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 @@ -7,7 +7,8 @@ See the LICENSE.md file in the root directory for more details. import json import math import platform - +from types import SimpleNamespace +from unittest.mock import MagicMock from openpilot.cereal import custom from openpilot.common.params import Params @@ -26,12 +27,12 @@ class TestSmartCruiseControlMap(OpenpilotTestCase): self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params self.reset_params() self.scc_m = SmartCruiseControlMap() + self.sm = MagicMock() + self.sm.__getitem__.return_value = SimpleNamespace(latitude=0.0, longitude=0.0) def reset_params(self): self.params.put_bool("SmartCruiseControlMap", True, block=True) - # TODO-SP: mock data from gpsLocation - self.params.put("LastGPSPosition", "{}", block=True) self.params.put("MapTargetVelocities", "{}", block=True) def test_initial_state(self): @@ -45,18 +46,18 @@ class TestSmartCruiseControlMap(OpenpilotTestCase): self.scc_m.enabled = self.params.get_bool("SmartCruiseControlMap") for _ in range(int(10. / DT_MDL)): - self.scc_m.update(True, False, 0., 0., 0.) + self.scc_m.update(self.sm, True, False, 0., 0., 0.) assert self.scc_m.state == VisionState.disabled assert not self.scc_m.is_active def test_disabled(self): for _ in range(int(10. / DT_MDL)): - self.scc_m.update(False, False, 0., 0., 0.) + self.scc_m.update(self.sm, False, False, 0., 0., 0.) assert self.scc_m.state == VisionState.disabled def test_transition_disabled_to_enabled(self): for _ in range(int(10. / DT_MDL)): - self.scc_m.update(True, False, 0., 0., 0.) + self.scc_m.update(self.sm, True, False, 0., 0., 0.) assert self.scc_m.state == VisionState.enabled def test_moderate_curve(self): @@ -64,11 +65,10 @@ class TestSmartCruiseControlMap(OpenpilotTestCase): # 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.scc_m.update(self.sm, True, False, 25.0, 0.0, 30.0) self.assertAlmostEqual(self.scc_m.v_target, 24.0, delta=24.0 * 1e-6) diff --git a/openpilot/sunnypilot/selfdrive/selfdrived/events.py b/openpilot/sunnypilot/selfdrive/selfdrived/events.py index b50873dea4..aee2627834 100644 --- a/openpilot/sunnypilot/selfdrive/selfdrived/events.py +++ b/openpilot/sunnypilot/selfdrive/selfdrived/events.py @@ -9,7 +9,7 @@ from openpilot.cereal import log, custom from opendbc.car.structs import car from openpilot.common.constants import CV from openpilot.sunnypilot.selfdrive.selfdrived.events_base import EventsBase, Priority, ET, Alert, \ - NoEntryAlert, ImmediateDisableAlert, EngagementAlert, NormalPermanentAlert, AlertCallbackType, wrong_car_mode_alert + NoEntryAlert, ImmediateDisableAlert, EngagementAlert, AlertCallbackType, wrong_car_mode_alert from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import PCM_LONG_REQUIRED_MAX_SET_SPEED, CONFIRM_SPEED_THRESHOLD from openpilot.common.hardware import HARDWARE @@ -181,10 +181,6 @@ EVENTS_SP: dict[int, dict[str, Alert | AlertCallbackType]] = { ET.NO_ENTRY: NoEntryAlert("Controls Mismatch: Lateral"), }, - EventNameSP.experimentalModeSwitched: { - ET.WARNING: NormalPermanentAlert("Experimental Mode Switched", duration=1.5) - }, - EventNameSP.wrongCarModeAlertOnly: { ET.WARNING: wrong_car_mode_alert, }, diff --git a/openpilot/sunnypilot/sunnylink/capabilities.py b/openpilot/sunnypilot/sunnylink/capabilities.py index 1f12b5b001..8d303f0ae9 100644 --- a/openpilot/sunnypilot/sunnylink/capabilities.py +++ b/openpilot/sunnypilot/sunnylink/capabilities.py @@ -10,6 +10,7 @@ from openpilot.cereal import custom, messaging from opendbc.car.structs import car from opendbc.car.hyundai.values import CAR as HYUNDAI_CAR, UNSUPPORTED_LONGITUDINAL_CAR from opendbc.car.subaru.values import CAR as SUBARU_CAR, SubaruFlags +from opendbc.sunnypilot.car.interfaces import has_bsm from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog @@ -32,7 +33,7 @@ CAPABILITY_FIELDS = ( "pcm_cruise", "alpha_long_available", "steer_control_type", - "enable_bsm", + "has_bsm", "is_release", "is_sp_release", "is_development", @@ -54,7 +55,7 @@ CAPABILITY_LABELS: dict[str, str] = { "pcm_cruise": "PCM cruise", "alpha_long_available": "Alpha Longitudinal available", "steer_control_type": "Steer control type", - "enable_bsm": "BSM available", + "has_bsm": "BSM available", "is_release": "Release branch", "is_sp_release": "SP release branch", "is_development": "Development branch", @@ -159,7 +160,6 @@ def generate_capabilities(params: Params | None = None) -> dict: if not caps["brand"] and CP.brand: caps["brand"] = str(CP.brand) caps["pcm_cruise"] = bool(CP.pcmCruise) - caps["enable_bsm"] = bool(CP.enableBsm) # Generic SnG fallback. Brand-specific opaque flags below override. caps["has_stop_and_go"] = bool(CP.openpilotLongitudinalControl) except Exception: @@ -167,6 +167,7 @@ def generate_capabilities(params: Params | None = None) -> dict: cloudlog.exception("capabilities: failed to deserialize CarParamsPersistent") # CarParamsSP-derived capabilities + CP_SP = None CP_SP_bytes = params.get("CarParamsSPPersistent") if CP_SP_bytes is not None: try: @@ -177,6 +178,9 @@ def generate_capabilities(params: Params | None = None) -> dict: except Exception: cloudlog.exception("capabilities: failed to deserialize CarParamsSPPersistent") + if CP is not None: + caps["has_bsm"] = has_bsm(CP, CP_SP) + _resolve_brand_capabilities(caps, bundle_platform, CP) return caps diff --git a/openpilot/sunnypilot/sunnylink/docs/README.md b/openpilot/sunnypilot/sunnylink/docs/README.md index 62d46677e6..cbc1eafbf4 100644 --- a/openpilot/sunnypilot/sunnylink/docs/README.md +++ b/openpilot/sunnypilot/sunnylink/docs/README.md @@ -156,7 +156,7 @@ The tables below describe the **compiled** `settings_ui.json` schema — what th **Enablement rules**: Grayed out (disabled) when rules fail. Frontend shows a contextual badge explaining why. -**Capability fields** (referenced in rules): `has_longitudinal_control`, `has_icbm`, `icbm_available`, `torque_allowed`, `brand`, `pcm_cruise`, `alpha_long_available`, `steer_control_type`, `enable_bsm`, `is_release`, `is_sp_release`, `is_development`, `tesla_has_vehicle_bus`, `has_stop_and_go`, `stock_longitudinal` +**Capability fields** (referenced in rules): `has_longitudinal_control`, `has_icbm`, `icbm_available`, `torque_allowed`, `brand`, `pcm_cruise`, `alpha_long_available`, `steer_control_type`, `has_bsm`, `is_release`, `is_sp_release`, `is_development`, `tesla_has_vehicle_bus`, `has_stop_and_go`, `stock_longitudinal` --- @@ -367,8 +367,8 @@ enablement: enablement: - type: any conditions: - - {type: param, key: ExperimentalMode, equals: true} - - {type: param, key: DynamicExperimentalControl, equals: true} + - {type: param, key: SmartCruiseControlVision, equals: true} + - {type: param, key: SmartCruiseControlMap, equals: true} ``` **Mixed: capability AND param** (only on longitudinal cars when ShowAdvancedControls is on): @@ -581,5 +581,4 @@ Settings layouts should not duplicate these params.remove() calls. Instead, rely Example constraints in `_enforce_constraints()`: - Angle steering cars: remove `EnforceTorqueControl` and `NeuralNetworkLateralControl` - No CarParams: remove all car-dependent params -- No longitudinal: remove `ExperimentalMode` - No ICBM: remove `IntelligentCruiseButtonManagement` diff --git a/openpilot/sunnypilot/sunnylink/settings_ui.json b/openpilot/sunnypilot/sunnylink/settings_ui.json index 63bf342fcb..c9d4c826f4 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui.json +++ b/openpilot/sunnypilot/sunnylink/settings_ui.json @@ -559,7 +559,7 @@ "enablement": [ { "type": "capability", - "field": "enable_bsm", + "field": "has_bsm", "equals": true }, { @@ -587,38 +587,6 @@ "title": "", "description": "", "items": [ - { - "key": "ExperimentalMode", - "widget": "toggle", - "title": "Experimental Mode", - "enablement": [ - { - "type": "capability", - "field": "has_longitudinal_control", - "equals": true - } - ] - }, - { - "key": "DynamicExperimentalControl", - "widget": "toggle", - "title": "Dynamic Experimental Control", - "description": "Let the model decide when to use sunnypilot ACC or sunnypilot End to End Longitudinal.", - "visibility": [ - { - "type": "capability", - "field": "has_longitudinal_control", - "equals": true - } - ], - "enablement": [ - { - "type": "capability", - "field": "has_longitudinal_control", - "equals": true - } - ] - }, { "key": "DisengageOnAccelerator", "widget": "toggle", @@ -1852,6 +1820,27 @@ } ] }, + { + "key": "NavigationEnabled", + "widget": "toggle", + "title": "Navigation (Alpha)", + "description": "Turn-by-turn navigation with a route map. Set a destination from comma connect.", + "visibility": [ + { + "type": "capability", + "field": "device_type", + "equals": "mici" + }, + { + "type": "not", + "condition": { + "type": "capability", + "field": "is_sp_release", + "equals": true + } + } + ] + }, { "key": "ShowDebugInfo", "widget": "toggle", diff --git a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml index 3ef73e0fb0..39d6fbffd4 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml +++ b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml @@ -11,19 +11,6 @@ sections: title: '' description: '' items: - - key: ExperimentalMode - widget: toggle - title: Experimental Mode - enablement: - - $ref: '#/macros/longitudinal' - - key: DynamicExperimentalControl - widget: toggle - title: Dynamic Experimental Control - description: Let the model decide when to use sunnypilot ACC or sunnypilot End to End Longitudinal. - visibility: - - $ref: '#/macros/longitudinal' - enablement: - - $ref: '#/macros/longitudinal' - key: DisengageOnAccelerator widget: toggle title: Disengage Cruise on Accelerator Pedal diff --git a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/developer.yaml b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/developer.yaml index b42720b436..f446981a2b 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/developer.yaml +++ b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/developer.yaml @@ -49,6 +49,13 @@ sections: equals: true enablement: - $ref: '#/macros/not_engaged' + - key: NavigationEnabled + widget: toggle + title: Navigation (Alpha) + description: Turn-by-turn navigation with a route map. Set a destination from comma connect. + visibility: + - {type: capability, field: device_type, equals: mici} + - $ref: '#/macros/release_branches_hide' - key: ShowDebugInfo widget: toggle title: UI Debug Mode diff --git a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/steering.yaml b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/steering.yaml index 73d46681a6..82d124f261 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/steering.yaml +++ b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/steering.yaml @@ -268,7 +268,7 @@ sections: spot. enablement: - type: capability - field: enable_bsm + field: has_bsm equals: true - type: param_compare key: AutoLaneChangeTimer diff --git a/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py b/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py index d9b1267a10..8c0be866ef 100644 --- a/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py +++ b/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py @@ -201,11 +201,6 @@ class TestSpuriousOffroadGatesDropped(OpenpilotTestCase): assert item is not None assert "offroad_only" not in _flatten_rule_types(item.get("enablement")) - def test_dynamic_experimental_has_no_offroad_only(self, schema): - item = _find_item(schema, "DynamicExperimentalControl") - assert item is not None - assert "offroad_only" not in _flatten_rule_types(item.get("enablement")) - class TestNotEngagedReplacement(OpenpilotTestCase): @parameterized.expand([ diff --git a/openpilot/system/athena/athenad.py b/openpilot/system/athena/athenad.py index 4d4735b461..9ae121ec63 100755 --- a/openpilot/system/athena/athenad.py +++ b/openpilot/system/athena/athenad.py @@ -385,19 +385,6 @@ def getVersion() -> dict[str, str]: } -@dispatcher.add_method -def setNavDestination(latitude: int = 0, longitude: int = 0, place_name: str | None = None, place_details: str | None = None) -> dict[str, int]: - destination = { - "latitude": latitude, - "longitude": longitude, - "place_name": place_name, - "place_details": place_details, - } - Params().put("NavDestination", json.dumps(destination)) - - return {"success": 1} - - def scan_dir(path: str, prefix: str, base: str | None = None) -> list[str]: if base is None: base = path @@ -720,6 +707,20 @@ def cancelUpload(upload_id: str | list[str]) -> dict[str, int | str]: cancelled_uploads.update(cancelled_ids) return {"success": 1} +@dispatcher.add_method +def setNavDestination(latitude: float, longitude: float) -> dict[str, int | str]: + if not (-90 <= latitude <= 90 and -180 <= longitude <= 180): + return {"success": 0, "error": "Invalid destination coordinates"} + Params().put("NavDestination", {"latitude": latitude, "longitude": longitude}, block=True) + return {"success": 1} + + +@dispatcher.add_method +def clearNavDestination() -> dict[str, int]: + Params().remove("NavDestination") + return {"success": 1} + + @dispatcher.add_method def setRouteViewed(route: str) -> dict[str, int | str]: # maintain a list of the last 10 routes viewed in connect diff --git a/openpilot/system/athena/tests/test_athenad.py b/openpilot/system/athena/tests/test_athenad.py index f9e4c0d334..1f1a498ed6 100644 --- a/openpilot/system/athena/tests/test_athenad.py +++ b/openpilot/system/athena/tests/test_athenad.py @@ -58,9 +58,7 @@ def host(): class TestAthenadMethods(OpenpilotTestCase): @classmethod def setup_class(cls): - cls.SOCKET_PORT = 45454 athenad.Api = MockApi # ty: ignore[invalid-assignment] # test double - athenad.LOCAL_PORT_WHITELIST = {cls.SOCKET_PORT} def setup_method(self): self.default_params = { @@ -114,6 +112,21 @@ class TestAthenadMethods(OpenpilotTestCase): # *** test cases *** + def test_nav_destination(self): + set_destination = dispatcher["setNavDestination"] + destination = {"latitude": 32.875134, "longitude": -117.209777} + assert set_destination(**destination) == {"success": 1} + assert self.params.get("NavDestination") == destination + + # out of range and non-finite coordinates are rejected without touching the destination + for latitude, longitude in [(91, 0), (0, -181), (float('nan'), 0), (0, float('inf'))]: + assert set_destination(latitude, longitude)["success"] == 0, (latitude, longitude) + assert self.params.get("NavDestination") == destination + + assert dispatcher["clearNavDestination"]() == {"success": 1} + assert dispatcher["clearNavDestination"]() == {"success": 1} + assert self.params.get("NavDestination") is None + def test_echo(self): assert dispatcher["echo"]("bob") == "bob" @@ -434,7 +447,7 @@ class TestAthenadMethods(OpenpilotTestCase): assert athenad.upload_queue.qsize() == 1 assert asdict(athenad.upload_queue.queue[-1]) == asdict(item1) - def test_start_local_proxy(self, mock_create_connection): + def test_start_local_proxy(self, mock_create_connection, mocker): end_event = threading.Event() ws_recv = queue.Queue() @@ -442,11 +455,13 @@ class TestAthenadMethods(OpenpilotTestCase): mock_ws = MockWebsocket(ws_recv, ws_send) mock_create_connection.return_value = mock_ws - echo_socket = EchoSocket(self.SOCKET_PORT) + echo_socket = EchoSocket(0) + port = echo_socket.socket.getsockname()[1] + mocker.patch('openpilot.system.athena.athenad.LOCAL_PORT_WHITELIST', {port}) socket_thread = threading.Thread(target=echo_socket.run) socket_thread.start() - athenad.startLocalProxy(end_event, 'ws://localhost:1234', self.SOCKET_PORT) + athenad.startLocalProxy(end_event, 'ws://localhost:1234', port) ws_recv.put_nowait(b'ping') try: diff --git a/openpilot/system/camerad/test/stress_restart.sh b/openpilot/system/camerad/test/stress_restart.sh index 0445dcba79..5f0f2dd2f9 100755 --- a/openpilot/system/camerad/test/stress_restart.sh +++ b/openpilot/system/camerad/test/stress_restart.sh @@ -4,6 +4,6 @@ while :; do ./camerad & pid="$!" sleep 2 - kill -2 $pid - wait $pid + kill -2 "$pid" + wait "$pid" done diff --git a/openpilot/system/hardware/chestnut/firmware_wrapped.bin b/openpilot/system/hardware/chestnut/firmware_wrapped.bin index 1d738e4a75..ff0381a6eb 100644 Binary files a/openpilot/system/hardware/chestnut/firmware_wrapped.bin 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 index f0d828031d..d2c8e1dfa3 100755 --- a/openpilot/system/hardware/chestnut/flash.py +++ b/openpilot/system/hardware/chestnut/flash.py @@ -63,9 +63,9 @@ 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()) + vid_pid = (Path(d, "idVendor").read_text().strip(), Path(d, "idProduct").read_text().strip()) if vid_pid in VID_PIDS + ROM_VID_PIDS: - found.append((d, vid_pid, open(d + "/product").read().strip())) + found.append((d, vid_pid, Path(d, "product").read_text().strip())) except OSError: pass if len(found) > 1: @@ -101,7 +101,7 @@ def unbind_drivers(path): def open_device(path): - bus, dev = int(open(path + "/busnum").read()), int(open(path + "/devnum").read()) + bus, dev = int(Path(path, "busnum").read_text()), int(Path(path, "devnum").read_text()) return os.open(f"/dev/bus/usb/{bus:03d}/{dev:03d}", os.O_RDWR) diff --git a/openpilot/system/hardware/chestnut/monitoring.py b/openpilot/system/hardware/chestnut/monitoring.py new file mode 100644 index 0000000000..3a8b21f61b --- /dev/null +++ b/openpilot/system/hardware/chestnut/monitoring.py @@ -0,0 +1,53 @@ +import struct + +import usb1 + +import openpilot.cereal.messaging as messaging +from openpilot.cereal.services import SERVICE_LIST +from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, get_usb_state, is_chestnut_usb_id + + +def read_chestnut_state(handle, gpu_state=None): + msg = messaging.new_message('chestnutState') + if gpu_state is not None: + msg.chestnutState = gpu_state + state = msg.chestnutState + try: + raw = handle.controlRead(0xC0, 0xC0, 0, 0, 5, timeout=100) + state.supplyVoltage, state.supplyCurrent, state.supplyFault = struct.unpack(' 0 - if not offroad and self.usb_seen and not firmware_ok: + if not offroad and self.usb_seen and len(devices) != 1: self.usb_failed = True + if offroad and len(devices) == 1: + self.usb_failed = False if not offroad and state is not None: powered = state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE @@ -72,6 +77,7 @@ class ChestnutStatus: self.power_unavailable = False self.power_lost = False self.power_restored = False + self.model_failed = False if state is not None: gpu_limit = GPU_TEMP_LIMIT - (TEMP_HYSTERESIS if self.overheated else 0.) @@ -87,15 +93,15 @@ class ChestnutStatus: chestnut_needs_switch = len(devices) == 1 and not big_model_available and chestnut_target is not None set_alert("Offroad_ChestnutBranch", chestnut_needs_switch, chestnut_target if chestnut_needs_switch else None) set_alert("Offroad_ChestnutNotDetected", missing) - set_alert("Offroad_ChestnutOverheated", self.overheated, f"{state.tempC:.0f} °C" if state is not None else None) + set_alert("Offroad_ChestnutOverheated", self.overheated) set_alert("Offroad_ChestnutUsbSlow", slow_usb, f"{devices[0]['speedMbps']} Mbps" if slow_usb else None) if self.power_lost: - pcie_alert = ("Chestnut power restored. 12V is stable again, cycle ignition." if self.power_restored else - "Chestnut power disconnected. Check 12V connection, then cycle ignition." if self.power_unavailable else - "Chestnut power lost. Possibly caused by an engine-crank voltage drop. Check 12V connection, then cycle ignition.") + pcie_alert = ("Chestnut power restored. Restart the car to retry." if self.power_restored else + "Chestnut power disconnected. Check the 12V connection, then restart the car." if self.power_unavailable else + "Chestnut power lost. Check the 12V connection, then restart the car.") else: - pcie_alert = "Chestnut GPU unavailable. PCIe link is not up. Check the GPU is securely seated." + pcie_alert = "Chestnut GPU unavailable. Check the GPU is securely seated, then restart the car." set_alert("Offroad_ChestnutPcieUnavailable", self.pcie_failed, pcie_alert) - set_alert("Offroad_ChestnutUncompiled", offroad and firmware_ok and not chestnut_compiled()) + set_alert("Offroad_ChestnutModelError", self.model_failed and not (missing or self.pcie_failed)) set_alert("Offroad_ChestnutUpdateFailed", offroad and firmware_failed) self.offroad = offroad diff --git a/openpilot/system/hardware/hardwared.py b/openpilot/system/hardware/hardwared.py index 0f051f6de3..375e206889 100755 --- a/openpilot/system/hardware/hardwared.py +++ b/openpilot/system/hardware/hardwared.py @@ -27,6 +27,7 @@ from openpilot.common.swaglog import cloudlog from openpilot.sunnypilot.system.statsd import statlog from openpilot.system.hardware.power_monitoring import PowerMonitoring from openpilot.system.hardware.fan_controller import FanController +from openpilot.system.hardware.chestnut.monitoring import chestnut_state_thread from openpilot.system.hardware.chestnut.status import ChestnutStatus from openpilot.common.version import terms_version, training_version, get_build_metadata, terms_version_sp @@ -40,9 +41,10 @@ PANDA_STATES_TIMEOUT = round(1000 / SERVICE_LIST['pandaStates'].frequency * 1.5) 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 + # flash before going onroad, modeld ignores chestnut until the product string matches MAX_ATTEMPTS = 3 RETRY_INTERVAL = 20. + STARTUP_TIMEOUT = 30. def __init__(self): self.thread: threading.Thread | None = None @@ -51,6 +53,10 @@ class Chestnut: self.flashed = False self.mismatch = False + @property + def flashing(self) -> bool: + return self.thread is not None and self.thread.is_alive() and time.monotonic() - self.last_attempt < self.STARTUP_TIMEOUT + @property def failed(self) -> bool: return self.mismatch and self.attempts >= self.MAX_ATTEMPTS and self.thread is not None and not self.thread.is_alive() and not self.flashed @@ -72,7 +78,7 @@ class Chestnut: return if self.thread is not None and self.thread.is_alive(): return - if time.monotonic() - self.last_attempt < self.RETRY_INTERVAL: + if self.attempts > 0 and time.monotonic() - self.last_attempt < self.RETRY_INTERVAL: return self.attempts += 1 @@ -194,7 +200,7 @@ def hw_state_thread(end_event, hw_queue): def hardware_thread(end_event, hw_queue) -> None: - system_stats = LinuxSystemStats() + system_stats = LinuxSystemStats() if sys.platform == "linux" else None pm = messaging.PubMaster(['deviceState']) sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", "chestnutState"], poll="pandaStates") @@ -221,7 +227,7 @@ def hardware_thread(end_event, hw_queue) -> None: network_strength=NetworkStrength.unknown, network_stats={'wwanTx': -1, 'wwanRx': -1}, modem_temps=[], - usb_state=[], + usb_state=get_usb_state(), ) all_temp_filter = FirstOrderFilter(0., TEMP_TAU, DT_HW, initialized=False) @@ -287,10 +293,13 @@ def hardware_thread(end_event, hw_queue) -> None: except queue.Empty: pass + memory_usage = system_stats.memory_usage_percent() if system_stats is not None else 0. + cpu_usage = system_stats.cpu_usage_percent() if system_stats is not None else [] + msg.deviceState.freeSpacePercent = get_available_percent(default=100.0) - msg.deviceState.memoryUsagePercent = int(round(system_stats.memory_usage_percent())) + msg.deviceState.memoryUsagePercent = int(round(memory_usage)) msg.deviceState.gpuUsagePercent = int(round(HARDWARE.get_gpu_usage_percent())) - online_cpu_usage = [int(round(n)) for n in system_stats.cpu_usage_percent()] + online_cpu_usage = [int(round(n)) for n in cpu_usage] offline_cpu_usage = [0., ] * (len(msg.deviceState.cpuTempC) - len(online_cpu_usage)) msg.deviceState.cpuUsagePercent = online_cpu_usage + offline_cpu_usage @@ -309,7 +318,7 @@ def hardware_thread(end_event, hw_queue) -> None: chestnut.update(started_ts is None, last_hw_state.usb_state) chestnut_state = sm["chestnutState"] chestnut_valid = sm.alive["chestnutState"] and sm.valid["chestnutState"] - chestnut_status.update(started_ts is None, branch, last_hw_state.usb_state, chestnut.failed, + chestnut_status.update(started_ts is None or not onroad_conditions["ignition"], branch, last_hw_state.usb_state, chestnut.failed, params.get_bool("ChestnutLoading"), params.get("ChestnutActive"), chestnut_state if chestnut_valid else None, set_offroad_alert_if_changed) # this subset is only used for offroad @@ -356,6 +365,8 @@ def hardware_thread(end_event, hw_queue) -> None: # must be at an engageable thermal band to go onroad startup_conditions["device_temp_engageable"] = thermal_status < ThermalStatus.overheated + startup_conditions["not_flashing_chestnut"] = not chestnut.flashing + # ensure device is fully booted startup_conditions["device_booted"] = startup_conditions.get("device_booted", False) or HARDWARE.booted() @@ -527,6 +538,7 @@ def main(): if COMMA_HARDWARE: threads.append(threading.Thread(target=touch_thread, args=(end_event,))) + threads.append(threading.Thread(target=chestnut_state_thread, args=(end_event,))) for t in threads: t.start() diff --git a/openpilot/system/loggerd/logger.cc b/openpilot/system/loggerd/logger.cc index f8f94c4b71..76588a74a5 100644 --- a/openpilot/system/loggerd/logger.cc +++ b/openpilot/system/loggerd/logger.cc @@ -49,7 +49,6 @@ kj::Array logger_build_init_data(bool route_log) { init.setGitCommitDate(params_map["GitCommitDate"]); init.setGitBranch(params_map["GitBranch"]); init.setGitRemote(params_map["GitRemote"]); - init.setPassive(false); init.setDongleId(params_map["DongleId"]); // for prebuilt branches diff --git a/openpilot/system/manager/manager.py b/openpilot/system/manager/manager.py index 7befb7f4ae..6907ff52d0 100755 --- a/openpilot/system/manager/manager.py +++ b/openpilot/system/manager/manager.py @@ -8,7 +8,6 @@ import traceback from openpilot.cereal import log import openpilot.cereal.messaging as messaging -import openpilot.system.sentry as sentry from openpilot.common.utils import atomic_write from openpilot.common.params import Params, ParamKeyFlag from openpilot.common.text_window import TextWindow @@ -95,7 +94,6 @@ def manager_init() -> None: os.environ['CLEAN'] = '1' # init logging - sentry.init(sentry.SentryProject.SELFDRIVE) cloudlog.bind_global(dongle_id=dongle_id, version=build_metadata.openpilot.version, origin=build_metadata.openpilot.git_normalized_origin, @@ -204,7 +202,7 @@ def main() -> None: manager_thread() except Exception: traceback.print_exc() - sentry.capture_exception() + cloudlog.exception("crash") finally: manager_cleanup() diff --git a/openpilot/system/manager/process.py b/openpilot/system/manager/process.py index 43ea3f7306..363f7f38e7 100644 --- a/openpilot/system/manager/process.py +++ b/openpilot/system/manager/process.py @@ -12,7 +12,6 @@ from setproctitle import setproctitle from openpilot.cereal import log from opendbc.car.structs import car import openpilot.cereal.messaging as messaging -import openpilot.system.sentry as sentry from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog @@ -31,7 +30,6 @@ def launcher(proc: str, name: str) -> None: # add daemon name tag to logs cloudlog.bind(daemon=name) - sentry.set_tag("daemon", name) # exec the process mod.main() @@ -40,7 +38,7 @@ def launcher(proc: str, name: str) -> None: except Exception: # can't install the crash handler because sys.excepthook doesn't play nice # with threads, so catch it here. - sentry.capture_exception() + cloudlog.exception("crash") raise diff --git a/openpilot/system/manager/process_config.py b/openpilot/system/manager/process_config.py index f776a85923..8959016844 100644 --- a/openpilot/system/manager/process_config.py +++ b/openpilot/system/manager/process_config.py @@ -62,6 +62,9 @@ def always_run(started: bool, params: Params, CP: car.CarParams) -> bool: def only_onroad(started: bool, params: Params, CP: car.CarParams) -> bool: return started +def navigation(started: bool, params: Params, CP: car.CarParams) -> bool: + return started and params.get_bool("NavigationEnabled") + def only_offroad(started: bool, params: Params, CP: car.CarParams) -> bool: return not started @@ -122,6 +125,8 @@ procs = [ PythonProcess("timed", "openpilot.system.timed", always_run, enabled=not PC), PythonProcess("modeld", "openpilot.selfdrive.modeld.modeld", and_(only_onroad, is_stock_model)), + PythonProcess("navd", "openpilot.selfdrive.navigation.navd", navigation), + PythonProcess("mapsd", "openpilot.selfdrive.navigation.mapsd", navigation), PythonProcess("dmonitoringmodeld", "openpilot.selfdrive.modeld.dmonitoringmodeld", driverview, enabled=(WEBCAM or not PC)), PythonProcess("sensord", "openpilot.system.sensord.sensord", only_onroad, enabled=not PC), @@ -170,6 +175,7 @@ procs += [ # Models PythonProcess("models_manager", "openpilot.sunnypilot.models.manager", only_offroad), NativeProcess("modeld_tinygrad", "openpilot/sunnypilot/modeld_v2", ["./modeld"], and_(only_onroad, is_tinygrad_model)), + PythonProcess("modeld_warmup", "openpilot.sunnypilot.modeld_v2.warmup", only_offroad), # Backup PythonProcess("backup_manager", "openpilot.sunnypilot.sunnylink.backups.manager", and_(only_offroad, sunnylink_ready_shim)), diff --git a/openpilot/system/sentry.py b/openpilot/system/sentry.py deleted file mode 100644 index dc078eb532..0000000000 --- a/openpilot/system/sentry.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Install exception handler for process crash.""" -import os -import traceback -from datetime import datetime -import sentry_sdk -from enum import Enum -from sentry_sdk.integrations.threading import ThreadingIntegration - -from openpilot.common.params import Params -from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID -from openpilot.common.hardware import HARDWARE -from openpilot.common.hardware.hw import Paths -from openpilot.common.swaglog import cloudlog -from openpilot.common.version import get_build_metadata, get_version - -from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID - -CRASHES_DIR = Paths.crash_log_root() - - -class SentryProject(Enum): - # python project - SELFDRIVE = "https://186a6736b7927e5ae9b92c869ba81b6b@o1138119.ingest.us.sentry.io/4508660076052480" - # native project - SELFDRIVE_NATIVE = SELFDRIVE - - -def report_tombstone(fn: str, message: str, contents: str) -> None: - cloudlog.error({'tombstone': message}) - - with sentry_sdk.configure_scope() as scope: - set_user() - scope.set_extra("tombstone_fn", fn) - scope.set_extra("tombstone", contents) - sentry_sdk.capture_message(message=message) - sentry_sdk.flush() - - -def capture_exception(*args, **kwargs) -> None: - cloudlog.error("crash", exc_info=kwargs.get('exc_info', 1)) - - try: - save_exception(traceback.format_exc()) - - set_user() - sentry_sdk.capture_exception(*args, **kwargs) - sentry_sdk.flush() # https://github.com/getsentry/sentry-python/issues/291 - except Exception: - cloudlog.exception("sentry exception") - - -def save_exception(content: str) -> None: - try: - if not os.path.exists(CRASHES_DIR): - os.makedirs(CRASHES_DIR) - - files = [ - os.path.join(CRASHES_DIR, datetime.now().strftime("%Y-%m-%d--%H-%M-%S.log")), - os.path.join(CRASHES_DIR, "error.log") - ] - - for fn in files: - with open(fn, 'w') as f: - if fn == "error.log": - lines = content.splitlines()[-3:] - f.write("\n".join(lines)) - else: - f.write(content) - - cloudlog.error(f"logged crash to {files}") - except Exception: - cloudlog.exception("error when attempting to save exception") - - -def capture_fingerprint_mock() -> None: - try: - set_user() - message = "car doesn't match any fingerprints" - sentry_sdk.capture_message(message=message, level="error") - sentry_sdk.flush() - except Exception as e: - cloudlog.exception(f"sentry fingerprint MOCK exception: {e}") - - -def capture_fingerprint(candidate: str, car_name: str) -> None: - try: - set_user() - sentry_sdk.set_tag("carFingerprint", candidate) - sentry_sdk.set_tag("carName", car_name) - - message = f"Fingerprinted {candidate}" - sentry_sdk.capture_message(message=message, level="info") - sentry_sdk.flush() - except Exception as e: - cloudlog.exception(f"sentry fingerprint exception: {e}") - - -def set_tag(key: str, value: str) -> None: - sentry_sdk.set_tag(key, value) - - -def set_user() -> None: - dongle_id, git_username, _ = get_properties() - sentry_sdk.set_user({"id": dongle_id, "name": git_username}) - - -def get_properties() -> tuple[str, str, str]: - params = Params() - hardware_serial: str = params.get("HardwareSerial") or "" - git_username: str = params.get("GithubUsername") or "" - dongle_id: str = params.get("DongleId") or f"{UNREGISTERED_DONGLE_ID}-{hardware_serial}" - sunnylink_dongle_id: str = params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID - - return dongle_id, git_username, sunnylink_dongle_id - - -def init(project: SentryProject) -> bool: - build_metadata = get_build_metadata() - - env = build_metadata.channel_type - dongle_id, git_username, sunnylink_dongle_id = get_properties() - - integrations = [] - if project == SentryProject.SELFDRIVE: - integrations.append(ThreadingIntegration(propagate_hub=True)) - - sentry_sdk.init(project.value, - default_integrations=False, - release=get_version(), - integrations=integrations, - traces_sample_rate=1.0, - max_value_length=8192, - environment=env) - - sentry_sdk.set_user({"id": dongle_id, "name": git_username}) - sentry_sdk.set_tag("dirty", build_metadata.openpilot.is_dirty) - sentry_sdk.set_tag("origin", build_metadata.openpilot.git_origin) - sentry_sdk.set_tag("branch", build_metadata.channel) - sentry_sdk.set_tag("commit", build_metadata.openpilot.git_commit) - sentry_sdk.set_tag("device", HARDWARE.get_device_type()) - sentry_sdk.set_tag("sunnylink_dongle_id", sunnylink_dongle_id) - - return True diff --git a/openpilot/system/tombstoned.py b/openpilot/system/tombstoned.py index 7741a923cd..7661ec7de5 100755 --- a/openpilot/system/tombstoned.py +++ b/openpilot/system/tombstoned.py @@ -9,10 +9,11 @@ import time import glob from typing import NoReturn -import openpilot.system.sentry as sentry +from openpilot.common.hardware import PC from openpilot.common.hardware.hw import Paths from openpilot.common.swaglog import cloudlog from openpilot.common.version import get_build_metadata +from openpilot.system.athena.registration import is_registered_device MAX_SIZE = 1_000_000 * 100 # allow up to 100M MAX_TOMBSTONE_FN_LEN = 62 # 85 - 23 ("/crash/") @@ -65,22 +66,12 @@ def report_tombstone_apport(fn): return message = "" # One line description of the crash - contents = "" # Full file contents without coredump path = "" # File path relative to openpilot directory - proc_maps = False - with open(fn) as f: for line in f: if "CoreDump" in line: break - elif "ProcMaps" in line: - proc_maps = True - elif "ProcStatus" in line: - proc_maps = False - - if not proc_maps: - contents += line if "ExecutablePath" in line: path = line.strip().split(': ')[-1] @@ -112,13 +103,12 @@ def report_tombstone_apport(fn): if not found: crash_function = stacktrace_s[1] - # Remove arguments that can contain pointers to make sentry one-liner unique + # Remove arguments that can contain pointers from the crash summary crash_function = " ".join(x for x in crash_function.split(' ')[1:] if not x.startswith('0x')) crash_function = re.sub(r'\(.*?\)', '', crash_function) - contents = stacktrace + "\n\n" + contents message = message + " - " + crash_function - sentry.report_tombstone(fn, message, contents) + cloudlog.error({'tombstone': message}) # Copy crashlog to upload folder clean_path = path.replace('/', '_') @@ -141,7 +131,9 @@ def report_tombstone_apport(fn): def main() -> NoReturn: - should_report = sentry.init(sentry.SentryProject.SELFDRIVE_NATIVE) + build_metadata = get_build_metadata() + comma_remote = build_metadata.openpilot.comma_remote and "commaai" in build_metadata.openpilot.git_origin + should_report = comma_remote and is_registered_device() and not PC # Clear apport folder on start, otherwise duplicate crashes won't register clear_apport_folder() diff --git a/openpilot/system/ubloxd/pigeond.py b/openpilot/system/ubloxd/pigeond.py index 08ed568d43..45ec286399 100755 --- a/openpilot/system/ubloxd/pigeond.py +++ b/openpilot/system/ubloxd/pigeond.py @@ -67,7 +67,9 @@ def get_assistnow_messages() -> list[bytes]: while len(dat) > 0: assert dat[:2] == b"\xB5\x62" msg_len = 6 + (dat[5] << 8 | dat[4]) + 2 - msgs.append(dat[:msg_len]) + # Skip MGA-INI messages; time from proxyserver is a stale cache + if dat[2:4] != b"\x13\x40": + msgs.append(dat[:msg_len]) dat = dat[msg_len:] return msgs @@ -146,6 +148,31 @@ class TTYPigeon: return True return False +def send_time(pigeon: TTYPigeon) -> None: + if not system_time_valid(): + return + + cloudlog.warning("Sending current time to ublox") + + # UBX-MGA-INI-TIME_UTC + t_now = datetime.now(UTC).replace(tzinfo=None) + msg = add_ubx_checksum(b"\xB5\x62\x13\x40\x18\x00" + struct.pack(" None: # store almanac in flash pigeon.send(b"\xB5\x62\x09\x14\x04\x00\x00\x00\x00\x00\x21\xEC") @@ -167,6 +194,27 @@ def init_baudrate(pigeon: TTYPigeon): pigeon.set_baud(460800) +def send_position(pigeon: TTYPigeon) -> None: + if not (position := Params().get("LastGPSPosition")): + return + + with log.Event.from_bytes(position) as evt: + gps = evt.gpsLocationExternal + if datetime.now(UTC).timestamp() - gps.unixTimestampMillis / 1000 > 7 * 24 * 3600: + return + cloudlog.warning("Sending last position to ublox") + + # UBX-MGA-INI-POS_LLH + msg = add_ubx_checksum(b"\xB5\x62\x13\x40\x14\x00" + struct.pack(" bool: # try initializing a few times for _ in range(10): @@ -185,7 +233,7 @@ def init_pigeon(pigeon: TTYPigeon) -> bool: pigeon.send_with_ack(b"\xB5\x62\x06\x08\x06\x00\x64\x00\x01\x00\x00\x00\x79\x10") # UBX-CFG-NAV5 (0x06 0x24) - pigeon.send_with_ack(b"\xB5\x62\x06\x24\x24\x00\x05\x00\x04\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x5A\x63") + pigeon.send_with_ack(b"\xB5\x62\x06\x24\x24\x00\x07\x00\x04\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x5C\xAB") # UBX-CFG-ODO (0x06 0x1E) pigeon.send_with_ack(b"\xB5\x62\x06\x1E\x14\x00\x00\x00\x00\x00\x01\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x3C\x37") @@ -217,29 +265,6 @@ def init_pigeon(pigeon: TTYPigeon) -> bool: else: cloudlog.error(f"failed to restore almanac backup, status: {restore_status}") - # sending time to ublox - if system_time_valid(): - t_now = datetime.now(UTC).replace(tzinfo=None) - cloudlog.warning("Sending current time to ublox") - - # UBX-MGA-INI-TIME_UTC - msg = add_ubx_checksum(b"\xB5\x62\x13\x40\x18\x00" + struct.pack(" None: init_baudrate(pigeon) init_pigeon(pigeon) + if system_time_valid(): + try: + send_time(pigeon) + send_position(pigeon) + except Exception: + cloudlog.exception("failed to send time and position to ublox") + def run_receiving(duration: int = 0): pm = messaging.PubMaster(['ubloxRaw']) @@ -301,6 +333,7 @@ def run_receiving(duration: int = 0): try: for msg in assist_messages: pigeon.send_with_ack(msg, ack=UBLOX_ASSIST_ACK) + send_time(pigeon) cloudlog.warning("AssistNow messages sent") except Exception: cloudlog.warning("failed to send AssistNow messages") diff --git a/openpilot/system/ubloxd/ubloxd.py b/openpilot/system/ubloxd/ubloxd.py index 3b32f70657..3365e9c693 100755 --- a/openpilot/system/ubloxd/ubloxd.py +++ b/openpilot/system/ubloxd/ubloxd.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import math +import time import capnp import calendar import numpy as np @@ -8,6 +9,7 @@ from dataclasses import dataclass from openpilot.cereal import log from openpilot.cereal import messaging +from openpilot.common.params import Params from openpilot.system.ubloxd.ubx import Ubx from openpilot.system.ubloxd.gps import Gps from openpilot.system.ubloxd.glonass import Glonass @@ -510,6 +512,8 @@ def main(): parser = UbloxMsgParser() pm = messaging.PubMaster(['ubloxGnss', 'gpsLocationExternal']) sock = messaging.sub_sock('ubloxRaw', timeout=100, conflate=False) + params = Params() + last_position_save = float('-inf') while True: msg = messaging.recv_one(sock) @@ -529,6 +533,10 @@ def main(): service, dat = res pm.send(service, dat) + if service == 'gpsLocationExternal' and dat.gpsLocationExternal.hasFix and time.monotonic() - last_position_save > 60: + params.put("LastGPSPosition", dat.to_bytes()) + last_position_save = time.monotonic() + if __name__ == '__main__': main() diff --git a/openpilot/system/ui/lib/cellular_manager.py b/openpilot/system/ui/lib/cellular_manager.py new file mode 100644 index 0000000000..7cd19c4cb3 --- /dev/null +++ b/openpilot/system/ui/lib/cellular_manager.py @@ -0,0 +1,157 @@ +import time +import threading +from collections.abc import Callable +from dataclasses import replace + +from openpilot.common.hardware import HARDWARE +from openpilot.common.swaglog import cloudlog +from openpilot.common.esim.base import LPABase, Profile +from openpilot.common.esim.esim import execute_and_process_notifications + + +class CellularManager: + PROFILE_POLL_INTERVAL_S = 5.0 + + def __init__(self): + self._lpa: LPABase | None = None + self._profiles: list[Profile] = [] + self._busy: bool = False + # re-probed every poll; SIM may be swapped at runtime on tray-accessible devices + self._is_euicc: bool | None = None + self._modem_state: dict = {} + + self._lock = threading.Lock() + self._callback_lock = threading.Lock() + self._callback_queue: list[Callable] = [] + + self.on_profiles_updated: Callable[[], None] | None = None + self.on_operation_error: Callable[[str], None] | None = None + + self._last_profile_poll: float = 0.0 + self._polling: bool = False + + @property + def modem_state(self) -> dict: + return self._modem_state + + def process_callbacks(self): + with self._callback_lock: + to_run, self._callback_queue = self._callback_queue, [] + for cb in to_run: + cb() + + if not self._busy and not self._polling and time.monotonic() - self._last_profile_poll >= self.PROFILE_POLL_INTERVAL_S: + self._last_profile_poll = time.monotonic() + self._modem_state = HARDWARE.get_modem_state() + self._poll_profiles() + + @property + def profiles(self) -> list[Profile]: + return self._profiles + + @property + def active_profile(self) -> Profile | None: + return next((p for p in self._profiles if p.enabled), None) + + @property + def busy(self) -> bool: + return self._busy + + @property + def is_euicc(self) -> bool | None: + return self._is_euicc + + def _ensure_lpa(self) -> LPABase: + if self._lpa is None: + self._lpa = HARDWARE.get_sim_lpa() + return self._lpa + + def _enqueue(self, cb: Callable): + with self._callback_lock: + self._callback_queue.append(cb) + + def _stop_polling(self): + self._polling = False + + def _set_profiles(self, profiles: list[Profile]): + self._profiles = profiles + if self.on_profiles_updated: + self.on_profiles_updated() + + def _finish(self, profiles: list[Profile] | None = None, error: str | None = None): + self._busy = False + # defer the next poll a full interval; the eUICC can briefly report stale state after an operation + self._last_profile_poll = time.monotonic() + if profiles is not None: + self._set_profiles(profiles) + if error is not None: + self.refresh_profiles() + if self.on_operation_error: + self.on_operation_error(error) + + def _run_operation(self, fn: Callable[[LPABase], None], error_msg: str, relist: bool = True): + self._busy = True + + def worker(): + try: + with self._lock: + lpa = self._ensure_lpa() + fn(lpa) + profiles = lpa.list_profiles() if relist else None + self._enqueue(lambda: self._finish(profiles=profiles)) + except Exception as e: + cloudlog.exception(error_msg) + err = str(e) + self._enqueue(lambda: self._finish(error=err)) + + threading.Thread(target=worker, daemon=True).start() + + def refresh_profiles(self): + # next process_callbacks tick polls, respecting busy/polling guards + self._last_profile_poll = 0.0 + + def _poll_profiles(self): + self._polling = True + def worker(): + try: + with self._lock: + lpa = self._ensure_lpa() + is_euicc = lpa.is_euicc() + profiles = lpa.list_profiles() if is_euicc else [] + self._enqueue(lambda: self._finish_poll(is_euicc, profiles)) + except Exception: + cloudlog.exception("Failed to poll eSIM profiles") + self._enqueue(self._stop_polling) + + threading.Thread(target=worker, daemon=True).start() + + def _finish_poll(self, is_euicc: bool, profiles: list[Profile]): + self._polling = False + if self._busy: + return + if not is_euicc and self._is_euicc: + # is_euicc() is False on any AT error (e.g. SIM busy during a profile refresh); confirm on the next poll + self._is_euicc = None + return + self._is_euicc = is_euicc + self._set_profiles(profiles) + + def switch_profile(self, iccid: str): + def switch(lpa: LPABase): + execute_and_process_notifications(lpa, lambda: lpa.switch_profile(iccid)) + + # optimistic: list_profiles() can briefly return stale enabled state after a switch + self._set_profiles([replace(p, enabled=(p.iccid == iccid)) for p in self._profiles]) + self._run_operation(switch, "Failed to switch eSIM profile", relist=False) + + def delete_profile(self, iccid: str): + def delete(lpa: LPABase): + execute_and_process_notifications(lpa, lambda: lpa.delete_profile(iccid)) + + self._run_operation(delete, "Failed to delete eSIM profile") + + def download_profile(self, qr: str, nickname: str | None = None): + self._run_operation(lambda lpa: lpa.download_profile(qr, nickname), "Failed to download eSIM profile") + + def nickname_profile(self, iccid: str, nickname: str): + self._run_operation(lambda lpa: lpa.nickname_profile(iccid, nickname), "Failed to update eSIM profile nickname") diff --git a/openpilot/system/ui/lib/scroll_panel2.py b/openpilot/system/ui/lib/scroll_panel2.py index 19faaa542d..b53e663794 100644 --- a/openpilot/system/ui/lib/scroll_panel2.py +++ b/openpilot/system/ui/lib/scroll_panel2.py @@ -263,6 +263,10 @@ class GuiScrollPanel2: def state(self) -> ScrollState: return self._state + @property + def velocity(self) -> float: + return self._velocity + def is_touch_valid(self) -> bool: # MIN_VELOCITY_FOR_CLICKING is checked in auto-scroll state return bool(self._state != ScrollState.MANUAL_SCROLL) diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 5e774a84d7..bb29b47ad1 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -203,7 +203,8 @@ class WifiManager: self._scan_lock = threading.Lock() self._scan_thread = threading.Thread(target=self._network_scanner, daemon=True) self._state_thread = threading.Thread(target=self._monitor_state, daemon=True) - self._initialize() + if not self._exit: + self._initialize() atexit.register(self.stop) def _initialize(self): diff --git a/openpilot/system/ui/sunnypilot/widgets/download_status.py b/openpilot/system/ui/sunnypilot/widgets/download_status.py index c85814d0eb..e0f45186bc 100644 --- a/openpilot/system/ui/sunnypilot/widgets/download_status.py +++ b/openpilot/system/ui/sunnypilot/widgets/download_status.py @@ -57,7 +57,7 @@ class DownloadStatusAction(ItemAction): 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 + # raw progress arrives in steps, one per progress report from the manager 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 diff --git a/openpilot/system/ui/widgets/__init__.py b/openpilot/system/ui/widgets/__init__.py index 4e13920d60..e2e44c2543 100644 --- a/openpilot/system/ui/widgets/__init__.py +++ b/openpilot/system/ui/widgets/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +import time import pyray as rl from enum import IntEnum from typing import Protocol, TypeVar @@ -51,6 +52,8 @@ class Widget(abc.ABC): self._click_delay: float | None = None # seconds to hold is_pressed after release self._click_release_time: float | None = None self._click_callback: Callable[[], None] | None = None + self._long_press_callback: Callable[[], None] | None = None + self._press_started: list[float | None] = [None] * MAX_TOUCH_SLOTS self._multi_touch = False self.__was_awake = True @@ -92,6 +95,9 @@ class Widget(abc.ABC): """Set a callback to be called when the widget is clicked.""" self._click_callback = click_callback + def set_long_press_callback(self, callback: Callable[[], None]) -> None: + self._long_press_callback = callback + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: """Set a callback to determine if the widget can be clicked.""" self._touch_valid_callback = touch_callback @@ -136,6 +142,7 @@ class Widget(abc.ABC): self._process_mouse_events() else: # TODO: ideally we emit release events when going disabled + self._press_started = [None] * MAX_TOUCH_SLOTS self.__is_pressed = [False] * MAX_TOUCH_SLOTS self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS @@ -160,6 +167,7 @@ class Widget(abc.ABC): # Allows touch to leave the rect and come back in focus if mouse did not release if mouse_event.left_pressed and touch_valid: if mouse_in_rect: + self._press_started[mouse_event.slot] = mouse_event.t self._handle_mouse_press(mouse_event.pos) self.__is_pressed[mouse_event.slot] = True self.__tracking_is_pressed[mouse_event.slot] = True @@ -185,9 +193,20 @@ class Widget(abc.ABC): # Mouse/touch left our rect but may come back into focus later elif not mouse_in_rect: + self._press_started[mouse_event.slot] = None self.__is_pressed[mouse_event.slot] = False self._handle_mouse_event(mouse_event) + if self._long_press_callback is not None and touch_valid: + for slot, started in enumerate(self._press_started): + if started is not None and self.__is_pressed[slot] and time.monotonic() - started >= 0.45: + # Clear tracking before opening help so release cannot activate a toggle or action. + self._press_started[slot] = None + self.__is_pressed[slot] = False + self.__tracking_is_pressed[slot] = False + self._long_press_callback() + break + def _layout(self) -> None: """Optionally lay out child widgets separately. This is called before rendering.""" diff --git a/openpilot/system/ui/widgets/button.py b/openpilot/system/ui/widgets/button.py index c031b2f141..cca48cb190 100644 --- a/openpilot/system/ui/widgets/button.py +++ b/openpilot/system/ui/widgets/button.py @@ -21,6 +21,7 @@ class ButtonStyle(IntEnum): NO_EFFECT = 6 KEYBOARD = 7 FORGET_WIFI = 8 + PILL = 11 # For pill buttons in list items, for example the multiple button selector ICON_PADDING = 15 @@ -39,10 +40,12 @@ BUTTON_TEXT_COLOR = { ButtonStyle.NO_EFFECT: rl.Color(228, 228, 228, 255), ButtonStyle.KEYBOARD: rl.Color(221, 221, 221, 255), ButtonStyle.FORGET_WIFI: rl.Color(51, 51, 51, 255), + ButtonStyle.PILL: rl.Color(228, 228, 228, 255), } BUTTON_DISABLED_TEXT_COLORS = { ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.WHITE, + ButtonStyle.PILL: rl.Color(150, 150, 150, 255), } BUTTON_BACKGROUND_COLORS = { @@ -57,6 +60,7 @@ BUTTON_BACKGROUND_COLORS = { ButtonStyle.NO_EFFECT: rl.Color(51, 51, 51, 255), ButtonStyle.KEYBOARD: rl.Color(68, 68, 68, 255), ButtonStyle.FORGET_WIFI: rl.Color(189, 189, 189, 255), + ButtonStyle.PILL: rl.Color(57, 57, 57, 255), } BUTTON_PRESSED_BACKGROUND_COLORS = { @@ -71,10 +75,12 @@ BUTTON_PRESSED_BACKGROUND_COLORS = { ButtonStyle.NO_EFFECT: rl.Color(51, 51, 51, 255), ButtonStyle.KEYBOARD: rl.Color(51, 51, 51, 255), ButtonStyle.FORGET_WIFI: rl.Color(130, 130, 130, 255), + ButtonStyle.PILL: rl.Color(74, 74, 74, 255), } BUTTON_DISABLED_BACKGROUND_COLORS = { ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK, + ButtonStyle.PILL: rl.Color(57, 57, 57, 150), } diff --git a/openpilot/system/ui/widgets/list_view.py b/openpilot/system/ui/widgets/list_view.py index b376928c58..e993009fb5 100644 --- a/openpilot/system/ui/widgets/list_view.py +++ b/openpilot/system/ui/widgets/list_view.py @@ -7,7 +7,8 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, T from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.button import Button, ButtonStyle +from openpilot.system.ui.widgets.button import (Button, ButtonStyle, BUTTON_TEXT_COLOR, BUTTON_DISABLED_TEXT_COLORS, BUTTON_BACKGROUND_COLORS, + BUTTON_PRESSED_BACKGROUND_COLORS, BUTTON_DISABLED_BACKGROUND_COLORS) from openpilot.system.ui.widgets.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType @@ -242,12 +243,12 @@ class MultipleButtonAction(ItemAction): if is_selected: bg_color = rl.Color(51, 171, 76, 255) # Green elif is_pressed: - bg_color = rl.Color(74, 74, 74, 255) # Dark gray + bg_color = BUTTON_PRESSED_BACKGROUND_COLORS[ButtonStyle.PILL] else: - bg_color = rl.Color(57, 57, 57, 255) # Gray + bg_color = BUTTON_BACKGROUND_COLORS[ButtonStyle.PILL] if not self.enabled: - bg_color = rl.Color(bg_color.r, bg_color.g, bg_color.b, 150) # Dim + bg_color = rl.Color(bg_color.r, bg_color.g, bg_color.b, BUTTON_DISABLED_BACKGROUND_COLORS[ButtonStyle.PILL].a) # Draw button rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color) @@ -257,7 +258,7 @@ class MultipleButtonAction(ItemAction): text_size = measure_text_cached(self._font, text, 40) text_x = button_x + (self.button_width - text_size.x) / 2 text_y = button_y + (BUTTON_HEIGHT - text_size.y) / 2 - text_color = rl.Color(228, 228, 228, 255) if self.enabled else rl.Color(150, 150, 150, 255) + text_color = BUTTON_TEXT_COLOR[ButtonStyle.PILL] if self.enabled else BUTTON_DISABLED_TEXT_COLORS[ButtonStyle.PILL] rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, text_color) def _handle_mouse_release(self, mouse_pos: MousePos): diff --git a/openpilot/system/ui/widgets/mici_keyboard.py b/openpilot/system/ui/widgets/mici_keyboard.py index bc3041aa3c..4ebb940eed 100644 --- a/openpilot/system/ui/widgets/mici_keyboard.py +++ b/openpilot/system/ui/widgets/mici_keyboard.py @@ -353,8 +353,12 @@ class MiciKeyboard(Widget): # draw black circle behind selected key circle_alpha = int(self._selected_key_filter.x * 225) - rl.draw_circle_gradient(rl.Vector2(key_x + key.rect.width / 2, key_y + key.rect.height / 2), - SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, circle_alpha), rl.BLANK) + if rl.RAYLIB_VERSION_MAJOR >= 6: + rl.draw_circle_gradient(rl.Vector2(key_x + key.rect.width / 2, key_y + key.rect.height / 2), + SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, circle_alpha), rl.BLANK) + else: # AGNOS updater compatibility for raylib 5 + rl.draw_circle_gradient(int(key_x + key.rect.width / 2), int(key_y + key.rect.height / 2), + SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, circle_alpha), rl.BLANK) else: # move other keys away from selected key a bit dx = key.original_position.x - self._closest_key[0].original_position.x diff --git a/openpilot/system/ui/widgets/scroller.py b/openpilot/system/ui/widgets/scroller.py index 55195b30fb..ebde01c428 100644 --- a/openpilot/system/ui/widgets/scroller.py +++ b/openpilot/system/ui/widgets/scroller.py @@ -97,6 +97,7 @@ class _Scroller(Widget): self._scroll_enabled: bool | Callable[[], bool] = True self._show_scroll_indicator = scroll_indicator and self._horizontal + self.scroll_indicator_start_after: Widget | None = None self._scroll_indicator = ScrollIndicator() self._edge_shadows = edge_shadows and self._horizontal @@ -146,9 +147,9 @@ class _Scroller(Widget): # preserve original touch valid callback original_touch_valid_callback = item._touch_valid_callback - item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled and not self._scrolling_to[2] - and not self.moving_items and (original_touch_valid_callback() if - original_touch_valid_callback else True)) + item.set_touch_valid_callback(lambda: self._touch_valid() and self.scroll_panel.is_touch_valid() + and self.enabled and not self._scrolling_to[2] and not self.moving_items + and (original_touch_valid_callback() if original_touch_valid_callback else True)) def add_widgets(self, items: Sequence[Widget]) -> None: for item in items: @@ -364,7 +365,14 @@ class _Scroller(Widget): # Draw scroll indicator on top of edge shadows if self._show_scroll_indicator and len(self._visible_items) > 0: - self._scroll_indicator.update(self._scroll_offset, self._content_size, self._rect) + viewport = self._rect + if self.scroll_indicator_start_after is not None: + offset = min(self._rect.width, max(0.0, self.scroll_indicator_start_after.rect.x + + self.scroll_indicator_start_after.rect.width + + self._spacing - self._rect.x)) + viewport = rl.Rectangle(self._rect.x + offset, self._rect.y, self._rect.width - offset, self._rect.height) + + self._scroll_indicator.update(self._scroll_offset, self._content_size, viewport) self._scroll_indicator.render() def show_event(self): @@ -397,6 +405,10 @@ class Scroller(Widget): # pass down enabled to child widget for nav stack self._scroller.set_enabled(lambda: self.enabled) + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + super().set_touch_valid_callback(touch_callback) + self._scroller.set_touch_valid_callback(touch_callback) + def _render(self, _, /): self._scroller.render(self._rect) diff --git a/openpilot/system/updated/updated.py b/openpilot/system/updated/updated.py index 62fa10ec9e..73f2ee5e3c 100755 --- a/openpilot/system/updated/updated.py +++ b/openpilot/system/updated/updated.py @@ -209,6 +209,8 @@ def handle_agnos_update() -> None: cloudlog.info(f"Beginning background installation for AGNOS {updated_version}") manifest_path = os.path.join(OVERLAY_MERGED, "openpilot/system/hardware/comma/agnos.json") + if not os.path.isfile(manifest_path): # releases before the openpilot/ move + manifest_path = os.path.join(OVERLAY_MERGED, "system/hardware/tici/agnos.json") target_slot_number = get_target_slot_number() flash_agnos_update(manifest_path, target_slot_number, cloudlog) diff --git a/openpilot/tools/cabana/.gitignore b/openpilot/tools/cabana/.gitignore index 7f9ac0fde0..c59f393869 100644 --- a/openpilot/tools/cabana/.gitignore +++ b/openpilot/tools/cabana/.gitignore @@ -1,11 +1,5 @@ -moc_* -*.moc -*.generated.qrc - -assets.cc bootstrap_icons.cc -_cabana +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 fbdfbb40e5..6fcce59eb0 100644 --- a/openpilot/tools/cabana/README.md +++ b/openpilot/tools/cabana/README.md @@ -6,29 +6,27 @@ Cabana is a tool developed to view raw CAN data. One use for this is creating an ```bash $ ./cabana -h -Usage: ./cabana [options] route +Usage: ./cabana [options] [route] + + route the drive to replay. find your drives at connect.comma.ai Options: - -h, --help Displays help on commandline options. - --help-all Displays help including Qt specific options. - --demo use a demo route instead of providing your own - --auto Auto load the route from the best available source (no video): - internal, openpilotci, comma_api, car_segments, testing_closet - --qcam load qcamera - --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 - --socketcan read can messages from given SocketCAN device - --zmq read can messages from zmq at the specified ip-address - messages - --data_dir local directory with routes - --no-vipc do not output video - --dbc dbc file to open - -Arguments: - route the drive to replay. find your drives at - connect.comma.ai + --help show this help + --demo use a demo route instead of providing your own + --auto Auto load the route from the best available source (no video): + internal, openpilotci, comma_api, car_segments, testing_closet + --qcam load qcamera + --wide-road load wide road camera (alias: --ecam) + --cabin load cabin camera (alias: --dcam) + --msgq read can messages from the msgq + --panda read can messages from panda + --panda-serial read can messages from panda with given serial + --socketcan read can messages from given SocketCAN device + --zmq read can messages from zmq at the specified ip-address + --data_dir local directory with routes + --no-vipc do not output video + --no-cache turn off the local route file cache + --dbc dbc file to open ``` ## Examples diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 63b725a759..b1ef7602a5 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -1,81 +1,22 @@ -import subprocess import os -import shutil +from pathlib import Path import bootstrap_icons +import imgui import libusb +from openpilot.common.basedir import BASEDIR +from opendbc.dbc.generator.generator import generate_all Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib', 'ffmpeg_libs') -# Detect Qt - skip build if not available -if arch == "Darwin": - try: - brew_prefix = subprocess.check_output(['brew', '--prefix'], encoding='utf8').strip() - has_qt = os.path.isdir(os.path.join(brew_prefix, "opt/qt@5")) - except (FileNotFoundError, subprocess.CalledProcessError): - has_qt = False -else: - has_qt = shutil.which('qmake') is not None -if not has_qt: - Return() +opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (env.Dir("../../../opendbc_repo/opendbc/dbc").abspath) -qt_env = env.Clone() -qt_modules = ["Widgets", "Gui", "Core"] - -qt_libs = [] -if arch == "Darwin": - qt_env['QTDIR'] = f"{brew_prefix}/opt/qt@5" - qt_dirs = [ - os.path.join(qt_env['QTDIR'], "include"), - ] - 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] - 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() - qt_install_headers = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_HEADERS'], encoding='utf8').strip() - - qt_env['QTDIR'] = qt_install_prefix - qt_dirs = [ - f"{qt_install_headers}", - ] - - qt_gui_path = os.path.join(qt_install_headers, "QtGui") - qt_gui_dirs = [d for d in os.listdir(qt_gui_path) if os.path.isdir(os.path.join(qt_gui_path, d))] - 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] -qt_env['QT3DIR'] = qt_env['QTDIR'] -qt_env.Tool('qt3') - -qt_env['CPPPATH'] += qt_dirs -qt_flags = [ - "-D_REENTRANT", - "-DQT_NO_DEBUG", - "-DQT_WIDGETS_LIB", - "-DQT_GUI_LIB", - "-DQT_CORE_LIB", - "-DQT_MESSAGELOGCONTEXT", -] -qt_env['CXXFLAGS'] += qt_flags -qt_env['LIBPATH'] += ['#openpilot/selfdrive/ui', ] -qt_env['LIBS'] = qt_libs - -base_frameworks = qt_env['FRAMEWORKS'] -base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] + qt_env["LIBS"] - -if arch == "Darwin": - 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 + ['usb-1.0'] + base_libs -opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../../opendbc_repo/opendbc/dbc").abspath) -cabana_env['CXXFLAGS'] += [opendbc_path] +dbc_dir = Path(env.Dir("#opendbc_repo/opendbc/dbc").abspath) +generated_dbcs = env.Command( + [str(dbc_dir / (name + '.dbc')) for name in sorted(generate_all())], + sorted(str(path) for path in (dbc_dir / 'generator').rglob('*') if path.suffix in ('.py', '.dbc')), + 'python3 -m opendbc.dbc.generator.generator', +) # embed the bootstrap icons SVG into the binary def build_bootstrap_icons_src(target, source, env): @@ -91,43 +32,57 @@ def build_bootstrap_icons_src(target, source, env): 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) +bootstrap_icons_src = env.Command('assets/bootstrap_icons.cc', str(bootstrap_icons.SVG_PATH), build_bootstrap_icons_src) -# build assets -assets = "assets/assets.cc" -cabana_env.Command(assets, "assets/assets.qrc", f"rcc $SOURCES -o $TARGET") -cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"])) - -cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', - 'routesdialog.cc', 'routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', - 'utils/export.cc', 'utils/util.cc', 'utils/qtutil.cc', 'utils/strings.cc', 'utils/elidedlabel.cc', - 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', - 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'settingsdialog.cc', 'panda.cc', - 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] +# sources shared by the imgui frontend and the core test +core_srcs = ['streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', + 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'utils/export.cc', 'utils/util.cc', 'utils/strings.cc', + 'commands.cc', 'settings.cc', 'routes.cc', 'panda.cc'] if arch != "Darwin": - cabana_srcs += ['streams/socketcanstream.cc'] -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) + core_srcs += ['streams/socketcanstream.cc'] + +# imgui frontend (tools/cabana/ui), no Qt +ui_env = env.Clone() +ui_env['CPPPATH'] += [imgui.INCLUDE_DIR, libusb.INCLUDE_DIR] +ui_env['LIBPATH'] += [imgui.MESA_DIR, libusb.LIB_DIR] +ui_env['CXXFLAGS'] += [ + opendbc_path, + "-DGLFW_INCLUDE_NONE", + '-DCABANA_FONTS_DIR=\'"%s"\'' % os.path.join(os.path.realpath(BASEDIR), "openpilot", "selfdrive", "assets", "fonts"), + '-DBOOTSTRAP_ICONS_TTF=\'"%s"\'' % bootstrap_icons.TTF_PATH, +] +ui_objs = [ui_env.Object('ui/obj/' + src.replace('/', '_')[:-3], src) for src in core_srcs] +ui_objs += [ui_env.Object('ui/obj/bootstrap_icons', bootstrap_icons_src)] +ui_objs += ui_env.Glob('ui/*.cc') + ui_env.Glob('ui/widgets/*.cc') + ui_env.Glob('ui/dialogs/*.cc') + ui_env.Glob('ui/chart/*.cc') + ui_env.Glob('ui/tools/*.cc') +ui_libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a")] + \ + ffmpeg_libs + ['zstd', 'm', 'pthread', 'usb-1.0'] +if arch == "Darwin": + ui_env['FRAMEWORKS'] = ['OpenGL', 'Cocoa', 'IOKit', 'CoreFoundation', 'CoreVideo', 'CoreMedia', 'Security', 'VideoToolbox'] +else: + ui_libs += ['GL', 'dl'] +cabana_ui = ui_env.Program('cabana', ui_objs, LIBS=ui_libs) +ui_env.Requires(cabana_ui, generated_dbcs) if GetOption('extras'): - # 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.Object('tests/dbc_core_strings', 'utils/strings.cc'), - dbc_core_test_env.Object('tests/dbc_core_util', 'utils/util.cc'), - dbc_core_test_env.Object('tests/dbc_core_icons', bootstrap_icons_src), - dbc_core_test_env.Object('tests/dbc_core_routes', 'routes.cc'), + cabana_core_test_env = env.Clone() + cabana_core_test_env['CXXFLAGS'] += [opendbc_path] + cabana_core_test_objects = [ + cabana_core_test_env.Object('tests/cabana_core_tests', 'tests/test_cabana.cc'), + cabana_core_test_env.Object('tests/cabana_core_model', 'dbc/dbc.cc'), + cabana_core_test_env.Object('tests/cabana_core_file', 'dbc/dbcfile.cc'), + cabana_core_test_env.Object('tests/cabana_core_manager', 'dbc/dbcmanager.cc'), + cabana_core_test_env.Object('tests/cabana_core_strings', 'utils/strings.cc'), + cabana_core_test_env.Object('tests/cabana_core_util', 'utils/util.cc'), + cabana_core_test_env.Object('tests/cabana_core_icons', bootstrap_icons_src), + cabana_core_test_env.Object('tests/cabana_core_routes', 'routes.cc'), + cabana_core_test_env.Object('tests/cabana_core_qtstate', 'ui/qtstate.cc'), ] - dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects, LIBS=[replay_lib, common]) + cabana_core_test = cabana_core_test_env.Program('tests/test_cabana', cabana_core_test_objects, LIBS=[replay_lib, common]) + cabana_core_test_env.Requires(cabana_core_test, generated_dbcs) output_json_file = 'openpilot/tools/cabana/dbc/car_fingerprint_to_dbc.json' -generate_dbc = cabana_env.Command('#' + output_json_file, - ['dbc/generate_dbc_json.py'], - "python3 openpilot/tools/cabana/dbc/generate_dbc_json.py --out " + output_json_file) -cabana_env.Depends(generate_dbc, ["#openpilot/common", '#opendbc_repo', "#openpilot/cereal", "#msgq_repo"]) +generate_dbc = ui_env.Command('#' + output_json_file, + ['dbc/generate_dbc_json.py'], + "python3 openpilot/tools/cabana/dbc/generate_dbc_json.py --out " + output_json_file) +ui_env.Depends(generate_dbc, ["#openpilot/common", '#opendbc_repo', "#openpilot/cereal", "#msgq_repo"]) +ui_env.Depends(cabana_ui, generate_dbc) diff --git a/openpilot/tools/cabana/assets/assets.qrc b/openpilot/tools/cabana/assets/assets.qrc deleted file mode 100644 index 009d63f008..0000000000 --- a/openpilot/tools/cabana/assets/assets.qrc +++ /dev/null @@ -1,5 +0,0 @@ - - - cabana-icon.png - - diff --git a/openpilot/tools/cabana/assets/cabana-icon.png b/openpilot/tools/cabana/assets/cabana-icon.png deleted file mode 100644 index 2c3cfcd7d1..0000000000 --- a/openpilot/tools/cabana/assets/cabana-icon.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:015cea9d425f747bce856fd2f5a6a57fca322159dde54554de11eda076273595 -size 18008 diff --git a/openpilot/tools/cabana/binaryview.cc b/openpilot/tools/cabana/binaryview.cc deleted file mode 100644 index 20fd2ec1ec..0000000000 --- a/openpilot/tools/cabana/binaryview.cc +++ /dev/null @@ -1,510 +0,0 @@ -#include "tools/cabana/binaryview.h" - -#include - -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/utils/qtutil.h" - -// BinaryView - -const int CELL_HEIGHT = 36; -const int VERTICAL_HEADER_WIDTH = 30; -inline int get_bit_pos(const QModelIndex &index) { return flipBitPos(index.row() * 8 + index.column()); } - -BinaryView::BinaryView(QWidget *parent) : QTableView(parent) { - model = new BinaryViewModel(this); - setModel(model); - delegate = new BinaryItemDelegate(this); - setItemDelegate(delegate); - horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); - horizontalHeader()->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); - verticalHeader()->setSectionsClickable(false); - verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); - verticalHeader()->setDefaultSectionSize(CELL_HEIGHT); - horizontalHeader()->hide(); - setShowGrid(false); - setMouseTracking(true); - setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); - - addShortcuts(); - setWhatsThis(R"( - Binary View
- - Shortcuts
- Delete Signal: -  x , -  Backspace , -  Delete 
- Change endianness:  e 
- Change signedness:  s 
- Open chart: -  c , -  p , -  g  - )"); -} - -void BinaryView::addShortcuts() { - // Delete (x, backspace, delete) - QShortcut *shortcut_delete_x = new QShortcut(QKeySequence(Qt::Key_X), this); - QShortcut *shortcut_delete_backspace = new QShortcut(QKeySequence(Qt::Key_Backspace), this); - QShortcut *shortcut_delete_delete = new QShortcut(QKeySequence(Qt::Key_Delete), this); - QObject::connect(shortcut_delete_delete, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated); - QObject::connect(shortcut_delete_backspace, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated); - QObject::connect(shortcut_delete_x, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, hovered_sig)); - hovered_sig = nullptr; - } - }); - - // Change endianness (e) - QShortcut *shortcut_endian = new QShortcut(QKeySequence(Qt::Key_E), this); - QObject::connect(shortcut_endian, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - cabana::Signal s = *hovered_sig; - s.is_little_endian = !s.is_little_endian; - emit editSignal(hovered_sig, s); - } - }); - - // Change signedness (s) - QShortcut *shortcut_sign = new QShortcut(QKeySequence(Qt::Key_S), this); - QObject::connect(shortcut_sign, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - cabana::Signal s = *hovered_sig; - s.is_signed = !s.is_signed; - emit editSignal(hovered_sig, s); - } - }); - - // Open chart (c, p, g) - QShortcut *shortcut_plot = new QShortcut(QKeySequence(Qt::Key_P), this); - QShortcut *shortcut_plot_g = new QShortcut(QKeySequence(Qt::Key_G), this); - QShortcut *shortcut_plot_c = new QShortcut(QKeySequence(Qt::Key_C), this); - QObject::connect(shortcut_plot_g, &QShortcut::activated, shortcut_plot, &QShortcut::activated); - QObject::connect(shortcut_plot_c, &QShortcut::activated, shortcut_plot, &QShortcut::activated); - QObject::connect(shortcut_plot, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - emit showChart(model->msg_id, hovered_sig, true, false); - } - }); -} - -QSize BinaryView::minimumSizeHint() const { - return {(horizontalHeader()->minimumSectionSize() + 1) * 9 + VERTICAL_HEADER_WIDTH + 2, - CELL_HEIGHT * std::min(model->rowCount(), 10) + 2}; -} - -void BinaryView::highlight(const cabana::Signal *sig) { - if (sig != hovered_sig) { - for (int i = 0; i < model->items.size(); ++i) { - auto &item_sigs = model->items[i].sigs; - auto has = [](const auto &v, auto p) { return std::find(v.begin(), v.end(), p) != v.end(); }; - if ((sig && has(item_sigs, sig)) || (hovered_sig && has(item_sigs, hovered_sig))) { - auto index = model->index(i / model->columnCount(), i % model->columnCount()); - emit model->dataChanged(index, index, {Qt::DisplayRole}); - } - } - - hovered_sig = sig; - emit signalHovered(hovered_sig); - } -} - -void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) { - auto index = indexAt(last_mouse_pos); - if (!anchor_index.isValid() || !index.isValid()) - return; - - QItemSelection selection; - auto [start, size, is_lb] = getSelection(index); - for (int i = 0; i < size; ++i) { - int pos = is_lb ? flipBitPos(start + i) : flipBitPos(start) + i; - selection << QItemSelectionRange{model->index(pos / 8, pos % 8)}; - } - selectionModel()->select(selection, flags); -} - -void BinaryView::mousePressEvent(QMouseEvent *event) { - resize_sig = nullptr; - 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); - for (auto s : item->sigs) { - if (bit_pos == s->lsb || bit_pos == s->msb) { - int idx = flipBitPos(bit_pos == s->lsb ? s->msb : s->lsb); - anchor_index = model->index(idx / 8, idx % 8); - resize_sig = s; - break; - } - } - } - event->accept(); -} - -void BinaryView::highlightPosition(const QPoint &pos) { - 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); - } -} - -void BinaryView::mouseMoveEvent(QMouseEvent *event) { - highlightPosition(last_mouse_pos = event->pos()); - QTableView::mouseMoveEvent(event); -} - -void BinaryView::mouseReleaseEvent(QMouseEvent *event) { - QTableView::mouseReleaseEvent(event); - - auto release_index = indexAt(event->pos()); - if (release_index.isValid() && anchor_index.isValid()) { - if (selectionModel()->hasSelection()) { - 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::instance()->push(new AddSigCommand(model->msg_id, sig)); - } else { - auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer(); - if (item && item->sigs.size() > 0) - emit signalClicked(item->sigs.back()); - } - } - clearSelection(); - anchor_index = QModelIndex(); - resize_sig = nullptr; -} - -void BinaryView::leaveEvent(QEvent *event) { - highlight(nullptr); - QTableView::leaveEvent(event); -} - -void BinaryView::setMessage(const MessageId &message_id) { - model->msg_id = message_id; - verticalScrollBar()->setValue(0); - refresh(); -} - -void BinaryView::refresh() { - clearSelection(); - anchor_index = QModelIndex(); - resize_sig = nullptr; - hovered_sig = nullptr; - model->refresh(); - if (underMouse()) highlightPosition(last_mouse_pos); -} - -std::set BinaryView::getOverlappingSignals() const { - std::set overlapping; - for (const auto &item : model->items) { - if (item.sigs.size() > 1) { - for (auto s : item.sigs) { - if (s->type == cabana::Signal::Type::Normal) overlapping.insert(s); - } - } - } - return overlapping; -} - -std::tuple BinaryView::getSelection(QModelIndex index) { - if (index.column() == 8) { - index = model->index(index.row(), 7); - } - bool is_lb = true; - if (resize_sig) { - is_lb = resize_sig->is_little_endian; - } else if (settings.drag_direction == Settings::DragDirection::MsbFirst) { - is_lb = index < anchor_index; - } else if (settings.drag_direction == Settings::DragDirection::LsbFirst) { - is_lb = !(index < anchor_index); - } else if (settings.drag_direction == Settings::DragDirection::AlwaysLE) { - is_lb = true; - } else if (settings.drag_direction == Settings::DragDirection::AlwaysBE) { - is_lb = false; - } - - int cur_bit_pos = get_bit_pos(index); - int anchor_bit_pos = get_bit_pos(anchor_index); - int start_bit = is_lb ? std::min(cur_bit_pos, anchor_bit_pos) : get_bit_pos(std::min(index, anchor_index)); - int size = is_lb ? std::abs(cur_bit_pos - anchor_bit_pos) + 1 : std::abs(flipBitPos(cur_bit_pos) - flipBitPos(anchor_bit_pos)) + 1; - return {start_bit, size, is_lb}; -} - -// BinaryViewModel - -void BinaryViewModel::refresh() { - beginResetModel(); - bit_flip_tracker = {}; - items.clear(); - if (auto dbc_msg = dbc()->msg(msg_id)) { - row_count = dbc_msg->size; - items.resize(row_count * column_count); - for (auto sig : dbc_msg->getSignals()) { - for (int j = 0; j < sig->size; ++j) { - 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()) { - 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; - if (j == sig->size - 1) sig->is_little_endian ? items[idx].is_msb = true : items[idx].is_lsb = true; - - auto &sigs = items[idx].sigs; - sigs.push_back(sig); - if (sigs.size() > 1) { - std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) { return l->size > r->size; }); - } - } - } - } else { - row_count = can->lastMessage(msg_id).dat.size(); - items.resize(row_count * column_count); - } - endResetModel(); - updateState(); -} - -void BinaryViewModel::updateItem(int row, int col, uint8_t val, const QColor &color) { - auto &item = items[row * column_count + col]; - item.valid = true; - if (item.val != val || item.bg_color != color) { - item.val = val; - item.bg_color = color; - auto idx = index(row, col); - emit dataChanged(idx, idx, {Qt::DisplayRole}); - } -} - -void BinaryViewModel::updateState() { - const auto &last_msg = can->lastMessage(msg_id); - const auto &binary = last_msg.dat; - // Handle size changes in binary data - if (binary.size() > row_count) { - beginInsertRows({}, row_count, binary.size() - 1); - row_count = binary.size(); - items.resize(row_count * column_count); - endInsertRows(); - } - - auto &bit_flips = heatmap_live_mode ? last_msg.bit_flip_counts : getBitFlipChanges(binary.size()); - // Find the maximum bit flip count across the message - uint32_t max_bit_flip_count = 1; // Default to 1 to avoid division by zero - for (const auto &row : bit_flips) { - for (uint32_t count : row) { - max_bit_flip_count = std::max(max_bit_flip_count, count); - } - } - - const double max_alpha = 255.0; - const double min_alpha_with_signal = 25.0; // Base alpha for small flip counts - const double min_alpha_no_signal = 10.0; // Base alpha for small flip counts for no signal bits - const double log_factor = 1.0 + 0.2; // Factor for logarithmic scaling - const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count); - - for (size_t i = 0; i < binary.size(); ++i) { - for (int j = 0; j < 8; ++j) { - auto &item = items[i * column_count + j]; - int bit_val = (binary[i] >> (7 - j)) & 1; - - double alpha = item.sigs.empty() ? 0 : min_alpha_with_signal; - uint32_t flip_count = bit_flips[i][j]; - if (flip_count > 0) { - double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler; - double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal; - alpha = std::clamp(normalized_alpha, min_alpha, max_alpha); - } - - auto color = item.bg_color; - color.setAlpha(alpha); - updateItem(i, j, bit_val, color); - } - updateItem(i, 8, binary[i], toQColor(last_msg.colors[i])); - } -} - -const std::vector> &BinaryViewModel::getBitFlipChanges(size_t msg_size) { - // Return cached results if time range and data are unchanged - auto time_range = can->timeRange(); - if (bit_flip_tracker.time_range == time_range && !bit_flip_tracker.flip_counts.empty()) - return bit_flip_tracker.flip_counts; - - bit_flip_tracker.time_range = time_range; - bit_flip_tracker.flip_counts.assign(msg_size, std::array{}); - - // Iterate over events within the specified time range and calculate bit flips - auto [first, last] = can->eventsInRange(msg_id, time_range); - if (std::distance(first, last) <= 1) return bit_flip_tracker.flip_counts; - - std::vector prev_values((*first)->dat, (*first)->dat + (*first)->size); - for (auto it = std::next(first); it != last; ++it) { - const CanEvent *event = *it; - int size = std::min(msg_size, event->size); - for (int i = 0; i < size; ++i) { - const uint8_t diff = event->dat[i] ^ prev_values[i]; - if (!diff) continue; - - auto &bit_flips = bit_flip_tracker.flip_counts[i]; - for (int bit = 0; bit < 8; ++bit) { - if (diff & (1u << bit)) ++bit_flips[7 - bit]; - } - prev_values[i] = event->dat[i]; - } - } - - return bit_flip_tracker.flip_counts; -} - -QVariant BinaryViewModel::headerData(int section, Qt::Orientation orientation, int role) const { - if (orientation == Qt::Vertical) { - switch (role) { - case Qt::DisplayRole: return section; - case Qt::SizeHintRole: return QSize(VERTICAL_HEADER_WIDTH, 0); - case Qt::TextAlignmentRole: return Qt::AlignCenter; - } - } - return {}; -} - -QVariant BinaryViewModel::data(const QModelIndex &index, int role) const { - auto item = (const BinaryViewModel::Item *)index.internalPointer(); - return role == Qt::ToolTipRole && item && !item->sigs.empty() ? QString::fromStdString(utils::signalToolTip(item->sigs.back())) : QVariant(); -} - -// BinaryItemDelegate - -BinaryItemDelegate::BinaryItemDelegate(QObject *parent) : QStyledItemDelegate(parent) { - small_font.setPixelSize(8); - hex_font = QFontDatabase::systemFont(QFontDatabase::FixedFont); - hex_font.setBold(true); - - bin_text_table[0].setText("0"); - bin_text_table[1].setText("1"); - for (int i = 0; i < 256; ++i) { - hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper()); - hex_text_table[i].prepare({}, hex_font); - } -} - -bool BinaryItemDelegate::hasSignal(const QModelIndex &index, int dx, int dy, const cabana::Signal *sig) const { - if (!index.isValid()) return false; - auto model = (const BinaryViewModel*)(index.model()); - int idx = (index.row() + dy) * model->columnCount() + index.column() + dx; - if (idx < 0 || idx >= (int)model->items.size()) return false; - auto &s = model->items[idx].sigs; - return std::find(s.begin(), s.end(), sig) != s.end(); -} - -void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto item = (const BinaryViewModel::Item *)index.internalPointer(); - BinaryView *bin_view = (BinaryView *)parent(); - painter->save(); - - if (index.column() == 8) { - if (item->valid) { - painter->setFont(hex_font); - painter->fillRect(option.rect, item->bg_color); - } - } else if (option.state & QStyle::State_Selected) { - 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, toQColor(s->color.darker(125))); // 4/5x brightness - } else { - drawSignalCell(painter, option, index, s); - } - } - } else if (item->valid && item->bg_color.alpha() > 0) { - painter->fillRect(option.rect, item->bg_color); - } - auto color_role = (std::find(item->sigs.begin(), item->sigs.end(), bin_view->hovered_sig) != item->sigs.end()) ? QPalette::BrightText : QPalette::Text; - painter->setPen(option.palette.color(bin_view->is_message_active ? QPalette::Normal : QPalette::Disabled, color_role)); - } - - if (item->sigs.size() > 1) { - painter->fillRect(option.rect, QBrush(Qt::darkGray, Qt::Dense7Pattern)); - } else if (!item->valid) { - painter->fillRect(option.rect, QBrush(Qt::darkGray, Qt::BDiagPattern)); - } - if (item->valid) { - utils::drawStaticText(painter, option.rect, index.column() == 8 ? hex_text_table[item->val] : bin_text_table[item->val]); - } - if (item->is_msb || item->is_lsb) { - painter->setFont(small_font); - painter->drawText(option.rect.adjusted(8, 0, -8, -3), Qt::AlignRight | Qt::AlignBottom, item->is_msb ? "M" : "L"); - } - painter->restore(); -} - -// Draw border on edge of signal -void BinaryItemDelegate::drawSignalCell(QPainter *painter, const QStyleOptionViewItem &option, - const QModelIndex &index, const cabana::Signal *sig) const { - bool draw_left = !hasSignal(index, -1, 0, sig); - bool draw_top = !hasSignal(index, 0, -1, sig); - bool draw_right = !hasSignal(index, 1, 0, sig); - bool draw_bottom = !hasSignal(index, 0, 1, sig); - - const int spacing = 2; - QRect rc = option.rect.adjusted(draw_left * 3, draw_top * spacing, draw_right * -3, draw_bottom * -spacing); - QRegion subtract; - if (!draw_top) { - if (!draw_left && !hasSignal(index, -1, -1, sig)) { - subtract += QRect{rc.left(), rc.top(), 3, spacing}; - } else if (!draw_right && !hasSignal(index, 1, -1, sig)) { - subtract += QRect{rc.right() - 2, rc.top(), 3, spacing}; - } - } - if (!draw_bottom) { - if (!draw_left && !hasSignal(index, -1, 1, sig)) { - subtract += QRect{rc.left(), rc.bottom() - (spacing - 1), 3, spacing}; - } else if (!draw_right && !hasSignal(index, 1, 1, sig)) { - subtract += QRect{rc.right() - 2, rc.bottom() - (spacing - 1), 3, spacing}; - } - } - painter->setClipRegion(QRegion(rc).subtracted(subtract)); - - auto item = (const BinaryViewModel::Item *)index.internalPointer(); - 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 = 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()); - if (draw_bottom) painter->drawLine(rc.bottomLeft(), rc.bottomRight()); - if (draw_top) painter->drawLine(rc.topLeft(), rc.topRight()); - - if (!subtract.isEmpty()) { - // fill gaps inside corners. - painter->setPen(QPen(color, 2, Qt::SolidLine, Qt::SquareCap, Qt::MiterJoin)); - for (auto &r : subtract) { - painter->drawRect(r); - } - } -} diff --git a/openpilot/tools/cabana/binaryview.h b/openpilot/tools/cabana/binaryview.h deleted file mode 100644 index c918ee01e7..0000000000 --- a/openpilot/tools/cabana/binaryview.h +++ /dev/null @@ -1,106 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" - -class BinaryItemDelegate : public QStyledItemDelegate { -public: - BinaryItemDelegate(QObject *parent); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - bool hasSignal(const QModelIndex &index, int dx, int dy, const cabana::Signal *sig) const; - void drawSignalCell(QPainter* painter, const QStyleOptionViewItem &option, const QModelIndex &index, const cabana::Signal *sig) const; - - QFont small_font, hex_font; - std::array hex_text_table; - std::array bin_text_table; -}; - -class BinaryViewModel : public QAbstractTableModel { -public: - BinaryViewModel(QObject *parent) : QAbstractTableModel(parent) {} - void refresh(); - void updateState(); - void updateItem(int row, int col, uint8_t val, const QColor &color); - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return row_count; } - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return column_count; } - QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override { - return createIndex(row, column, (void *)&items[row * column_count + column]); - } - Qt::ItemFlags flags(const QModelIndex &index) const override { - return (index.column() == column_count - 1) ? Qt::ItemIsEnabled : Qt::ItemIsEnabled | Qt::ItemIsSelectable; - } - const std::vector> &getBitFlipChanges(size_t msg_size); - - struct BitFlipTracker { - std::optional> time_range; - std::vector> flip_counts; - } bit_flip_tracker; - - struct Item { - QColor bg_color = QColor(102, 86, 169, 255); - bool is_msb = false; - bool is_lsb = false; - uint8_t val; - std::vector sigs; - bool valid = false; - }; - std::vector items; - bool heatmap_live_mode = true; - MessageId msg_id; - int row_count = 0; - const int column_count = 9; -}; - -class BinaryView : public QTableView { - Q_OBJECT - -public: - BinaryView(QWidget *parent = nullptr); - void setMessage(const MessageId &message_id); - void highlight(const cabana::Signal *sig); - std::set getOverlappingSignals() const; - void updateState() { model->updateState(); } - void paintEvent(QPaintEvent *event) override { - is_message_active = can->isMessageActive(model->msg_id); - QTableView::paintEvent(event); - } - QSize minimumSizeHint() const override; - void setHeatmapLiveMode(bool live) { model->heatmap_live_mode = live; updateState(); } - -signals: - void signalClicked(const cabana::Signal *sig); - void signalHovered(const cabana::Signal *sig); - void editSignal(const cabana::Signal *origin_s, cabana::Signal &s); - void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); - -private: - void addShortcuts(); - void refresh(); - std::tuple getSelection(QModelIndex index); - void setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) override; - void mousePressEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - void leaveEvent(QEvent *event) override; - void highlightPosition(const QPoint &pt); - - QModelIndex anchor_index; - QPoint last_mouse_pos{-1, -1}; - BinaryViewModel *model; - BinaryItemDelegate *delegate; - bool is_message_active = false; - const cabana::Signal *resize_sig = nullptr; - const cabana::Signal *hovered_sig = nullptr; - Connections connections_; - friend class BinaryItemDelegate; -}; diff --git a/openpilot/tools/cabana/cabana b/openpilot/tools/cabana/cabana deleted file mode 100755 index db613b5391..0000000000 --- a/openpilot/tools/cabana/cabana +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -set -e - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -ROOT="$(cd "$DIR/../../../" && pwd)" - -install_qt() { - if [[ "$(uname)" == "Darwin" ]]; then - brew install qt@5 - brew link qt@5 || true - else - SUDO="" - if [[ ! $(id -u) -eq 0 ]]; then - SUDO="sudo" - fi - $SUDO apt-get install -y --no-install-recommends \ - qtbase5-dev \ - qtbase5-dev-tools \ - qttools5-dev-tools \ - libqt5charts5-dev \ - libqt5svg5-dev \ - libqt5serialbus5-dev \ - libqt5x11extras5-dev \ - libqt5opengl5-dev - fi -} - -# Install Qt if not found -if ! command -v qmake &> /dev/null; then - echo "Qt not found, installing dependencies..." - install_qt -fi - -# Build _cabana -cd "$ROOT" -scons -u openpilot/tools/cabana/_cabana openpilot/cereal/messaging/bridge - -exec "$DIR/_cabana" "$@" diff --git a/openpilot/tools/cabana/cameraview.cc b/openpilot/tools/cabana/cameraview.cc deleted file mode 100644 index b8c70ab1bf..0000000000 --- a/openpilot/tools/cabana/cameraview.cc +++ /dev/null @@ -1,121 +0,0 @@ -#include "tools/cabana/cameraview.h" - -#include -#include -#include -#include - -#include -#include - -#include "common/yuv.h" -#include "tools/cabana/utils/util.h" - -CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type, QWidget* parent) : - stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QWidget(parent) { - setAttribute(Qt::WA_OpaquePaintEvent); - QObject::connect(QApplication::instance(), &QCoreApplication::aboutToQuit, this, &CameraWidget::stopVipcThread); -} - -CameraWidget::~CameraWidget() { - stopVipcThread(); -} - -void CameraWidget::showEvent(QShowEvent *event) { - if (!vipc_thread.joinable()) { - clearFrames(); - vipc_exit = false; - vipc_thread = std::thread(&CameraWidget::vipcThread, this); - } -} - -void CameraWidget::stopVipcThread() { - vipc_exit = true; - if (vipc_thread.joinable()) { - vipc_thread.join(); - } -} - -void CameraWidget::paintEvent(QPaintEvent *event) { - QPainter p(this); - p.fillRect(rect(), bg); - - std::lock_guard lk(frame_lock); - if (rgb_frame.isNull()) return; - - // Scale for aspect ratio - float widget_ratio = (float)width() / height(); - 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); - - 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::vipcThread() { - VisionStreamType cur_stream = requested_stream_type; - std::unique_ptr vipc_client; - VisionIpcBufExtra frame_meta = {}; - - while (!vipc_exit) { - if (!vipc_client || cur_stream != requested_stream_type) { - clearFrames(); - 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)); - } - active_stream_type = cur_stream; - - if (!vipc_client->connected) { - clearFrames(); - auto streams = VisionIpcClient::getAvailableStreams(stream_name, false); - if (streams.empty()) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - continue; - } - utils::runOnMainThread([this, alive = std::weak_ptr(alive_), streams]() { - if (alive.expired()) return; - available_streams = streams; - availableStreamsUpdated(streams); - }); - - if (!vipc_client->connect(false)) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - continue; - } - } - - 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); - rgb_frame.swap(rgb_back); - } - utils::runOnMainThread([this, alive = std::weak_ptr(alive_)]() { - if (!alive.expired()) update(); - }); - } - } -} - -void CameraWidget::clearFrames() { - std::lock_guard lk(frame_lock); - rgb_frame = QImage(); - rgb_back = QImage(); - available_streams.clear(); -} diff --git a/openpilot/tools/cabana/cameraview.h b/openpilot/tools/cabana/cameraview.h deleted file mode 100644 index ac292a701c..0000000000 --- a/openpilot/tools/cabana/cameraview.h +++ /dev/null @@ -1,49 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include "openpilot/cereal/visionstream.h" -#include "tools/cabana/core/observable.h" -#include "msgq/visionipc/visionipc_client.h" - -class CameraWidget : public QWidget { -public: - explicit CameraWidget(std::string stream_name, VisionStreamType stream_type, QWidget* parent = nullptr); - ~CameraWidget(); - void setStreamType(VisionStreamType type) { requested_stream_type = type; } - VisionStreamType getStreamType() { return active_stream_type; } - void stopVipcThread(); - - Observable<> clicked; - Observable> availableStreamsUpdated; // invoked on the main thread - -protected: - void paintEvent(QPaintEvent *event) override; - void showEvent(QShowEvent *event) override; - void hideEvent(QHideEvent *event) override { stopVipcThread(); } - void mouseReleaseEvent(QMouseEvent *event) override { clicked(); } - void vipcThread(); - void clearFrames(); - - 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; - std::atomic active_stream_type; - std::atomic requested_stream_type; - std::set available_streams; - std::thread vipc_thread; - std::atomic vipc_exit = false; - std::mutex frame_lock; - std::shared_ptr alive_ = std::make_shared(true); -}; diff --git a/openpilot/tools/cabana/chart/chart.cc b/openpilot/tools/cabana/chart/chart.cc deleted file mode 100644 index 478ff3ce9d..0000000000 --- a/openpilot/tools/cabana/chart/chart.cc +++ /dev/null @@ -1,769 +0,0 @@ -#include "tools/cabana/chart/chart.h" - -#include -#include -#include - -#include -#include -#include -#include - -#include "tools/cabana/chart/chartswidget.h" - -const int AXIS_X_TOP_MARGIN = 4; -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) - : x_min(x_range.first), x_max(x_range.second), charts_widget(parent), QWidget(parent) { - series_type = (SeriesType)settings.chart_series_type; - align_to = 50; - setMouseTracking(true); - tip_label = new TipLabel(this); - createToolButtons(); - signal_value_font.setPointSize(9); - - connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { signalRemoved(sig); })); - connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { signalUpdated(sig); })); - connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { msgRemoved(id); })); - connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { msgUpdated(id); })); -} - -void ChartView::createToolButtons() { - close_btn = new ToolButton("x", tr("Remove Chart"), this); - - menu = new QMenu(this); - // series types - auto change_series_group = new QActionGroup(menu); - change_series_group->setExclusive(true); - QStringList types{tr("Line"), tr("Step Line"), tr("Scatter")}; - for (int i = 0; i < types.size(); ++i) { - QAction *act = new QAction(types[i], change_series_group); - act->setData(i); - act->setCheckable(true); - act->setChecked(i == (int)series_type); - menu->addAction(act); - } - menu->addSeparator(); - menu->addAction(tr("Manage Signals"), this, &ChartView::manageSignals); - split_chart_act = menu->addAction(tr("Split Chart"), [this]() { charts_widget->splitChart(this); }); - - manage_btn = new ToolButton("list", "", this); - manage_btn->setMenu(menu); - manage_btn->setPopupMode(QToolButton::InstantPopup); - manage_btn->setStyleSheet("QToolButton::menu-indicator { image: none; }"); - - close_act = new QAction(tr("Close"), this); - QObject::connect(close_act, &QAction::triggered, [this] () { charts_widget->removeChart(this); }); - 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()); - }); -} - -QSize ChartView::sizeHint() const { - return {CHART_MIN_WIDTH, settings.chart_height}; -} - -void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) { - if (hasSignal(msg_id, sig)) return; - - sigs.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(toQColor(sig->color))}); - updateSeries(sig); - updateTitle(); - emit charts_widget->seriesChanged(); -} - -bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const { - return std::any_of(sigs.cbegin(), sigs.cend(), [&](auto &s) { return s.msg_id == msg_id && s.sig == sig; }); -} - -void ChartView::removeIf(std::function predicate) { - int prev_size = sigs.size(); - 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(); - 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->color != toQColor(sig->color)) { - it->color = uniqueColor(toQColor(sig->color), sig); - } - updateTitle(); - updateSeries(sig); - } -} - -void ChartView::msgUpdated(MessageId id) { - if (std::any_of(sigs.cbegin(), sigs.cend(), [=](auto &s) { return s.msg_id.address == id.address; })) { - updateTitle(); - } -} - -void ChartView::manageSignals() { - SignalSelector dlg(tr("Manage Chart"), this); - for (auto &s : sigs) { - dlg.addSelected(s.msg_id, s.sig); - } - if (dlg.exec() == QDialog::Accepted) { - auto items = dlg.seletedItems(); - for (auto s : items) { - addSignal(s->msg_id, s->sig); - } - removeIf([&](auto &s) { - return std::none_of(items.cbegin(), items.cend(), [&](auto &it) { return s.msg_id == it->msg_id && s.sig == it->sig; }); - }); - } -} - -void ChartView::resizeEvent(QResizeEvent *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; - - 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); - - // 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 = 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() { - split_chart_act->setEnabled(sigs.size() > 1); - updatePlotArea(align_to, true); -} - -void ChartView::updatePlot(double cur, double min, double max) { - cur_sec = cur; - if (min != x_min || max != x_max) { - x_min = min; - x_max = max; - updateAxisY(); - // update tooltip - if (tooltip_x >= 0) { - showTip(secondsAtPoint({tooltip_x, 0})); - } - resetChartCache(); - } - update(); -} - -void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector &events, - std::vector &vals, std::vector &step_vals) { - vals.reserve(vals.size() + events.capacity()); - step_vals.reserve(step_vals.size() + events.capacity() * 2); - - double value = 0; - for (const CanEvent *e : events) { - if (sig->getValue(e->dat, e->size, &value)) { - const double ts = can->toSeconds(e->mono_time); - vals.emplace_back(ts, value); - if (!step_vals.empty()) - step_vals.emplace_back(ts, step_vals.back().y()); - step_vals.emplace_back(ts, value); - } - } -} - -void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap *msg_new_events) { - for (auto &s : sigs) { - if (!sig || s.sig == sig) { - if (!msg_new_events) { - s.vals.clear(); - s.step_vals.clear(); - } - auto events = msg_new_events ? msg_new_events : &can->eventsMap(); - auto it = events->find(s.msg_id); - if (it == events->end() || it->second.empty()) continue; - - if (s.vals.empty() || can->toSeconds(it->second.back()->mono_time) > s.vals.back().x()) { - appendCanEvents(s.sig, it->second, s.vals, s.step_vals); - } else { - std::vector vals, step_vals; - appendCanEvents(s.sig, it->second, vals, step_vals); - s.vals.insert(std::lower_bound(s.vals.begin(), s.vals.end(), vals.front().x(), xLessThan), - vals.begin(), vals.end()); - s.step_vals.insert(std::lower_bound(s.step_vals.begin(), s.step_vals.end(), step_vals.front().x(), xLessThan), - step_vals.begin(), step_vals.end()); - } - - if (!can->liveStreaming()) { - s.segment_tree.build(s.vals.size(), [&vals = s.vals](int i) { return vals[i].y(); }); - } - } - } - updateAxisY(); - // invoke resetChartCache in ui thread - QMetaObject::invokeMethod(this, &ChartView::resetChartCache, Qt::QueuedConnection); -} - -// auto zoom on yaxis -void ChartView::updateAxisY() { - if (sigs.empty()) return; - - double min = std::numeric_limits::max(); - double max = std::numeric_limits::lowest(); - QString unit = QString::fromStdString(sigs[0].sig->unit); - - for (auto &s : sigs) { - 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(), 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()) { - for (auto it = first; it != last; ++it) { - if (it->y() < s.min) s.min = it->y(); - if (it->y() > s.max) s.max = it->y(); - } - } else { - std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last)); - } - min = std::min(min, s.min); - max = std::max(max, s.max); - } - if (min == std::numeric_limits::max()) min = 0; - if (max == std::numeric_limits::lowest()) max = 0; - - 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 != 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); - - QFontMetrics fm(font()); - int max_label_width = 0; - 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', y_precision))); - } - - int title_spacing = y_unit.isEmpty() ? 0 : fm.size(Qt::TextSingleLine, y_unit).height(); - y_label_width = title_spacing + max_label_width + 15; - emit axisYLabelWidthChanged(y_label_width); - } -} - -std::tuple ChartView::getNiceAxisNumbers(qreal min, qreal max, int tick_count) { - qreal range = niceNumber((max - min), true); // range with ceiling - qreal step = niceNumber(range / (tick_count - 1), false); - min = std::floor(min / step); - max = std::ceil(max / step); - tick_count = int(max - min) + 1; - 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 - qreal q = x / z; //q<10 && q>=1; - if (ceiling) { - if (q <= 1.0) q = 1; - else if (q <= 2.0) q = 2; - else if (q <= 5.0) q = 5; - else q = 10; - } else { - if (q < 1.5) q = 1; - else if (q < 3.0) q = 2; - else if (q < 7.0) q = 5; - else q = 10; - } - return q * z; -} - -void ChartView::contextMenuEvent(QContextMenuEvent *event) { - QMenu context_menu(this); - context_menu.addActions(menu->actions()); - context_menu.addSeparator(); - context_menu.addAction(charts_widget->undo_zoom_action); - context_menu.addAction(charts_widget->redo_zoom_action); - context_menu.addSeparator(); - context_menu.addAction(close_act); - context_menu.exec(event->globalPos()); -} - -void ChartView::mousePressEvent(QMouseEvent *event) { - 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); - } - mouse_mode = MouseMode::Scrub; - } else if (event->button() == Qt::LeftButton && plot_area.contains(event->pos())) { - mouse_mode = MouseMode::Rubber; - rubber_rect = QRect(); - } else { - QWidget::mousePressEvent(event); - } -} - -void ChartView::mouseMoveEvent(QMouseEvent *ev) { - // Scrubbing - if (mouse_mode == MouseMode::Scrub && ev->modifiers().testFlag(Qt::ShiftModifier)) { - if (plot_area.contains(ev->pos())) { - can->seekTo(std::clamp(secondsAtPoint(ev->pos()), can->minSeconds(), can->maxSeconds())); - } - } - - 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(); - } - - 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); -} - -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; - } - } -} - -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, 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 = xPos(sec); - qreal x = -1; - QStringList text_list; - for (auto &s : sigs) { - 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() >= x_min) { - value = QString::fromStdString(s.sig->formatValue(it->y(), false)); - s.track_pt = *it; - 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.color.name(), name, value, min, max); - } - } - if (x < 0) { - x = tooltip_x; - } - 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); - update(); -} - -void ChartView::hideTip() { - clearTrackPoints(); - tooltip_x = -1; - tip_label->hide(); - update(); -} - -void ChartView::resetChartCache() { - chart_pixmap = QPixmap(); - update(); -} - -void ChartView::paintEvent(QPaintEvent *event) { - QPainter painter(this); - painter.setRenderHints(QPainter::Antialiasing); - - // 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::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::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.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) { - painter->setPen(QPen(Qt::darkGray, 1, Qt::DashLine)); - 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) { - 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) { - // draw vertical time line - 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(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(font()); - painter->drawText(time_str_rect, Qt::AlignCenter, time_str); -} - -void ChartView::drawSignalValue(QPainter *painter) { - painter->setFont(signal_value_font); - 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() >= 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); - } -} - -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 = 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), - sat(rng) / 100.0, - val(rng) / 100.0); - break; - } - } - return color; -} - -void ChartView::setSeriesType(SeriesType type) { - if (type != series_type) { - series_type = type; - menu->actions()[(int)type]->setChecked(true); - updateTitle(); - } -} diff --git a/openpilot/tools/cabana/chart/chart.h b/openpilot/tools/cabana/chart/chart.h deleted file mode 100644 index 3188e1f401..0000000000 --- a/openpilot/tools/cabana/chart/chart.h +++ /dev/null @@ -1,132 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include - -#include "tools/cabana/chart/tiplabel.h" -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/utils/qtutil.h" - -enum class SeriesType { - Line = 0, - StepLine, - Scatter -}; - -class ChartsWidget; -class ChartView : public QWidget { - Q_OBJECT - -public: - ChartView(const std::pair &x_range, ChartsWidget *parent = nullptr); - void addSignal(const MessageId &msg_id, const cabana::Signal *sig); - bool hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const; - void updateSeries(const cabana::Signal *sig = nullptr, const MessageEventsMap *msg_new_events = nullptr); - void updatePlot(double cur, double min, double max); - void setSeriesType(SeriesType type); - void updatePlotArea(int left, bool force = false); - void showTip(double sec); - void hideTip(); - 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; - QColor color; - bool visible = true; - std::vector vals; - std::vector step_vals; - QPointF track_pt{}; - SegmentTree segment_tree; - double min = 0; - double max = 0; - }; - -signals: - void axisYLabelWidthChanged(int w); - -private slots: - void signalUpdated(const cabana::Signal *sig); - void manageSignals(); - 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; }); } - -private: - void appendCanEvents(const cabana::Signal *sig, const std::vector &events, - std::vector &vals, std::vector &step_vals); - void createToolButtons(); - void contextMenuEvent(QContextMenuEvent *event) override; - void mousePressEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - void resizeEvent(QResizeEvent *event) override; - QSize sizeHint() const override; - void updateAxisY(); - void updateTitle(); - void resetChartCache(); - void paintEvent(QPaintEvent *event) override; - 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); - 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; - // 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; - ToolButton *manage_btn; - ToolButton *close_btn; - TipLabel *tip_label; - std::vector sigs; - double cur_sec = 0; - SeriesType series_type = SeriesType::Line; - QPixmap chart_pixmap; - bool can_drop = false; - double tooltip_x = -1; - QFont signal_value_font; - ChartsWidget *charts_widget; - Connections connections_; - friend class ChartsWidget; -}; diff --git a/openpilot/tools/cabana/chart/chartswidget.cc b/openpilot/tools/cabana/chart/chartswidget.cc deleted file mode 100644 index 2e3e86bab0..0000000000 --- a/openpilot/tools/cabana/chart/chartswidget.cc +++ /dev/null @@ -1,660 +0,0 @@ -#include "tools/cabana/chart/chartswidget.h" - -#include -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/chart/chart.h" - -const int MAX_COLUMN_COUNT = 4; -const int CHART_SPACING = 4; - -ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { - align_timer = new QTimer(this); - auto_scroll_timer = new QTimer(this); - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - - // toolbar - toolbar = new QToolBar(tr("Charts"), this); - int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize); - toolbar->setIconSize({icon_size, icon_size}); - - auto new_plot_btn = new ToolButton("file-plus", tr("New Chart")); - auto new_tab_btn = new ToolButton("window-stack", tr("New Tab")); - toolbar->addWidget(new_plot_btn); - toolbar->addWidget(new_tab_btn); - toolbar->addWidget(title_label = new QLabel()); - title_label->setContentsMargins(0, 0, style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), 0); - - auto chart_type_action = toolbar->addAction(""); - QMenu *chart_type_menu = new QMenu(this); - auto types = std::array{tr("Line"), tr("Step"), tr("Scatter")}; - for (int i = 0; i < types.size(); ++i) { - QString type_text = types[i]; - chart_type_menu->addAction(type_text, this, [=]() { - settings.chart_series_type = i; - chart_type_action->setText("Type: " + type_text); - settingChanged(); - }); - } - chart_type_action->setText("Type: " + types[settings.chart_series_type]); - chart_type_action->setMenu(chart_type_menu); - qobject_cast(toolbar->widgetForAction(chart_type_action))->setPopupMode(QToolButton::InstantPopup); - - QMenu *menu = new QMenu(this); - for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { - menu->addAction(tr("%1").arg(i + 1), [=]() { setColumnCount(i + 1); }); - } - columns_action = toolbar->addAction(""); - columns_action->setMenu(menu); - qobject_cast(toolbar->widgetForAction(columns_action))->setPopupMode(QToolButton::InstantPopup); - - QWidget *spacer = new QWidget(this); - spacer->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); - toolbar->addWidget(spacer); - - range_lb_action = toolbar->addWidget(range_lb = new QLabel(this)); - range_slider = new LogSlider(1000, Qt::Horizontal, this); - range_slider->setFixedWidth(150 * qApp->devicePixelRatio()); - range_slider->setToolTip(tr("Set the chart range")); - range_slider->setRange(1, settings.max_cached_minutes * 60); - range_slider->setSingleStep(1); - range_slider->setPageStep(60); // 1 min - range_slider_action = toolbar->addWidget(range_slider); - - // zoom controls - 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); - connections_.push_back(zoom_undo_stack.indexChanged.connect([this]() { - undo_zoom_action->setEnabled(zoom_undo_stack.canUndo()); - redo_zoom_action->setEnabled(zoom_undo_stack.canRedo()); - })); - reset_zoom_action = toolbar->addWidget(reset_zoom_btn = new ToolButton("zoom-out", tr("Reset Zoom"))); - reset_zoom_btn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - - toolbar->addWidget(remove_all_btn = new ToolButton("x-square", tr("Remove all charts"))); - toolbar->addWidget(dock_btn = new ToolButton("")); - main_layout->addWidget(toolbar); - - // tabbar - tabbar = new TabBar(this); - tabbar->setAutoHide(true); - tabbar->setExpanding(false); - tabbar->setDrawBase(true); - tabbar->setUsesScrollButtons(true); - main_layout->addWidget(tabbar); - - // charts - charts_container = new ChartsContainer(this); - charts_container->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); - charts_scroll = new QScrollArea(this); - charts_scroll->viewport()->setBackgroundRole(QPalette::Base); - charts_scroll->setFrameStyle(QFrame::NoFrame); - charts_scroll->setWidgetResizable(true); - charts_scroll->setWidget(charts_container); - 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); - max_chart_range = std::clamp(settings.chart_range, 1, settings.max_cached_minutes * 60); - display_range = std::make_pair(can->minSeconds(), can->minSeconds() + max_chart_range); - range_slider->setValue(max_chart_range); - updateToolBar(); - - align_timer->setSingleShot(true); - QObject::connect(align_timer, &QTimer::timeout, this, &ChartsWidget::alignCharts); - QObject::connect(auto_scroll_timer, &QTimer::timeout, this, &ChartsWidget::doAutoScroll); - connections_.push_back(dbc()->fileChanged.connect([this]() { removeAll(); })); - connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &events) { eventsMerged(events); })); - connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); - connections_.push_back(can->seeking.connect([this](double) { updateState(); })); - connections_.push_back(can->timeRangeChanged.connect([this](const auto &range) { timeRangeChanged(range); })); - QObject::connect(range_slider, &QSlider::valueChanged, this, &ChartsWidget::setMaxChartRange); - QObject::connect(new_plot_btn, &QToolButton::clicked, this, &ChartsWidget::newChart); - QObject::connect(remove_all_btn, &QToolButton::clicked, this, &ChartsWidget::removeAll); - QObject::connect(reset_zoom_btn, &QToolButton::clicked, this, &ChartsWidget::zoomReset); - connections_.push_back(settings.changed.connect([this]() { settingChanged(); })); - QObject::connect(new_tab_btn, &QToolButton::clicked, this, &ChartsWidget::newTab); - QObject::connect(this, &ChartsWidget::seriesChanged, this, &ChartsWidget::updateTabBar); - QObject::connect(tabbar, &QTabBar::tabCloseRequested, this, &ChartsWidget::removeTab); - QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) { - if (index != -1) updateLayout(true); - }); - QObject::connect(dock_btn, &QToolButton::clicked, this, &ChartsWidget::toggleChartsDocking); - - setIsDocked(true); - newTab(); - qApp->installEventFilter(this); - setWhatsThis(tr(R"( - Chart View
- Click: Click to seek to a corresponding time.
- Drag: Zoom into the chart.
- Shift + Drag: Scrub through the chart to view values.
- Right Mouse: Open the context menu.
- )")); -} - -void ChartsWidget::newTab() { - static int tab_unique_id = 0; - int idx = tabbar->addTab(""); - tabbar->setTabData(idx, tab_unique_id++); - tabbar->setCurrentIndex(idx); - updateTabBar(); -} - -void ChartsWidget::removeTab(int index) { - int id = tabbar->tabData(index).toInt(); - for (auto &c : tab_charts[id]) { - removeChart(c); - } - tab_charts.erase(id); - tabbar->removeTab(index); - updateTabBar(); -} - -void ChartsWidget::updateTabBar() { - for (int i = 0; i < tabbar->count(); ++i) { - const auto &charts_in_tab = tab_charts[tabbar->tabData(i).toInt()]; - tabbar->setTabText(i, QString("Tab %1 (%2)").arg(i + 1).arg((int)charts_in_tab.size())); - } -} - -void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) { - std::vector> futures; - for (auto c : charts) { - futures.push_back(std::async(std::launch::async, &ChartView::updateSeries, c, nullptr, &new_events)); - } - for (auto &f : futures) f.get(); -} - -void ChartsWidget::timeRangeChanged(const std::optional> &time_range) { - updateToolBar(); - updateState(); -} - -void ChartsWidget::zoomReset() { - can->setTimeRange(std::nullopt); - zoom_undo_stack.clear(); -} - -QRect ChartsWidget::chartVisibleRect(ChartView *chart) { - const QRect visible_rect(-charts_container->pos(), charts_scroll->viewport()->size()); - return chart->rect().intersected(QRect(chart->mapFrom(charts_container, visible_rect.topLeft()), visible_rect.size())); -} - -void ChartsWidget::showValueTip(double sec) { - emit showTip(sec); - if (sec < 0 && !value_tip_visible_) return; - - value_tip_visible_ = sec >= 0; - for (auto c : currentCharts()) { - value_tip_visible_ ? c->showTip(sec) : c->hideTip(); - } -} - -void ChartsWidget::updateState() { - if (charts.empty()) return; - - const auto &time_range = can->timeRange(); - const double cur_sec = can->currentSec(); - if (!time_range.has_value()) { - double pos = (cur_sec - display_range.first) / std::max(1.0, max_chart_range); - if (pos < 0 || pos > 0.8) { - display_range.first = std::max(can->minSeconds(), cur_sec - max_chart_range * 0.1); - } - double max_sec = std::min(display_range.first + max_chart_range, can->maxSeconds()); - display_range.first = std::max(can->minSeconds(), max_sec - max_chart_range); - display_range.second = display_range.first + max_chart_range; - } - - const auto &range = time_range ? *time_range : display_range; - for (auto c : charts) { - c->updatePlot(cur_sec, range.first, range.second); - } -} - -void ChartsWidget::setMaxChartRange(int value) { - max_chart_range = settings.chart_range = range_slider->value(); - updateToolBar(); - updateState(); -} - -void ChartsWidget::setIsDocked(bool docked) { - is_docked = docked; - dock_btn->setIcon(is_docked ? "arrow-up-right-square" : "arrow-down-left-square"); - dock_btn->setToolTip(is_docked ? tr("Float the charts window") : tr("Dock the charts window")); -} - -void ChartsWidget::updateToolBar() { - title_label->setText(tr("Charts: %1").arg(charts.size())); - columns_action->setText(tr("Columns: %1").arg(column_count)); - range_lb->setText(QString::fromStdString(utils::formatSeconds(max_chart_range))); - - bool is_zoomed = can->timeRange().has_value(); - range_lb_action->setVisible(!is_zoomed); - range_slider_action->setVisible(!is_zoomed); - undo_zoom_action->setVisible(is_zoomed); - redo_zoom_action->setVisible(is_zoomed); - reset_zoom_action->setVisible(is_zoomed); - reset_zoom_btn->setText(is_zoomed ? tr("%1-%2").arg(can->timeRange()->first, 0, 'f', 2).arg(can->timeRange()->second, 0, 'f', 2) : ""); - remove_all_btn->setEnabled(!charts.empty()); -} - -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")); - } - if (range_slider->maximum() != settings.max_cached_minutes * 60) { - range_slider->setRange(1, settings.max_cached_minutes * 60); - } - for (auto c : charts) { - c->setFixedHeight(settings.chart_height); - c->setSeriesType((SeriesType)settings.chart_series_type); - c->resetChartCache(); - } -} - -ChartView *ChartsWidget::findChart(const MessageId &id, const cabana::Signal *sig) { - for (auto c : charts) - if (c->hasSignal(id, sig)) return c; - return nullptr; -} - -ChartView *ChartsWidget::createChart(int pos) { - auto chart = new ChartView(can->timeRange().value_or(display_range), this); - chart->setFixedHeight(settings.chart_height); - chart->setMinimumWidth(CHART_MIN_WIDTH); - chart->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Fixed); - QObject::connect(chart, &ChartView::axisYLabelWidthChanged, align_timer, qOverload<>(&QTimer::start)); - pos = std::clamp(pos, 0, (int)charts.size()); - charts.insert(charts.begin() + pos, chart); - currentCharts().insert(currentCharts().begin() + pos, chart); - updateLayout(true); - updateToolBar(); - return chart; -} - -void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { - ChartView *chart = findChart(id, sig); - if (show && !chart) { - chart = merge && currentCharts().size() > 0 ? currentCharts().front() : createChart(); - chart->addSignal(id, sig); - updateState(); - } else if (!show && chart) { - chart->removeIf([&](auto &s) { return s.msg_id == id && s.sig == sig; }); - } -} - -void ChartsWidget::splitChart(ChartView *src_chart) { - if (src_chart->sigs.size() > 1) { - 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); - // Restore to the original color - it->color = toQColor(it->sig->color); - c->sigs.emplace_back(std::move(*it)); - c->updateAxisY(); - c->updateTitle(); - it = src_chart->sigs.erase(it); - } - src_chart->updateAxisY(); - src_chart->updateTitle(); - updateState(); - QTimer::singleShot(0, src_chart, &ChartView::resetChartCache); - } -} - -QStringList ChartsWidget::serializeChartIds() const { - QStringList chart_ids; - for (auto c : charts) { - QStringList ids; - for (const auto& s : c->sigs) - ids += QString("%1|%2").arg(QString::fromStdString(s.msg_id.toString()), QString::fromStdString(s.sig->name)); - chart_ids += ids.join(','); - } - std::reverse(chart_ids.begin(), chart_ids.end()); - return chart_ids; -} - -void ChartsWidget::restoreChartsFromIds(const QStringList& chart_ids) { - for (const auto& chart_id : chart_ids) { - int index = 0; - for (const auto& part : chart_id.split(',')) { - const auto sig_parts = part.split('|'); - if (sig_parts.size() != 2) continue; - MessageId msg_id = MessageId::fromString(sig_parts[0].toStdString()); - if (auto* msg = dbc()->msg(msg_id)) - if (auto* sig = msg->sig(sig_parts[1].toStdString())) - showChart(msg_id, sig, true, index++ > 0); - } - } -} - -void ChartsWidget::setColumnCount(int n) { - n = std::clamp(n, 1, MAX_COLUMN_COUNT); - if (column_count != n) { - column_count = settings.chart_column_count = n; - updateToolBar(); - updateLayout(); - } -} - -void ChartsWidget::updateLayout(bool force) { - auto charts_layout = charts_container->charts_layout; - int n = MAX_COLUMN_COUNT; - for (; n > 1; --n) { - if ((n * CHART_MIN_WIDTH + (n - 1) * charts_layout->horizontalSpacing()) < charts_layout->geometry().width()) break; - } - - bool show_column_cb = n > 1; - columns_action->setVisible(show_column_cb); - - n = std::min(column_count, n); - auto ¤t_charts = currentCharts(); - if ((current_charts.size() != charts_layout->count() || n != current_column_count) || force) { - current_column_count = n; - charts_container->setUpdatesEnabled(false); - for (auto c : charts) { - c->setVisible(false); - } - for (int i = 0; i < current_charts.size(); ++i) { - charts_layout->addWidget(current_charts[i], i / n, i % n); - if (current_charts[i]->sigs.empty()) { - // the chart will be resized after add signal. delay setVisible to reduce flicker. - QTimer::singleShot(0, current_charts[i], [c = current_charts[i]]() { c->setVisible(true); }); - } else { - current_charts[i]->setVisible(true); - } - } - charts_container->setUpdatesEnabled(true); - } -} - -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); -} - -void ChartsWidget::stopAutoScroll() { - auto_scroll_timer->stop(); - auto_scroll_count = 0; -} - -void ChartsWidget::doAutoScroll() { - QScrollBar *scroll = charts_scroll->verticalScrollBar(); - if (auto_scroll_count < scroll->pageStep()) { - ++auto_scroll_count; - } - - int value = scroll->value(); - QPoint pos = charts_scroll->viewport()->mapFromGlobal(auto_scroll_pos); - QRect area = charts_scroll->viewport()->rect(); - - if (pos.y() - area.top() < settings.chart_height / 2) { - scroll->setValue(value - auto_scroll_count); - } else if (area.bottom() - pos.y() < settings.chart_height / 2) { - scroll->setValue(value + auto_scroll_count); - } - if (value == scroll->value()) { - stopAutoScroll(); - } else if (chartDragActive()) { - // refresh the drop indicator/target at the new scroll position - dragChartMove(auto_scroll_pos); - } -} - -QSize ChartsWidget::minimumSizeHint() const { - return QSize(CHART_MIN_WIDTH * 1.5, QWidget::minimumSizeHint().height()); -} - -void ChartsWidget::newChart() { - SignalSelector dlg(tr("New Chart"), this); - if (dlg.exec() == QDialog::Accepted) { - auto items = dlg.seletedItems(); - if (!items.empty()) { - auto c = createChart(); - 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) { - list.erase(std::remove(list.begin(), list.end(), chart), list.end()); - } - updateToolBar(); - updateLayout(true); - alignCharts(); - emit seriesChanged(); -} - -void ChartsWidget::removeAll() { - while (tabbar->count() > 1) { - tabbar->removeTab(1); - } - tab_charts.clear(); - - if (!charts.empty()) { - for (auto c : charts) { - delete c; - } - charts.clear(); - emit seriesChanged(); - } - zoomReset(); -} - -void ChartsWidget::alignCharts() { - int plot_left = 0; - for (auto c : charts) { - plot_left = std::max(plot_left, c->y_label_width); - } - plot_left = std::max((plot_left / 10) * 10 + 10, 50); - for (auto c : charts) { - c->updatePlotArea(plot_left); - } -} - -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) { - bool on_tip = qobject_cast(o) != nullptr; - auto global_pos = static_cast(e)->globalPos(); - - for (const auto &c : charts) { - auto local_pos = c->mapFromGlobal(global_pos); - if (c->plot_area.contains(local_pos)) { - if (on_tip) { - showValueTip(c->secondsAtPoint(local_pos)); - } - return false; - } - } - - showValueTip(-1); - } else if (e->type() == QEvent::Wheel) { - if (auto tip = qobject_cast(o)) { - // Forward the event to the parent widget - QCoreApplication::sendEvent(tip->parentWidget(), e); - } - } - return false; -} - -bool ChartsWidget::event(QEvent *event) { - bool back_button = false; - switch (event->type()) { - case QEvent::Resize: - updateLayout(); - break; - case QEvent::MouseButtonPress: - back_button = static_cast(event)->button() == Qt::BackButton; - break; - case QEvent::NativeGesture: - back_button = (static_cast(event)->value() == 180); - break; - case QEvent::WindowDeactivate: - case QEvent::FocusOut: - if (chartDragActive()) cancelChartDrag(); - showValueTip(-1); - default: - break; - } - - if (back_button) { - zoom_undo_stack.undo(); - return true; // Return true since the event has been handled - } - return QFrame::event(event); -} - -// ChartsContainer - -ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), QWidget(parent) { - setBackgroundRole(QPalette::Window); - QVBoxLayout *charts_main_layout = new QVBoxLayout(this); - charts_main_layout->setContentsMargins(0, CHART_SPACING, 0, CHART_SPACING); - charts_layout = new QGridLayout(); - charts_layout->setSpacing(CHART_SPACING); - charts_main_layout->addLayout(charts_layout); - charts_main_layout->addStretch(0); -} - -void ChartsContainer::paintEvent(QPaintEvent *ev) { - if (!drop_indictor_pos.isNull() && !childAt(drop_indictor_pos)) { - QRect r = geometry(); - r.setHeight(CHART_SPACING); - if (auto insert_after = getDropAfter(drop_indictor_pos)) { - r.moveTop(insert_after->geometry().bottom()); - } - - QPainter p(this); - p.fillRect(r, palette().highlight()); - } -} - -ChartView *ChartsContainer::getDropAfter(const QPoint &pos) const { - auto it = std::find_if(charts_widget->currentCharts().crbegin(), charts_widget->currentCharts().crend(), [&pos](auto c) { - auto area = c->geometry(); - return pos.x() >= area.left() && pos.x() <= area.right() && pos.y() >= area.bottom(); - }); - return it == charts_widget->currentCharts().crend() ? nullptr : *it; -} diff --git a/openpilot/tools/cabana/chart/chartswidget.h b/openpilot/tools/cabana/chart/chartswidget.h deleted file mode 100644 index 17e18a90d2..0000000000 --- a/openpilot/tools/cabana/chart/chartswidget.h +++ /dev/null @@ -1,140 +0,0 @@ -#pragma once - -#include -#include - -#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" -#include "tools/cabana/utils/qtutil.h" - -const int CHART_MIN_WIDTH = 300; - -class ChartView; -class ChartsWidget; - -class ChartsContainer : public QWidget { -public: - ChartsContainer(ChartsWidget *parent); - void drawDropIndicator(const QPoint &pt) { drop_indictor_pos = pt; update(); } - void paintEvent(QPaintEvent *ev) override; - ChartView *getDropAfter(const QPoint &pos) const; - - QGridLayout *charts_layout; - ChartsWidget *charts_widget; - QPoint drop_indictor_pos; -}; - -class ChartsWidget : public QFrame { - Q_OBJECT - -public: - ChartsWidget(QWidget *parent = nullptr); - void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); - inline bool hasSignal(const MessageId &id, const cabana::Signal *sig) { return findChart(id, sig) != nullptr; } - QStringList serializeChartIds() const; - void restoreChartsFromIds(const QStringList &chart_ids); - -public slots: - void setColumnCount(int n); - void removeAll(); - void timeRangeChanged(const std::optional> &time_range); - void setIsDocked(bool dock); - -signals: - void toggleChartsDocking(); - void seriesChanged(); - void showTip(double seconds); - -private: - QSize minimumSizeHint() const override; - bool event(QEvent *event) override; - void alignCharts(); - void newChart(); - ChartView *createChart(int pos = 0); - void removeChart(ChartView *chart); - void splitChart(ChartView *chart); - QRect chartVisibleRect(ChartView *chart); - void eventsMerged(const MessageEventsMap &new_events); - void updateState(); - void zoomReset(); - 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(); - void updateTabBar(); - void setMaxChartRange(int value); - void updateLayout(bool force = false); - void settingChanged(); - void showValueTip(double sec); - bool eventFilter(QObject *obj, QEvent *event) override; - void newTab(); - void removeTab(int index); - inline std::vector ¤tCharts() { return tab_charts[tabbar->tabData(tabbar->currentIndex()).toInt()]; } - ChartView *findChart(const MessageId &id, const cabana::Signal *sig); - - QLabel *title_label; - QLabel *range_lb; - LogSlider *range_slider; - QAction *range_lb_action; - QAction *range_slider_action; - bool is_docked = true; - ToolButton *dock_btn; - - QToolBar *toolbar; - QAction *undo_zoom_action; - QAction *redo_zoom_action; - QAction *reset_zoom_action; - ToolButton *reset_zoom_btn; - UndoStack zoom_undo_stack; - - ToolButton *remove_all_btn; - std::vector charts; - std::unordered_map> tab_charts; - TabBar *tabbar; - ChartsContainer *charts_container; - QScrollArea *charts_scroll; - uint32_t max_chart_range = 0; - std::pair display_range; - 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; - bool value_tip_visible_ = false; - Connections connections_; - friend class ChartView; - friend class ChartsContainer; -}; - -class ZoomCommand : public UndoCommand { -public: - ZoomCommand(std::pair range) : range(range) { - prev_range = can->timeRange(); - } - void undo() override { can->setTimeRange(prev_range); } - void redo() override { can->setTimeRange(range); } - std::optional> prev_range, range; -}; diff --git a/openpilot/tools/cabana/chart/signalselector.cc b/openpilot/tools/cabana/chart/signalselector.cc deleted file mode 100644 index 90825dc402..0000000000 --- a/openpilot/tools/cabana/chart/signalselector.cc +++ /dev/null @@ -1,107 +0,0 @@ -#include "tools/cabana/chart/signalselector.h" - -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/utils/qtutil.h" - -SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) { - setWindowTitle(title); - QGridLayout *main_layout = new QGridLayout(this); - - // left column - main_layout->addWidget(new QLabel(tr("Available Signals")), 0, 0); - main_layout->addWidget(msgs_combo = new QComboBox(this), 1, 0); - msgs_combo->setEditable(true); - msgs_combo->lineEdit()->setPlaceholderText(tr("Select a msg...")); - msgs_combo->setInsertPolicy(QComboBox::NoInsert); - - main_layout->addWidget(available_list = new QListWidget(this), 2, 0); - - // buttons - QVBoxLayout *btn_layout = new QVBoxLayout(); - QPushButton *add_btn = new QPushButton(utils::icon("chevron-right"), "", this); - add_btn->setEnabled(false); - QPushButton *remove_btn = new QPushButton(utils::icon("chevron-left"), "", this); - remove_btn->setEnabled(false); - btn_layout->addStretch(0); - btn_layout->addWidget(add_btn); - btn_layout->addWidget(remove_btn); - btn_layout->addStretch(0); - main_layout->addLayout(btn_layout, 0, 1, 3, 1); - - // right column - main_layout->addWidget(new QLabel(tr("Selected Signals")), 0, 2); - main_layout->addWidget(selected_list = new QListWidget(this), 1, 2, 2, 1); - - auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - main_layout->addWidget(buttonBox, 3, 2); - - for (const auto &[id, _] : can->lastMessages()) { - if (auto m = dbc()->msg(id)) { - msgs_combo->addItem(QString("%1 (%2)").arg(QString::fromStdString(m->name)).arg(QString::fromStdString(id.toString())), QVariant::fromValue(id)); - } - } - msgs_combo->model()->sort(0); - msgs_combo->setCurrentIndex(-1); - - QObject::connect(msgs_combo, qOverload(&QComboBox::currentIndexChanged), this, &SignalSelector::updateAvailableList); - QObject::connect(available_list, &QListWidget::currentRowChanged, [=](int row) { add_btn->setEnabled(row != -1); }); - QObject::connect(selected_list, &QListWidget::currentRowChanged, [=](int row) { remove_btn->setEnabled(row != -1); }); - QObject::connect(available_list, &QListWidget::itemDoubleClicked, this, &SignalSelector::add); - QObject::connect(selected_list, &QListWidget::itemDoubleClicked, this, &SignalSelector::remove); - QObject::connect(add_btn, &QPushButton::clicked, [this]() { if (auto item = available_list->currentItem()) add(item); }); - QObject::connect(remove_btn, &QPushButton::clicked, [this]() { if (auto item = selected_list->currentItem()) remove(item); }); - QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); - QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); -} - -void SignalSelector::add(QListWidgetItem *item) { - auto it = (ListItem *)item; - addItemToList(selected_list, it->msg_id, it->sig, true); - delete item; -} - -void SignalSelector::remove(QListWidgetItem *item) { - auto it = (ListItem *)item; - if (it->msg_id == msgs_combo->currentData().value()) { - addItemToList(available_list, it->msg_id, it->sig); - } - delete item; -} - -void SignalSelector::updateAvailableList(int index) { - if (index == -1) return; - available_list->clear(); - MessageId msg_id = msgs_combo->itemData(index).value(); - auto selected_items = seletedItems(); - for (auto s : dbc()->msg(msg_id)->getSignals()) { - bool is_selected = std::any_of(selected_items.begin(), selected_items.end(), - [sig = s, &msg_id](auto it) { return it->msg_id == msg_id && it->sig == sig; }); - if (!is_selected) { - addItemToList(available_list, msg_id, s); - } - } -} - -void SignalSelector::addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_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); - label->setContentsMargins(5, 0, 5, 0); - auto new_item = new ListItem(id, sig, parent); - new_item->setSizeHint(label->sizeHint()); - parent->setItemWidget(new_item, label); -} - -std::vector SignalSelector::seletedItems() { - std::vector ret; - for (int i = 0; i < selected_list->count(); ++i) ret.push_back((ListItem *)selected_list->item(i)); - return ret; -} diff --git a/openpilot/tools/cabana/chart/signalselector.h b/openpilot/tools/cabana/chart/signalselector.h deleted file mode 100644 index 5b6e37e56a..0000000000 --- a/openpilot/tools/cabana/chart/signalselector.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" - -class SignalSelector : public QDialog { -public: - struct ListItem : public QListWidgetItem { - ListItem(const MessageId &msg_id, const cabana::Signal *sig, QListWidget *parent) : msg_id(msg_id), sig(sig), QListWidgetItem(parent) {} - MessageId msg_id; - const cabana::Signal *sig; - }; - - SignalSelector(QString title, QWidget *parent); - std::vector seletedItems(); - inline void addSelected(const MessageId &id, const cabana::Signal *sig) { addItemToList(selected_list, id, sig, true); } - -private: - void updateAvailableList(int index); - void addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name = false); - void add(QListWidgetItem *item); - void remove(QListWidgetItem *item); - - QComboBox *msgs_combo; - QListWidget *available_list; - QListWidget *selected_list; -}; diff --git a/openpilot/tools/cabana/chart/sparkline.cc b/openpilot/tools/cabana/chart/sparkline.cc deleted file mode 100644 index 587a35956d..0000000000 --- a/openpilot/tools/cabana/chart/sparkline.cc +++ /dev/null @@ -1,101 +0,0 @@ -#include "tools/cabana/chart/sparkline.h" - -#include -#include -#include -#include "tools/cabana/utils/qtutil.h" - -void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size) { - if (first == last || size.isEmpty()) { - pixmap = QPixmap(); - return; - } - - points_.clear(); - min_val = std::numeric_limits::max(); - max_val = std::numeric_limits::lowest(); - points_.reserve(std::distance(first, last)); - - uint64_t start_time = (*first)->mono_time; - double value = 0.0; - for (auto it = first; it != last; ++it) { - if (sig->getValue((*it)->dat, (*it)->size, &value)) { - min_val = std::min(min_val, value); - max_val = std::max(max_val, value); - points_.emplace_back(((*it)->mono_time - start_time) / 1e9, value); - } - } - - if (points_.empty()) { - pixmap = QPixmap(); - return; - } - - freq_ = points_.size() / std::max(points_.back().x() - points_.front().x(), 1.0); - render(toQColor(sig->color), range, size); -} - -void Sparkline::render(const QColor &color, int range, QSize size) { - // Adjust for flat lines - bool is_flat_line = min_val == max_val; - if (is_flat_line) { - min_val -= 1.0; - max_val += 1.0; - } - - // Calculate scaling - const double xscale = (size.width() - 1) / (double)range; - const double yscale = (size.height() - 3) / (max_val - min_val); - bool draw_individual_points = (points_.back().x() * xscale / points_.size()) > 8.0; - - // Transform or downsample points - render_points_.reserve(points_.size()); - render_points_.clear(); - if (draw_individual_points) { - for (const auto &p : points_) { - render_points_.emplace_back(p.x() * xscale, 1.0 + (max_val - p.y()) * yscale); - } - } else if (is_flat_line) { - double y = size.height() / 2.0; - render_points_.emplace_back(0.0, y); - render_points_.emplace_back(points_.back().x() * xscale, y); - } else { - double prev_y = points_.front().y(); - render_points_.emplace_back(points_.front().x() * xscale, 1.0 + (max_val - prev_y) * yscale); - bool in_flat = false; - - for (size_t i = 1; i < points_.size(); ++i) { - const auto &p = points_[i]; - double y = p.y(); - if (std::abs(y - prev_y) < 1e-6) { - in_flat = true; - } else { - if (in_flat) render_points_.emplace_back(points_[i - 1].x() * xscale, 1.0 + (max_val - prev_y) * yscale); - render_points_.emplace_back(p.x() * xscale, 1.0 + (max_val - y) * yscale); - in_flat = false; - } - prev_y = y; - } - if (in_flat) render_points_.emplace_back(points_.back().x() * xscale, 1.0 + (max_val - prev_y) * yscale); - } - - // Render to pixmap - qreal dpr = qApp->devicePixelRatio(); - const QSize pixmap_size = size * dpr; - if (pixmap.size() != pixmap_size) { - pixmap = QPixmap(pixmap_size); - } - pixmap.setDevicePixelRatio(dpr); - pixmap.fill(Qt::transparent); - QPainter painter(&pixmap); - painter.setRenderHint(QPainter::Antialiasing, render_points_.size() <= 500); - painter.setPen(color); - painter.drawPolyline(render_points_.data(), render_points_.size()); - - painter.setPen(QPen(color, 3)); - if (draw_individual_points) { - painter.drawPoints(render_points_.data(), render_points_.size()); - } else { - painter.drawPoint(render_points_.back()); - } -} diff --git a/openpilot/tools/cabana/chart/sparkline.h b/openpilot/tools/cabana/chart/sparkline.h deleted file mode 100644 index 7f30047d0d..0000000000 --- a/openpilot/tools/cabana/chart/sparkline.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "tools/cabana/dbc/dbc.h" -#include "tools/cabana/streams/abstractstream.h" - -class Sparkline { -public: - void update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size); - inline double freq() const { return freq_; } - bool isEmpty() const { return pixmap.isNull(); } - - QPixmap pixmap; - double min_val = 0; - double max_val = 0; - -private: - void render(const QColor &color, int range, QSize size); - - std::vector points_; - std::vector render_points_; - double freq_ = 0; -}; diff --git a/openpilot/tools/cabana/chart/tiplabel.cc b/openpilot/tools/cabana/chart/tiplabel.cc deleted file mode 100644 index c250ebf877..0000000000 --- a/openpilot/tools/cabana/chart/tiplabel.cc +++ /dev/null @@ -1,58 +0,0 @@ -#include "tools/cabana/chart/tiplabel.h" - -#include - -#include -#include -#include - -#include "tools/cabana/settings.h" -#include "tools/cabana/utils/qtutil.h" - -TipLabel::TipLabel(QWidget *parent) : QLabel(parent, Qt::ToolTip | Qt::FramelessWindowHint) { - setAttribute(Qt::WA_ShowWithoutActivating); - setAttribute(Qt::WA_TransparentForMouseEvents); - - setForegroundRole(QPalette::ToolTipText); - setBackgroundRole(QPalette::ToolTipBase); - - QFont font; - font.setPointSizeF(8.34563465); - setFont(font); - auto palette = QToolTip::palette(); - if (!utils::isDarkTheme()) { - palette.setColor(QPalette::ToolTipBase, QApplication::palette().color(QPalette::Base)); - palette.setColor(QPalette::ToolTipText, QRgb(0x404044)); // same color as chart label brush - } - setPalette(palette); - ensurePolished(); - setMargin(1 + style()->pixelMetric(QStyle::PM_ToolTipLabelFrameWidth, nullptr, this)); - setTextFormat(Qt::RichText); -} - -void TipLabel::showText(const QPoint &pt, const QString &text, QWidget *w, const QRect &rect) { - setText(text); - if (!text.isEmpty()) { - QSize extra(1, 1); - resize(sizeHint() + extra); - QPoint tip_pos(pt.x() + 8, rect.top() + 2); - if (tip_pos.x() + size().width() >= rect.right()) { - tip_pos.rx() = pt.x() - size().width() - 8; - } - if (rect.contains({tip_pos, size()})) { - move(w->mapToGlobal(tip_pos)); - setVisible(true); - return; - } - } - setVisible(false); -} - -void TipLabel::paintEvent(QPaintEvent *ev) { - QStylePainter p(this); - QStyleOptionFrame opt; - opt.init(this); - p.drawPrimitive(QStyle::PE_PanelTipLabel, opt); - p.end(); - QLabel::paintEvent(ev); -} diff --git a/openpilot/tools/cabana/chart/tiplabel.h b/openpilot/tools/cabana/chart/tiplabel.h deleted file mode 100644 index cc96aa9864..0000000000 --- a/openpilot/tools/cabana/chart/tiplabel.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include - -class TipLabel : public QLabel { - Q_OBJECT - -public: - TipLabel(QWidget *parent = nullptr); - void showText(const QPoint &pt, const QString &sec, QWidget *w, const QRect &rect); - void paintEvent(QPaintEvent *ev) override; -}; diff --git a/openpilot/tools/cabana/core/color.h b/openpilot/tools/cabana/core/color.h index c29704dd44..47e7370c5e 100644 --- a/openpilot/tools/cabana/core/color.h +++ b/openpilot/tools/cabana/core/color.h @@ -60,7 +60,6 @@ struct CabanaColor { 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; diff --git a/openpilot/tools/cabana/core/heatmap.h b/openpilot/tools/cabana/core/heatmap.h new file mode 100644 index 0000000000..04ef64e884 --- /dev/null +++ b/openpilot/tools/cabana/core/heatmap.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include + +// Count adjacent samples, never treating a newly appearing byte as a change. +struct HeatmapCounts { + HeatmapCounts() = default; + explicit HeatmapCounts(size_t size) : bits(size), bytes(size) {} + + void add(const uint8_t *data, size_t size) { + const size_t common = std::min({size, previous.size(), bits.size()}); + for (size_t i = 0; i < common; ++i) { + const uint8_t diff = data[i] ^ previous[i]; + if (diff) ++bytes[i]; + for (int bit = 0; bit < 8; ++bit) { + if (diff & (1u << bit)) ++bits[i][7 - bit]; + } + } + previous.assign(data, data + size); + } + + std::vector> bits; + std::vector bytes; + std::vector previous; +}; diff --git a/openpilot/tools/cabana/core/message_id.h b/openpilot/tools/cabana/core/message_id.h index ecac279631..e7f4e3c0b4 100644 --- a/openpilot/tools/cabana/core/message_id.h +++ b/openpilot/tools/cabana/core/message_id.h @@ -1,7 +1,9 @@ #pragma once +#include #include #include #include +#include #include #include @@ -11,11 +13,18 @@ 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))}; + // strict "bus:HEX" parser + static std::optional parse(const std::string &s) { + const auto colon = s.find(':'); + if (colon == std::string::npos) return std::nullopt; + const char *begin = s.data(), *end = begin + s.size(); + uint32_t source = 0, address = 0; + auto bus = std::from_chars(begin, begin + colon, source); + auto addr = std::from_chars(begin + colon + 1, end, address, 16); + if (bus.ec != std::errc() || bus.ptr != begin + colon || source > 255 || addr.ec != std::errc() || addr.ptr != end) return std::nullopt; + return MessageId{static_cast(source), address}; } + static MessageId fromString(const std::string &s) { return parse(s).value_or(MessageId{}); } 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); } diff --git a/openpilot/tools/cabana/core/settings.h b/openpilot/tools/cabana/core/settings.h index cad152de92..6032ee5665 100644 --- a/openpilot/tools/cabana/core/settings.h +++ b/openpilot/tools/cabana/core/settings.h @@ -5,22 +5,23 @@ constexpr int LIGHT_THEME = 1; constexpr int DARK_THEME = 2; +constexpr int STREAM_UPDATE_FPS = 30; // rate the streams publish message updates to the UI 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 theme = LIGHT_THEME; int sparkline_range = 15; bool multiple_lines_hex = false; bool log_livestream = true; bool suppress_defined_signals = false; + bool crop_video = true; std::string log_path; std::string last_dir; std::string last_route_dir; diff --git a/openpilot/tools/cabana/dbc/dbcmanager.cc b/openpilot/tools/cabana/dbc/dbcmanager.cc index 551619ea82..5170ab74d0 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.cc +++ b/openpilot/tools/cabana/dbc/dbcmanager.cc @@ -141,8 +141,15 @@ std::vector DBCManager::signalNames() { } int DBCManager::nonEmptyDBCCount() { - auto files = allDBCFiles(); - return std::count_if(files.cbegin(), files.cend(), [](auto &f) { return !f->isEmpty(); }); + return nonEmptyDBCFiles().size(); +} + +std::vector DBCManager::nonEmptyDBCFiles() { + std::vector files; + for (auto f : allDBCFiles()) { + if (!f->isEmpty()) files.push_back(f); + } + return files; } DBCFile *DBCManager::findDBCFile(const uint8_t source) { diff --git a/openpilot/tools/cabana/dbc/dbcmanager.h b/openpilot/tools/cabana/dbc/dbcmanager.h index 6bc86a8167..a729986f4a 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.h +++ b/openpilot/tools/cabana/dbc/dbcmanager.h @@ -39,6 +39,7 @@ public: std::vector signalNames(); inline int dbcCount() { return allDBCFiles().size(); } int nonEmptyDBCCount(); + std::vector nonEmptyDBCFiles(); const SourceSet sources(const DBCFile *dbc_file) const; DBCFile *findDBCFile(const uint8_t source); diff --git a/openpilot/tools/cabana/deqt.md b/openpilot/tools/cabana/deqt.md deleted file mode 100644 index 3bef2d7ab0..0000000000 --- a/openpilot/tools/cabana/deqt.md +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index d0cccb5992..0000000000 --- a/openpilot/tools/cabana/detailwidget.cc +++ /dev/null @@ -1,323 +0,0 @@ -#include "tools/cabana/detailwidget.h" - -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/mainwin.h" - -// DetailWidget - -DetailWidget::DetailWidget(ChartsWidget *charts, QWidget *parent) : charts(charts), QWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - - // tabbar - tabbar = new TabBar(this); - tabbar->setUsesScrollButtons(true); - tabbar->setAutoHide(true); - tabbar->setContextMenuPolicy(Qt::CustomContextMenu); - main_layout->addWidget(tabbar); - - createToolBar(); - - // warning - warning_widget = new QWidget(this); - QHBoxLayout *warning_hlayout = new QHBoxLayout(warning_widget); - warning_hlayout->addWidget(warning_icon = new QLabel(this), 0, Qt::AlignTop); - warning_hlayout->addWidget(warning_label = new QLabel(this), 1, Qt::AlignLeft); - warning_widget->hide(); - main_layout->addWidget(warning_widget); - - // msg widget - splitter = new QSplitter(Qt::Vertical, this); - splitter->addWidget(binary_view = new BinaryView(this)); - splitter->addWidget(signal_view = new SignalView(charts, this)); - binary_view->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); - signal_view->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); - splitter->setStretchFactor(0, 0); - splitter->setStretchFactor(1, 1); - - tab_widget = new QTabWidget(this); - tab_widget->setStyleSheet("QTabWidget::pane {border: none; margin-bottom: -2px;}"); - tab_widget->setTabPosition(QTabWidget::South); - tab_widget->addTab(splitter, utils::icon("file-earmark-ruled"), "&Msg"); - tab_widget->addTab(history_log = new LogsWidget(this), utils::icon("stopwatch"), "&Logs"); - main_layout->addWidget(tab_widget); - - QObject::connect(binary_view, &BinaryView::signalHovered, signal_view, &SignalView::signalHovered); - QObject::connect(binary_view, &BinaryView::signalClicked, [this](const cabana::Signal *s) { signal_view->selectSignal(s, true); }); - QObject::connect(binary_view, &BinaryView::editSignal, signal_view->model, &SignalModel::saveSignal); - QObject::connect(binary_view, &BinaryView::showChart, charts, &ChartsWidget::showChart); - QObject::connect(signal_view, &SignalView::showChart, charts, &ChartsWidget::showChart); - QObject::connect(signal_view, &SignalView::highlight, binary_view, &BinaryView::highlight); - QObject::connect(tab_widget, &QTabWidget::currentChanged, [this]() { updateState(); }); - connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); - connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); - QObject::connect(tabbar, &QTabBar::customContextMenuRequested, this, &DetailWidget::showTabBarContextMenu); - QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) { - if (index != -1) { - setMessage(tabbar->tabData(index).value()); - } - }); - QObject::connect(tabbar, &QTabBar::tabCloseRequested, tabbar, &QTabBar::removeTab); - QObject::connect(charts, &ChartsWidget::seriesChanged, signal_view, &SignalView::updateChartState); -} - -void DetailWidget::createToolBar() { - QToolBar *toolbar = new QToolBar(this); - int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize); - toolbar->setIconSize({icon_size, icon_size}); - toolbar->addWidget(name_label = new ElidedLabel(this)); - name_label->setStyleSheet("QLabel{font-weight:bold;}"); - - QWidget *spacer = new QWidget(); - spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - toolbar->addWidget(spacer); - -// Heatmap label and radio buttons - toolbar->addWidget(new QLabel(tr("Heatmap:"), this)); - auto *heatmap_live = new QRadioButton(tr("Live"), this); - auto *heatmap_all = new QRadioButton(tr("All"), this); - heatmap_live->setChecked(true); - - toolbar->addWidget(heatmap_live); - toolbar->addWidget(heatmap_all); - - // Edit and remove buttons - toolbar->addSeparator(); - toolbar->addAction(utils::icon("pencil"), tr("Edit Message"), this, &DetailWidget::editMsg); - action_remove_msg = toolbar->addAction(utils::icon("x-lg"), tr("Remove Message"), this, &DetailWidget::removeMsg); - - layout()->addWidget(toolbar); - - connect(heatmap_live, &QAbstractButton::toggled, this, [this](bool on) { binary_view->setHeatmapLiveMode(on); }); - connections_.push_back(can->timeRangeChanged.connect([=](const std::optional> &range) { - auto text = range ? QString("%1 - %2").arg(range->first, 0, 'f', 3).arg(range->second, 0, 'f', 3) : "All"; - heatmap_all->setText(text); - (range ? heatmap_all : heatmap_live)->setChecked(true); - })); -} - -void DetailWidget::showTabBarContextMenu(const QPoint &pt) { - int index = tabbar->tabAt(pt); - if (index >= 0) { - QMenu menu(this); - menu.addAction(tr("Close Other Tabs")); - if (menu.exec(tabbar->mapToGlobal(pt))) { - tabbar->moveTab(index, 0); - tabbar->setCurrentIndex(0); - while (tabbar->count() > 1) { - tabbar->removeTab(1); - } - } - } -} - -int DetailWidget::findOrAddTab(const MessageId& message_id) { - int index = tabbar->count() - 1; - for (/**/; index >= 0; --index) { - if (tabbar->tabData(index).value() == message_id) break; - } - if (index == -1) { - index = tabbar->addTab(QString::fromStdString(message_id.toString())); - tabbar->setTabData(index, QVariant::fromValue(message_id)); - tabbar->setTabToolTip(index, QString::fromStdString(msgName(message_id))); - } - return index; -} - -void DetailWidget::setMessage(const MessageId &message_id) { - if (std::exchange(msg_id, message_id) == message_id) return; - - tabbar->blockSignals(true); - int index = findOrAddTab(message_id); - tabbar->setCurrentIndex(index); - tabbar->blockSignals(false); - - setUpdatesEnabled(false); - signal_view->setMessage(msg_id); - binary_view->setMessage(msg_id); - history_log->setMessage(msg_id); - refresh(); - setUpdatesEnabled(true); -} - -std::pair DetailWidget::serializeMessageIds() const { - QStringList msgs; - for (int i = 0; i < tabbar->count(); ++i) { - MessageId id = tabbar->tabData(i).value(); - msgs.append(QString::fromStdString(id.toString())); - } - return std::make_pair(QString::fromStdString(msg_id.toString()), msgs); -} - -void DetailWidget::restoreTabs(const QString active_msg_id, const QStringList& msg_ids) { - tabbar->blockSignals(true); - for (const auto& str_id : msg_ids) { - MessageId id = MessageId::fromString(str_id.toStdString()); - if (dbc()->msg(id) != nullptr) - findOrAddTab(id); - } - tabbar->blockSignals(false); - - auto active_id = MessageId::fromString(active_msg_id.toStdString()); - if (dbc()->msg(active_id) != nullptr) - setMessage(active_id); -} - -void DetailWidget::refresh() { - QStringList warnings; - auto msg = dbc()->msg(msg_id); - if (msg) { - if (msg_id.source == INVALID_SOURCE) { - warnings.push_back(tr("No messages received.")); - } else if (msg->size != can->lastMessage(msg_id).dat.size()) { - warnings.push_back(tr("Message size (%1) is incorrect.").arg(msg->size)); - } - for (auto s : binary_view->getOverlappingSignals()) { - warnings.push_back(tr("%1 has overlapping bits.").arg(QString::fromStdString(s->name))); - } - } - QString msg_name = msg ? QString("%1 (%2)").arg(QString::fromStdString(msg->name), QString::fromStdString(msg->transmitter)) : QString::fromStdString(msgName(msg_id)); - name_label->setText(msg_name); - name_label->setToolTip(msg_name); - action_remove_msg->setEnabled(msg != nullptr); - - if (!warnings.isEmpty()) { - warning_label->setText(warnings.join('\n')); - warning_icon->setPixmap(utils::icon(msg ? "exclamation-triangle" : "info-circle")); - } - warning_widget->setVisible(!warnings.isEmpty()); -} - -void DetailWidget::updateState(const std::set *msgs) { - if ((msgs && !msgs->count(msg_id))) - return; - - if (tab_widget->currentIndex() == 0) - binary_view->updateState(); - else - history_log->updateState(); -} - -void DetailWidget::editMsg() { - auto msg = dbc()->msg(msg_id); - 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::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::instance()->push(new RemoveMsgCommand(msg_id)); -} - -// EditMessageDialog - -EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent) - : original_name(title), msg_id(msg_id), QDialog(parent) { - setWindowTitle(tr("Edit message: %1").arg(QString::fromStdString(msg_id.toString()))); - QFormLayout *form_layout = new QFormLayout(this); - - form_layout->addRow("", error_label = new QLabel); - error_label->setVisible(false); - form_layout->addRow(tr("Name"), name_edit = new QLineEdit(title, this)); - name_edit->setValidator(new NameValidator(name_edit)); - - form_layout->addRow(tr("Size"), size_spin = new QSpinBox(this)); - size_spin->setRange(1, CAN_MAX_DATA_BYTES); - size_spin->setValue(size); - - form_layout->addRow(tr("Node"), node = new QLineEdit(this)); - node->setValidator(new NameValidator(name_edit)); - form_layout->addRow(tr("Comment"), comment_edit = new QTextEdit(this)); - form_layout->addRow(btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel)); - - if (auto msg = dbc()->msg(msg_id)) { - node->setText(QString::fromStdString(msg->transmitter)); - comment_edit->setText(QString::fromStdString(msg->comment)); - } - validateName(name_edit->text()); - setFixedWidth(parent->width() * 0.9); - connect(name_edit, &QLineEdit::textEdited, this, &EditMessageDialog::validateName); - connect(btn_box, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); -} - -void EditMessageDialog::validateName(const QString &text) { - bool valid = text.compare(QString::fromStdString(UNTITLED), Qt::CaseInsensitive) != 0; - error_label->setVisible(false); - if (!text.isEmpty() && valid && text != original_name) { - valid = dbc()->msg(msg_id.source, text.toStdString()) == nullptr; - if (!valid) { - error_label->setText(tr("Name already exists")); - error_label->setVisible(true); - } - } - btn_box->button(QDialogButtonBox::Ok)->setEnabled(valid); -} - -// CenterWidget - -CenterWidget::CenterWidget(QWidget *parent) : QWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->addWidget(welcome_widget = createWelcomeWidget()); -} - -DetailWidget* CenterWidget::ensureDetailWidget() { - if (!detail_widget) { - delete welcome_widget; - welcome_widget = nullptr; - layout()->addWidget(detail_widget = new DetailWidget(((MainWindow*)parentWidget())->charts_widget, this)); - } - return detail_widget; -} - -void CenterWidget::clear() { - delete detail_widget; - detail_widget = nullptr; - if (!welcome_widget) { - layout()->addWidget(welcome_widget = createWelcomeWidget()); - } -} - -QWidget *CenterWidget::createWelcomeWidget() { - QWidget *w = new QWidget(this); - QVBoxLayout *main_layout = new QVBoxLayout(w); - main_layout->addStretch(0); - QLabel *logo = new QLabel("CABANA"); - logo->setAlignment(Qt::AlignCenter); - logo->setStyleSheet("font-size:50px;font-weight:bold;"); - main_layout->addWidget(logo); - - auto newShortcutRow = [](const QString &title, const QString &key) { - QHBoxLayout *hlayout = new QHBoxLayout(); - auto btn = new QToolButton(); - btn->setText(key); - btn->setEnabled(false); - hlayout->addWidget(new QLabel(title), 0, Qt::AlignRight); - hlayout->addWidget(btn, 0, Qt::AlignLeft); - return hlayout; - }; - - auto lb = new QLabel(tr("<-Select a message to view details")); - lb->setAlignment(Qt::AlignHCenter); - main_layout->addWidget(lb); - main_layout->addLayout(newShortcutRow("Pause", "Space")); - main_layout->addLayout(newShortcutRow("Help", "F1")); - main_layout->addLayout(newShortcutRow("WhatsThis", "Shift+F1")); - main_layout->addStretch(0); - - w->setStyleSheet("QLabel{color:darkGray;}"); - w->setBackgroundRole(QPalette::Base); - w->setAutoFillBackground(true); - return w; -} diff --git a/openpilot/tools/cabana/detailwidget.h b/openpilot/tools/cabana/detailwidget.h deleted file mode 100644 index c003548da4..0000000000 --- a/openpilot/tools/cabana/detailwidget.h +++ /dev/null @@ -1,78 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/binaryview.h" -#include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/historylog.h" -#include "tools/cabana/signalview.h" -#include "tools/cabana/utils/elidedlabel.h" -#include "tools/cabana/utils/qtutil.h" - -class EditMessageDialog : public QDialog { -public: - EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent); - void validateName(const QString &text); - - MessageId msg_id; - QString original_name; - QDialogButtonBox *btn_box; - QLineEdit *name_edit; - QLineEdit *node; - QTextEdit *comment_edit; - QLabel *error_label; - QSpinBox *size_spin; -}; - -class DetailWidget : public QWidget { - Q_OBJECT - -public: - DetailWidget(ChartsWidget *charts, QWidget *parent); - void setMessage(const MessageId &message_id); - void refresh(); - std::pair serializeMessageIds() const; - void restoreTabs(const QString active_msg_id, const QStringList &msg_ids); - -private: - void createToolBar(); - int findOrAddTab(const MessageId& message_id); - void showTabBarContextMenu(const QPoint &pt); - void editMsg(); - void removeMsg(); - void updateState(const std::set *msgs = nullptr); - - MessageId msg_id; - QLabel *warning_icon, *warning_label; - ElidedLabel *name_label; - QWidget *warning_widget; - TabBar *tabbar; - QTabWidget *tab_widget; - QAction *action_remove_msg; - LogsWidget *history_log; - BinaryView *binary_view; - SignalView *signal_view; - ChartsWidget *charts; - QSplitter *splitter; - Connections connections_; -}; - -class CenterWidget : public QWidget { - Q_OBJECT -public: - CenterWidget(QWidget *parent); - void setMessage(const MessageId &message_id) { ensureDetailWidget()->setMessage(message_id); } - DetailWidget* getDetailWidget() { return detail_widget; } - DetailWidget* ensureDetailWidget(); - void clear(); - -private: - QWidget *createWelcomeWidget(); - DetailWidget *detail_widget = nullptr; - QWidget *welcome_widget = nullptr; -}; diff --git a/openpilot/tools/cabana/historylog.cc b/openpilot/tools/cabana/historylog.cc deleted file mode 100644 index 26c0f4168f..0000000000 --- a/openpilot/tools/cabana/historylog.cc +++ /dev/null @@ -1,251 +0,0 @@ -#include "tools/cabana/historylog.h" - -#include - -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/utils/export.h" - -HistoryLogModel::HistoryLogModel(QObject *parent) : QAbstractTableModel(parent) { - connections_.push_back(can->seekedTo.connect([this](double) { reset(); })); - connections_.push_back(dbc()->fileChanged.connect([this]() { reset(); })); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { reset(); })); -} - -QVariant HistoryLogModel::data(const QModelIndex &index, int role) const { - const auto &m = messages[index.row()]; - const int col = index.column(); - if (role == Qt::DisplayRole) { - if (col == 0) return QString::number(can->toSeconds(m.mono_time), 'f', 3); - if (!isHexMode()) return QString::fromStdString(sigs[col - 1]->formatValue(m.sig_values[col - 1], false)); - } else if (role == Qt::TextAlignmentRole) { - return (uint32_t)(Qt::AlignRight | Qt::AlignVCenter); - } - - if (isHexMode() && col == 1) { - if (role == ColorsRole) return QVariant::fromValue((void *)(&m.colors)); - if (role == BytesRole) return QVariant::fromValue((void *)(&m.data)); - } - return {}; -} - -void HistoryLogModel::setMessage(const MessageId &message_id) { - msg_id = message_id; - reset(); -} - -void HistoryLogModel::reset() { - beginResetModel(); - sigs.clear(); - if (auto dbc_msg = dbc()->msg(msg_id)) { - sigs = dbc_msg->getSignals(); - } - messages.clear(); - hex_colors = {}; - endResetModel(); - setFilter(0, "", nullptr); -} - -QVariant HistoryLogModel::headerData(int section, Qt::Orientation orientation, int role) const { - if (orientation == Qt::Horizontal) { - if (role == Qt::DisplayRole || role == Qt::ToolTipRole) { - if (section == 0) return "Time"; - if (isHexMode()) return "Data"; - - QString name = QString::fromStdString(sigs[section - 1]->name); - QString unit = QString::fromStdString(sigs[section - 1]->unit); - 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 = toQColor(sigs[section - 1]->color); - sigColor.setAlpha(128); - return QBrush(sigColor); - } - } - return {}; -} - -void HistoryLogModel::setHexMode(bool hex) { - hex_mode = hex; - reset(); -} - -void HistoryLogModel::setFilter(int sig_idx, const QString &value, std::function cmp) { - filter_sig_idx = sig_idx; - filter_value = value.toDouble(); - filter_cmp = value.isEmpty() ? nullptr : cmp; - updateState(true); -} - -void HistoryLogModel::updateState(bool clear) { - if (clear && !messages.empty()) { - beginRemoveRows({}, 0, messages.size() - 1); - messages.clear(); - endRemoveRows(); - } - uint64_t current_time = can->toMonoTime(can->lastMessage(msg_id).ts) + 1; - fetchData(messages.begin(), current_time, messages.empty() ? 0 : messages.front().mono_time); -} - -bool HistoryLogModel::canFetchMore(const QModelIndex &parent) const { - const auto &events = can->events(msg_id); - return !events.empty() && !messages.empty() && messages.back().mono_time > events.front()->mono_time; -} - -void HistoryLogModel::fetchMore(const QModelIndex &parent) { - if (!messages.empty()) - fetchData(messages.end(), messages.back().mono_time, 0); -} - -void HistoryLogModel::fetchData(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time) { - const auto &events = can->events(msg_id); - auto first = std::upper_bound(events.rbegin(), events.rend(), from_time, [](uint64_t ts, auto e) { - return ts > e->mono_time; - }); - - std::vector msgs; - std::vector values(sigs.size()); - msgs.reserve(batch_size); - for (; first != events.rend() && (*first)->mono_time > min_time; ++first) { - const CanEvent *e = *first; - for (int i = 0; i < sigs.size(); ++i) { - sigs[i]->getValue(e->dat, e->size, &values[i]); - } - if (!filter_cmp || filter_cmp(values[filter_sig_idx], filter_value)) { - msgs.emplace_back(Message{e->mono_time, values, {e->dat, e->dat + e->size}}); - if (msgs.size() >= batch_size && min_time == 0) { - break; - } - } - } - - if (!msgs.empty()) { - if (isHexMode() && (min_time > 0 || messages.empty())) { - const auto freq = can->lastMessage(msg_id).freq; - const std::vector no_mask; - for (auto &m : msgs) { - hex_colors.compute(msg_id, m.data.data(), m.data.size(), m.mono_time / (double)1e9, can->getSpeed(), no_mask, freq); - m.colors = hex_colors.colors; - } - } - int pos = std::distance(messages.begin(), insert_pos); - beginInsertRows({}, pos , pos + msgs.size() - 1); - messages.insert(insert_pos, std::move_iterator(msgs.begin()), std::move_iterator(msgs.end())); - endInsertRows(); - } -} - -// HeaderView - -QSize HeaderView::sectionSizeFromContents(int logicalIndex) const { - static const QSize time_col_size = fontMetrics().size(Qt::TextSingleLine, "000000.000") + QSize(10, 6); - if (logicalIndex == 0) { - return time_col_size; - } else { - int default_size = qMax(100, (rect().width() - time_col_size.width()) / (model()->columnCount() - 1)); - QString text = model()->headerData(logicalIndex, this->orientation(), Qt::DisplayRole).toString(); - const QRect rect = fontMetrics().boundingRect({0, 0, default_size, 2000}, defaultAlignment(), text.replace(QChar('_'), ' ')); - QSize size = rect.size() + QSize{10, 6}; - return QSize{qMax(size.width(), default_size), size.height()}; - } -} - -void HeaderView::paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const { - auto bg_role = model()->headerData(logicalIndex, Qt::Horizontal, Qt::BackgroundRole); - if (bg_role.isValid()) { - painter->fillRect(rect, bg_role.value()); - } - QString text = model()->headerData(logicalIndex, Qt::Horizontal, Qt::DisplayRole).toString(); - painter->setPen(palette().color(utils::isDarkTheme() ? QPalette::BrightText : QPalette::Text)); - painter->drawText(rect.adjusted(5, 3, -5, -3), defaultAlignment(), text.replace(QChar('_'), ' ')); -} - -// LogsWidget - -LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) { - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - - QWidget *toolbar = new QWidget(this); - toolbar->setAutoFillBackground(true); - QHBoxLayout *h = new QHBoxLayout(toolbar); - - filters_widget = new QWidget(this); - QHBoxLayout *filter_layout = new QHBoxLayout(filters_widget); - filter_layout->setContentsMargins(0, 0, 0, 0); - filter_layout->addWidget(display_type_cb = new QComboBox(this)); - filter_layout->addWidget(signals_cb = new QComboBox(this)); - filter_layout->addWidget(comp_box = new QComboBox(this)); - filter_layout->addWidget(value_edit = new QLineEdit(this)); - h->addWidget(filters_widget); - h->addStretch(0); - export_btn = new ToolButton("filetype-csv", tr("Export to CSV file...")); - h->addWidget(export_btn, 0, Qt::AlignRight); - - display_type_cb->addItems({"Signal", "Hex"}); - display_type_cb->setToolTip(tr("Display signal value or raw hex value")); - comp_box->addItems({">", "=", "!=", "<"}); - value_edit->setClearButtonEnabled(true); - value_edit->setValidator(new DoubleValidator(this)); - - main_layout->addWidget(toolbar); - QFrame *line = new QFrame(this); - line->setFrameStyle(QFrame::HLine | QFrame::Sunken); - main_layout->addWidget(line); - main_layout->addWidget(logs = new QTableView(this)); - logs->setModel(model = new HistoryLogModel(this)); - logs->setItemDelegate(delegate = new MessageBytesDelegate(this)); - logs->setHorizontalHeader(new HeaderView(Qt::Horizontal, this)); - logs->horizontalHeader()->setDefaultAlignment(Qt::AlignRight | (Qt::Alignment)Qt::TextWordWrap); - logs->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); - logs->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); - logs->verticalHeader()->setDefaultSectionSize(delegate->sizeForBytes(8).height()); - logs->setFrameShape(QFrame::NoFrame); - - QObject::connect(display_type_cb, qOverload(&QComboBox::activated), model, &HistoryLogModel::setHexMode); - QObject::connect(signals_cb, SIGNAL(activated(int)), this, SLOT(filterChanged())); - QObject::connect(comp_box, SIGNAL(activated(int)), this, SLOT(filterChanged())); - QObject::connect(value_edit, &QLineEdit::textEdited, this, &LogsWidget::filterChanged); - QObject::connect(export_btn, &QToolButton::clicked, this, &LogsWidget::exportToCSV); - QObject::connect(model, &HistoryLogModel::modelReset, this, &LogsWidget::modelReset); - QObject::connect(model, &HistoryLogModel::rowsInserted, [this]() { export_btn->setEnabled(true); }); -} - -void LogsWidget::modelReset() { - signals_cb->clear(); - for (auto s : model->sigs) { - signals_cb->addItem(QString::fromStdString(s->name)); - } - export_btn->setEnabled(false); - value_edit->clear(); - comp_box->setCurrentIndex(0); - filters_widget->setVisible(!model->sigs.empty()); -} - -void LogsWidget::filterChanged() { - if (value_edit->text().isEmpty() && !value_edit->isModified()) return; - - std::function cmp = nullptr; - switch (comp_box->currentIndex()) { - case 0: cmp = std::greater{}; break; - case 1: cmp = std::equal_to{}; break; - case 2: cmp = [](double l, double r) { return l != r; }; break; // not equal - case 3: cmp = std::less{}; break; - } - model->setFilter(signals_cb->currentIndex(), value_edit->text(), cmp); -} - -void LogsWidget::exportToCSV() { - 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.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 deleted file mode 100644 index 1b75e3b8a9..0000000000 --- a/openpilot/tools/cabana/historylog.h +++ /dev/null @@ -1,83 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/utils/qtutil.h" - -class HeaderView : public QHeaderView { -public: - HeaderView(Qt::Orientation orientation, QWidget *parent = nullptr) : QHeaderView(orientation, parent) {} - QSize sectionSizeFromContents(int logicalIndex) const override; - void paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const; -}; - -class HistoryLogModel : public QAbstractTableModel { - Q_OBJECT - -public: - HistoryLogModel(QObject *parent); - void setMessage(const MessageId &message_id); - void updateState(bool clear = false); - void setFilter(int sig_idx, const QString &value, std::function cmp); - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - void fetchMore(const QModelIndex &parent) override; - bool canFetchMore(const QModelIndex &parent) const override; - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return messages.size(); } - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return !isHexMode() ? sigs.size() + 1 : 2; } - inline bool isHexMode() const { return sigs.empty() || hex_mode; } - void reset(); - void setHexMode(bool hex_mode); - - struct Message { - uint64_t mono_time = 0; - std::vector sig_values; - std::vector data; - std::vector colors; - }; - - void fetchData(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time); - - MessageId msg_id; - CanData hex_colors; - const int batch_size = 50; - int filter_sig_idx = -1; - double filter_value = 0; - std::function filter_cmp = nullptr; - std::deque messages; - std::vector sigs; - bool hex_mode = false; - Connections connections_; -}; - -class LogsWidget : public QFrame { - Q_OBJECT - -public: - LogsWidget(QWidget *parent); - void setMessage(const MessageId &message_id) { model->setMessage(message_id); } - void updateState() { model->updateState(); } - void showEvent(QShowEvent *event) override { model->updateState(true); } - -private slots: - void filterChanged(); - void exportToCSV(); - void modelReset(); - -private: - QTableView *logs; - HistoryLogModel *model; - QComboBox *signals_cb, *comp_box, *display_type_cb; - QLineEdit *value_edit; - QWidget *filters_widget; - ToolButton *export_btn; - MessageBytesDelegate *delegate; -}; diff --git a/openpilot/tools/cabana/mainwin.cc b/openpilot/tools/cabana/mainwin.cc deleted file mode 100644 index 2c19df530c..0000000000 --- a/openpilot/tools/cabana/mainwin.cc +++ /dev/null @@ -1,752 +0,0 @@ -#include "tools/cabana/mainwin.h" - -#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/settingsdialog.h" -#include "tools/cabana/streamselector.h" -#include "tools/cabana/tools/findsignal.h" -#include "tools/cabana/utils/export.h" -#include "tools/cabana/utils/qtutil.h" -#include "tools/replay/py_downloader.h" -#include "tools/replay/util.h" - -MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainWindow() { - loadFingerprints(); - createDockWindows(); - setCentralWidget(center_widget = new CenterWidget(this)); - createActions(); - createStatusBar(); - createShortcuts(); - - // save default window state to allow resetting it - default_state = utils::toBytes(saveState()); - - // restore states; restoreGeometry() itself corrects stale off-screen geometry - restoreGeometry(utils::qbytes(settings.geometry)); - restoreState(utils::qbytes(settings.window_state)); - - // download handlers are called from download threads - static auto static_main_win = this; - installDownloadProgressHandler([](uint64_t cur, uint64_t total, bool success) { - utils::runOnMainThread([=]() { static_main_win->updateDownloadProgress(cur, total, success); }); - }); - installMessageHandler([](ReplyMsgType type, const std::string msg) { - utils::runOnMainThread([=]() { static_main_win->statusBar()->showMessage(QString::fromStdString(msg), 2000); }); - }); - - setStyleSheet(QString(R"(QMainWindow::separator { - width: %1px; /* when vertical */ - height: %1px; /* when horizontal */ - })").arg(style()->pixelMetric(QStyle::PM_SplitterWidth))); - - connections_.push_back(dbc()->fileChanged.connect([this]() { DBCFileChanged(); })); - connections_.push_back(UndoStack::instance()->cleanChanged.connect([this](bool clean) { undoStackCleanChanged(clean); })); - connections_.push_back(settings.changed.connect([this]() { updateStatus(); })); - - // temporary pump for the non-Qt main thread queue until imgui owns the loop - auto *queue_timer = new QTimer(this); - QObject::connect(queue_timer, &QTimer::timeout, utils::drainMainThreadQueue); - queue_timer->start(10); - - QTimer::singleShot(0, this, [=]() { stream ? openStream(stream, dbc_file) : selectAndOpenStream(); }); - show(); -} - -void MainWindow::loadFingerprints() { - 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()); - } - } -} - -void MainWindow::createActions() { - // File menu - QMenu *file_menu = menuBar()->addMenu(tr("&File")); - file_menu->addAction(tr("Open Stream..."), this, &MainWindow::selectAndOpenStream); - close_stream_act = file_menu->addAction(tr("Close stream"), this, &MainWindow::closeStream); - export_to_csv_act = file_menu->addAction(tr("Export to CSV..."), this, &MainWindow::exportToCSV); - close_stream_act->setEnabled(false); - export_to_csv_act->setEnabled(false); - file_menu->addSeparator(); - - file_menu->addAction(tr("New DBC File"), [this]() { newFile(); }, QKeySequence::New); - file_menu->addAction(tr("Open DBC File..."), [this]() { openFile(); }, QKeySequence::Open); - - manage_dbcs_menu = file_menu->addMenu(tr("Manage &DBC Files")); - QObject::connect(manage_dbcs_menu, &QMenu::aboutToShow, this, &MainWindow::updateLoadSaveMenus); - - open_recent_menu = file_menu->addMenu(tr("Open &Recent")); - QObject::connect(open_recent_menu, &QMenu::aboutToShow, this, &MainWindow::updateRecentFileMenu); - - file_menu->addSeparator(); - QMenu *load_opendbc_menu = file_menu->addMenu(tr("Load DBC from commaai/opendbc")); - // load_opendbc_menu->setStyleSheet("QMenu { menu-scrollable: true; }"); - 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(); }); - - file_menu->addSeparator(); - save_dbc = file_menu->addAction(tr("Save DBC..."), this, &MainWindow::save, QKeySequence::Save); - save_dbc_as = file_menu->addAction(tr("Save DBC As..."), this, &MainWindow::saveAs, QKeySequence::SaveAs); - copy_dbc_to_clipboard = file_menu->addAction(tr("Copy DBC To Clipboard"), this, &MainWindow::saveToClipboard); - - file_menu->addSeparator(); - file_menu->addAction(tr("Settings..."), this, &MainWindow::setOption, QKeySequence::Preferences); - - file_menu->addSeparator(); - file_menu->addAction(tr("E&xit"), qApp, &QApplication::closeAllWindows, QKeySequence::Quit); - - // Edit Menu - QMenu *edit_menu = menuBar()->addMenu(tr("&Edit")); - undo_act = edit_menu->addAction(tr("&Undo"), []() { UndoStack::instance()->undo(); }); - undo_act->setShortcuts(QKeySequence::Undo); - redo_act = edit_menu->addAction(tr("&Redo"), []() { UndoStack::instance()->redo(); }); - redo_act->setShortcuts(QKeySequence::Redo); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { updateUndoRedoActions(); })); - updateUndoRedoActions(); - - // View Menu - QMenu *view_menu = menuBar()->addMenu(tr("&View")); - auto act = view_menu->addAction(tr("Full Screen"), this, &MainWindow::toggleFullScreen, QKeySequence::FullScreen); - addAction(act); - view_menu->addSeparator(); - view_menu->addAction(messages_dock->toggleViewAction()); - view_menu->addAction(video_dock->toggleViewAction()); - view_menu->addSeparator(); - view_menu->addAction(tr("Reset Window Layout"), [this]() { restoreState(utils::qbytes(default_state)); }); - - // Tools Menu - tools_menu = menuBar()->addMenu(tr("&Tools")); - tools_menu->addAction(tr("Find &Similar Bits"), this, &MainWindow::findSimilarBits); - tools_menu->addAction(tr("&Find Signal"), this, &MainWindow::findSignal); - - // Help Menu - QMenu *help_menu = menuBar()->addMenu(tr("&Help")); - help_menu->addAction(tr("Help"), this, &MainWindow::onlineHelp, QKeySequence::HelpContents); - help_menu->addAction(tr("About &Qt"), qApp, &QApplication::aboutQt); -} - -void MainWindow::createDockWindows() { - messages_dock = new QDockWidget(tr("MESSAGES"), this); - messages_dock->setObjectName("MessagesPanel"); - messages_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::TopDockWidgetArea | Qt::BottomDockWidgetArea); - messages_dock->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); - addDockWidget(Qt::LeftDockWidgetArea, messages_dock); - - video_dock = new QDockWidget("", this); - video_dock->setObjectName(tr("VideoPanel")); - video_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); - video_dock->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); - addDockWidget(Qt::RightDockWidgetArea, video_dock); -} - -void MainWindow::createDockWidgets() { - messages_widget = new MessagesWidget(this); - messages_dock->setWidget(messages_widget); - QObject::connect(messages_widget, &MessagesWidget::titleChanged, messages_dock, &QDockWidget::setWindowTitle); - QObject::connect(messages_widget, &MessagesWidget::msgSelectionChanged, center_widget, &CenterWidget::setMessage); - - // right panel - charts_widget = new ChartsWidget(this); - QWidget *charts_container = new QWidget(this); - charts_layout = new QVBoxLayout(charts_container); - charts_layout->setContentsMargins(0, 0, 0, 0); - charts_layout->addWidget(charts_widget); - - // splitter between video and charts - video_splitter = new QSplitter(Qt::Vertical, this); - video_widget = new VideoWidget(this); - video_splitter->addWidget(video_widget); - - video_splitter->addWidget(charts_container); - video_splitter->setStretchFactor(1, 1); - 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); - QObject::connect(charts_widget, &ChartsWidget::showTip, video_widget, &VideoWidget::showThumbnail); -} - -void MainWindow::createStatusBar() { - progress_bar = new QProgressBar(); - progress_bar->setRange(0, 100); - progress_bar->setTextVisible(true); - progress_bar->setFixedSize({300, 16}); - progress_bar->setVisible(false); - statusBar()->addWidget(new QLabel(tr("For Help, Press F1"))); - statusBar()->addPermanentWidget(progress_bar); - statusBar()->addPermanentWidget(status_label = new QLabel(this)); - updateStatus(); -} - -void MainWindow::createShortcuts() { - auto shortcut = new QShortcut(QKeySequence(Qt::Key_Space), this, nullptr, nullptr, Qt::ApplicationShortcut); - QObject::connect(shortcut, &QShortcut::activated, this, []() { - if (can) can->pause(!can->isPaused()); - }); - // TODO: add more shortcuts here. -} - -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(); - - // Update file menu - int cnt = dbc()->nonEmptyDBCCount(); - save_dbc->setText(cnt > 1 ? tr("Save %1 DBCs...").arg(cnt) : tr("Save DBC...")); - save_dbc->setEnabled(cnt > 0); - save_dbc_as->setEnabled(cnt == 1); - // TODO: Support clipboard for multiple files - copy_dbc_to_clipboard->setEnabled(cnt == 1); - manage_dbcs_menu->setEnabled(dynamic_cast(can) == nullptr); - - QStringList title; - for (auto f : dbc()->allDBCFiles()) { - title.push_back(tr("(%1) %2").arg(QString::fromStdString(toString(dbc()->sources(f))), QString::fromStdString(f->name()))); - } - setWindowFilePath(title.join(" | ")); - - QTimer::singleShot(0, this, &::MainWindow::restoreSessionState); -} - -void MainWindow::selectAndOpenStream() { - StreamSelector dlg(this); - if (dlg.exec()) { - openStream(dlg.stream(), dlg.dbcFile()); - } else if (!can) { - openStream(new DummyStream()); - } -} - -void MainWindow::closeStream() { - openStream(new DummyStream()); - if (dbc()->nonEmptyDBCCount() > 0) { - dbc()->fileChanged(); - } - statusBar()->showMessage(tr("stream closed")); -} - -void MainWindow::exportToCSV() { - 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.toStdString()); - } -} - -void MainWindow::newFile(SourceSet s) { - closeFile(s); - dbc()->open(s, std::string(""), std::string("")); -} - -void MainWindow::openFile(SourceSet s) { - remindSaveChanges(); - QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)"); - if (!fn.isEmpty()) { - loadFile(fn, s); - } -} - -void MainWindow::loadFile(const QString &fn, SourceSet s) { - if (!fn.isEmpty()) { - closeFile(s); - - 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(QString::fromStdString(error)); - msg_box.exec(); - } - } -} - -void MainWindow::loadDBCFromOpendbc(const QString &name) { - loadFile(QString("%1/%2").arg(OPENDBC_FILE_PATH, 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); - - 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(QString::fromStdString(error)); - msg_box.exec(); - } -} - -// stream threads read the global `can` until its destructor joins them -MainWindow::~MainWindow() { - delete can; - can = nullptr; -} - -void MainWindow::openStream(AbstractStream *stream, const QString &dbc_file) { - stream_connections_.clear(); - if (wait_dlg_) wait_dlg_->deleteLater(); - wait_dlg_ = nullptr; - delete can; - can = nullptr; - startStream(stream, dbc_file); -} - -void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { - center_widget->clear(); - delete messages_widget; - delete video_splitter; - - can = stream; // take ownership - stream_connections_.push_back(can->error.connect([this](const std::string &msg) { - QMessageBox::warning(this, tr("Error"), QString::fromStdString(msg)); - })); - can->start(); - - loadFile(dbc_file); - statusBar()->showMessage(tr("Stream [%1] started").arg(QString::fromStdString(can->routeName())), 2000); - - bool has_stream = dynamic_cast(can) == nullptr; - close_stream_act->setEnabled(has_stream); - export_to_csv_act->setEnabled(has_stream); - tools_menu->setEnabled(has_stream); - createDockWidgets(); - - video_dock->setWindowTitle(QString::fromStdString(can->routeName())); - if (can->liveStreaming() || video_splitter->sizes()[0] == 0) { - // display video at minimum size. - video_splitter->setSizes({1, 1}); - } - // Don't overwrite already loaded DBC - if (!dbc()->nonEmptyDBCCount()) { - newFile(); - } - - stream_connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { eventsMerged(); })); - - if (has_stream) { - wait_dlg_ = new QProgressDialog( - can->liveStreaming() ? tr("Waiting for the live stream to start...") : tr("Loading segment data..."), - tr("&Abort"), 0, 100, this); - wait_dlg_->setWindowModality(Qt::WindowModal); - wait_dlg_->setFixedSize(400, wait_dlg_->sizeHint().height()); - QObject::connect(wait_dlg_, &QProgressDialog::canceled, this, &MainWindow::close); - wait_dlg_connection_ = can->eventsMerged.connect([this](const MessageEventsMap &) { - wait_dlg_->deleteLater(); - wait_dlg_ = nullptr; - wait_dlg_connection_.disconnect(); - }); - } -} - -void MainWindow::eventsMerged() { - if (!can->liveStreaming() && std::exchange(car_fingerprint, QString::fromStdString(can->carFingerprint())) != car_fingerprint) { - video_dock->setWindowTitle(tr("ROUTE: %1 FINGERPRINT: %2") - .arg(QString::fromStdString(can->routeName())) - .arg(car_fingerprint.isEmpty() ? tr("Unknown Car") : car_fingerprint)); - // Don't overwrite already loaded 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"); - }); - } - } -} - -void MainWindow::save() { - // Save all open DBC files - for (auto dbc_file : dbc()->allDBCFiles()) { - if (dbc_file->isEmpty()) continue; - saveFile(dbc_file); - } -} - -void MainWindow::saveAs() { - // Save as all open DBC files. Should not be called with more than 1 file open - for (auto dbc_file : dbc()->allDBCFiles()) { - if (dbc_file->isEmpty()) continue; - saveFileAs(dbc_file); - } -} - -void MainWindow::closeFile(SourceSet s) { - remindSaveChanges(); - if (s == SOURCE_ALL) { - dbc()->closeAll(); - } else { - dbc()->close(s); - } -} - -void MainWindow::closeFile(DBCFile *dbc_file) { - assert(dbc_file != nullptr); - remindSaveChanges(); - dbc()->close(dbc_file); - // Ensure we always have at least one file open - if (dbc()->dbcCount() == 0) { - newFile(); - } -} - -void MainWindow::saveFile(DBCFile *dbc_file) { - assert(dbc_file != nullptr); - if (!dbc_file->filename.empty()) { - dbc_file->save(); - UndoStack::instance()->setClean(); - statusBar()->showMessage(tr("File saved"), 2000); - } else if (!dbc_file->isEmpty()) { - saveFileAs(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, 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(); - statusBar()->showMessage(tr("File saved as %1").arg(fn), 2000); - updateRecentFiles(fn); - } -} - -void MainWindow::saveToClipboard() { - // Copy all open DBC files to clipboard. Should not be called with more than 1 file open - for (auto dbc_file : dbc()->allDBCFiles()) { - if (dbc_file->isEmpty()) continue; - saveFileToClipboard(dbc_file); - } -} - -void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { - assert(dbc_file != nullptr); - 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() { - manage_dbcs_menu->clear(); - - for (int source : can->sources) { - if (source >= 64) continue; // Sent and blocked buses are handled implicitly - - SourceSet ss = {source, uint8_t(source + 128), uint8_t(source + 192)}; - - QMenu *bus_menu = new QMenu(this); - bus_menu->addAction(tr("New DBC File..."), [=]() { newFile(ss); }); - bus_menu->addAction(tr("Open DBC File..."), [=]() { openFile(ss); }); - bus_menu->addAction(tr("Load DBC From Clipboard..."), [=]() { loadFromClipboard(ss, false); }); - - // Show sub-menu for each dbc for this source. - auto dbc_file = dbc()->findDBCFile(source); - if (dbc_file) { - bus_menu->addSeparator(); - bus_menu->addAction(QString::fromStdString(dbc_file->name()) + " (" + QString::fromStdString(toString(dbc()->sources(dbc_file))) + ")")->setEnabled(false); - bus_menu->addAction(tr("Save..."), [=]() { saveFile(dbc_file); }); - bus_menu->addAction(tr("Save As..."), [=]() { saveFileAs(dbc_file); }); - bus_menu->addAction(tr("Copy to Clipboard..."), [=]() { saveFileToClipboard(dbc_file); }); - bus_menu->addAction(tr("Remove from this bus..."), [=]() { closeFile(ss); }); - bus_menu->addAction(tr("Remove from all buses..."), [=]() { closeFile(dbc_file); }); - } - bus_menu->setTitle(tr("Bus %1 (%2)").arg(source).arg(dbc_file ? QString::fromStdString(dbc_file->name()) : "No DBCs loaded")); - - manage_dbcs_menu->addMenu(bus_menu); - } -} - -void MainWindow::updateRecentFiles(const QString &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.pop_back(); - } - settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string(); -} - -void MainWindow::updateRecentFileMenu() { - open_recent_menu->clear(); - - int num_recent_files = std::min(settings.recent_files.size(), MAX_RECENT_FILES); - if (!num_recent_files) { - open_recent_menu->addAction(tr("No Recent Files"))->setEnabled(false); - return; - } - - for (int i = 0; i < num_recent_files; ++i) { - 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)); }); - } -} - -void MainWindow::remindSaveChanges() { - while (!UndoStack::instance()->isClean()) { - QString text = tr("You have unsaved changes. Press ok to save them, cancel to discard."); - int ret = QMessageBox::question(this, tr("Unsaved Changes"), text, QMessageBox::Ok | QMessageBox::Cancel); - if (ret != QMessageBox::Ok) break; - save(); - } - UndoStack::instance()->clear(); -} - -void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool success) { - if (wait_dlg_) wait_dlg_->setValue((int)((cur / (double)total) * 100)); - if (success && cur < total) { - progress_bar->setValue((cur / (double)total) * 100); - progress_bar->setFormat(tr("Downloading %p% (%1)").arg(formattedDataSize(total).c_str())); - progress_bar->show(); - } else { - progress_bar->hide(); - } -} - -void MainWindow::updateStatus() { - status_label->setText(tr("Cached Minutes:%1 FPS:%2").arg(settings.max_cached_minutes).arg(settings.fps)); -} - -bool MainWindow::eventFilter(QObject *obj, QEvent *event) { - if (obj == floating_window && event->type() == QEvent::Close) { - toggleChartsDocking(); - return true; - } - return QMainWindow::eventFilter(obj, event); -} - -void MainWindow::toggleChartsDocking() { - if (floating_window) { - // Dock the charts widget back to the main window - floating_window->removeEventFilter(this); - charts_layout->insertWidget(0, charts_widget, 1); - floating_window->deleteLater(); - floating_window = nullptr; - charts_widget->setIsDocked(true); - } else { - // Float the charts widget in a separate window - floating_window = new QWidget(this, Qt::Window); - floating_window->setWindowTitle("Charts"); - floating_window->setLayout(new QVBoxLayout()); - floating_window->layout()->addWidget(charts_widget); - floating_window->installEventFilter(this); - floating_window->showMaximized(); - charts_widget->setIsDocked(false); - } -} - -void MainWindow::closeEvent(QCloseEvent *event) { - remindSaveChanges(); - - installDownloadProgressHandler(nullptr); - installMessageHandler(nullptr); - - if (floating_window) - floating_window->deleteLater(); - - // save states - settings.geometry = utils::toBytes(saveGeometry()); - settings.window_state = utils::toBytes(saveState()); - if (can && !can->liveStreaming()) { - 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); -} - -void MainWindow::setOption() { - SettingsDialog dlg(this); - dlg.exec(); -} - -void MainWindow::findSimilarBits() { - FindSimilarBitsDlg *dlg = new FindSimilarBitsDlg(this); - QObject::connect(dlg, &FindSimilarBitsDlg::openMessage, messages_widget, &MessagesWidget::selectMessage); - dlg->show(); -} - -void MainWindow::findSignal() { - FindSignalDlg *dlg = new FindSignalDlg(this); - QObject::connect(dlg, &FindSignalDlg::openMessage, messages_widget, &MessagesWidget::selectMessage); - dlg->show(); -} - -void MainWindow::onlineHelp() { - if (auto help = findChild()) { - help->close(); - } else { - help = new HelpOverlay(this); - help->setGeometry(rect()); - help->show(); - help->raise(); - } -} - -void MainWindow::toggleFullScreen() { - if (isFullScreen()) { - menuBar()->show(); - statusBar()->show(); - showNormal(); - showMaximized(); - } else { - menuBar()->hide(); - statusBar()->hide(); - showFullScreen(); - } -} - -void MainWindow::saveSessionState() { - settings.recent_dbc_file = ""; - settings.active_msg_id = ""; - settings.selected_msg_ids.clear(); - settings.active_charts.clear(); - - for (auto &f : dbc()->allDBCFiles()) - 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.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()); - } -} - -void MainWindow::restoreSessionState() { - 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.toStdString() != settings.recent_dbc_file) return; - - 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()) { - QStringList ids; - for (const auto &id : settings.active_charts) ids.push_back(QString::fromStdString(id)); - charts_widget->restoreChartsFromIds(ids); - } -} - -// HelpOverlay -HelpOverlay::HelpOverlay(MainWindow *parent) : QWidget(parent) { - setAttribute(Qt::WA_NoSystemBackground, true); - setAttribute(Qt::WA_TranslucentBackground, true); - setAttribute(Qt::WA_DeleteOnClose); - parent->installEventFilter(this); -} - -void HelpOverlay::paintEvent(QPaintEvent *event) { - QPainter painter(this); - painter.fillRect(rect(), QColor(0, 0, 0, 50)); - auto parent = parentWidget(); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); -} - -void HelpOverlay::drawHelpForWidget(QPainter &painter, QWidget *w) { - if (w && w->isVisible() && !w->whatsThis().isEmpty()) { - QPoint pt = mapFromGlobal(w->mapToGlobal(w->rect().center())); - if (rect().contains(pt)) { - QTextDocument document; - document.setHtml(w->whatsThis()); - QSize doc_size = document.size().toSize(); - QPoint topleft = {pt.x() - doc_size.width() / 2, pt.y() - doc_size.height() / 2}; - painter.translate(topleft); - painter.fillRect(QRect{{0, 0}, doc_size}, palette().toolTipBase()); - document.drawContents(&painter); - painter.translate(-topleft); - } - } -} - -bool HelpOverlay::eventFilter(QObject *obj, QEvent *event) { - if (obj == parentWidget() && event->type() == QEvent::Resize) { - QResizeEvent *resize_event = (QResizeEvent *)(event); - setGeometry(QRect{QPoint(0, 0), resize_event->size()}); - } - return false; -} - -void HelpOverlay::mouseReleaseEvent(QMouseEvent *event) { - close(); -} diff --git a/openpilot/tools/cabana/mainwin.h b/openpilot/tools/cabana/mainwin.h deleted file mode 100644 index a57232ceb2..0000000000 --- a/openpilot/tools/cabana/mainwin.h +++ /dev/null @@ -1,122 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/detailwidget.h" -#include "tools/cabana/messageswidget.h" -#include "tools/cabana/videowidget.h" -#include "tools/cabana/tools/findsimilarbits.h" - -class QProgressDialog; - -class MainWindow : public QMainWindow { - Q_OBJECT - -public: - MainWindow(AbstractStream *stream, const QString &dbc_file); - ~MainWindow(); - void toggleChartsDocking(); - void showStatusMessage(const QString &msg, int timeout = 0) { statusBar()->showMessage(msg, timeout); } - void loadFile(const QString &fn, SourceSet s = SOURCE_ALL); - ChartsWidget *charts_widget = nullptr; - -public slots: - void selectAndOpenStream(); - void openStream(AbstractStream *stream, const QString &dbc_file = {}); - void closeStream(); - void exportToCSV(); - - void newFile(SourceSet s = SOURCE_ALL); - void openFile(SourceSet s = SOURCE_ALL); - void loadDBCFromOpendbc(const QString &name); - void save(); - void saveAs(); - void saveToClipboard(); - -protected: - void startStream(AbstractStream *stream, QString dbc_file); - bool eventFilter(QObject *obj, QEvent *event) override; - void remindSaveChanges(); - void closeFile(SourceSet s = SOURCE_ALL); - void closeFile(DBCFile *dbc_file); - void saveFile(DBCFile *dbc_file); - void saveFileAs(DBCFile *dbc_file); - void saveFileToClipboard(DBCFile *dbc_file); - void loadFingerprints(); - void loadFromClipboard(SourceSet s = SOURCE_ALL, bool close_all = true); - void updateRecentFiles(const QString &fn); - void updateRecentFileMenu(); - void createActions(); - void createDockWindows(); - void createStatusBar(); - void createShortcuts(); - void closeEvent(QCloseEvent *event) override; - void DBCFileChanged(); - void updateDownloadProgress(uint64_t cur, uint64_t total, bool success); - void setOption(); - void findSimilarBits(); - void findSignal(); - void undoStackCleanChanged(bool clean); - void updateUndoRedoActions(); - void onlineHelp(); - void toggleFullScreen(); - void updateStatus(); - void updateLoadSaveMenus(); - void createDockWidgets(); - void eventsMerged(); - void saveSessionState(); - void restoreSessionState(); - - VideoWidget *video_widget = nullptr; - QDockWidget *video_dock; - QDockWidget *messages_dock; - MessagesWidget *messages_widget = nullptr; - CenterWidget *center_widget; - QWidget *floating_window = nullptr; - QVBoxLayout *charts_layout; - QProgressBar *progress_bar; - QLabel *status_label; - std::unordered_map fingerprint_to_dbc; - QSplitter *video_splitter = nullptr; - enum { MAX_RECENT_FILES = 15 }; - QMenu *open_recent_menu = nullptr; - QMenu *manage_dbcs_menu = nullptr; - QMenu *tools_menu = nullptr; - QAction *close_stream_act = nullptr; - QAction *export_to_csv_act = nullptr; - 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; - std::vector default_state; - Connections connections_; - Connections stream_connections_; - Connection wait_dlg_connection_; - QProgressDialog *wait_dlg_ = nullptr; -}; - -class HelpOverlay : public QWidget { - Q_OBJECT -public: - HelpOverlay(MainWindow *parent); - -protected: - void drawHelpForWidget(QPainter &painter, QWidget *w); - void paintEvent(QPaintEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - bool eventFilter(QObject *obj, QEvent *event) override; -}; diff --git a/openpilot/tools/cabana/messageswidget.cc b/openpilot/tools/cabana/messageswidget.cc deleted file mode 100644 index 2325bb3ae4..0000000000 --- a/openpilot/tools/cabana/messageswidget.cc +++ /dev/null @@ -1,467 +0,0 @@ -#include "tools/cabana/messageswidget.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" - -MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - // toolbar - main_layout->addWidget(createToolBar()); - // message table - main_layout->addWidget(view = new MessageView(this)); - view->setItemDelegate(delegate = new MessageBytesDelegate(view, settings.multiple_lines_hex)); - view->setModel(model = new MessageListModel(this)); - view->setHeader(header = new MessageViewHeader(this)); - view->setSortingEnabled(true); - view->sortByColumn(MessageListModel::Column::NAME, Qt::AscendingOrder); - view->setAllColumnsShowFocus(true); - view->setEditTriggers(QAbstractItemView::NoEditTriggers); - view->setItemsExpandable(false); - view->setIndentation(0); - view->setRootIsDecorated(false); - - // Must be called before setting any header parameters to avoid overriding - restoreHeaderState(settings.message_header_state); - header->setSectionsMovable(true); - header->setSectionResizeMode(MessageListModel::Column::DATA, QHeaderView::Fixed); - header->setStretchLastSection(true); - header->setContextMenuPolicy(Qt::CustomContextMenu); - - // signals/slots - QObject::connect(menu, &QMenu::aboutToShow, this, &MessagesWidget::menuAboutToShow); - QObject::connect(header, &MessageViewHeader::customContextMenuRequested, this, &MessagesWidget::headerContextMenuEvent); - QObject::connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, header, &MessageViewHeader::updateHeaderPositions); - QObject::connect(model, &MessageListModel::modelReset, [this]() { - if (current_msg_id) { - selectMessage(*current_msg_id); - } - view->updateBytesSectionSize(); - updateTitle(); - }); - QObject::connect(view->selectionModel(), &QItemSelectionModel::currentChanged, [=](const QModelIndex ¤t, const QModelIndex &previous) { - if (current.isValid() && current.row() < model->items_.size()) { - const auto &id = model->items_[current.row()].id; - if (!current_msg_id || id != *current_msg_id) { - current_msg_id = id; - emit msgSelectionChanged(*current_msg_id); - } - } - }); - - setWhatsThis(tr(R"( - Message View
- - Byte color
- ■ constant changing
- ■ increasing
- ■ decreasing
- Shortcuts
- Horizontal Scrolling:  shift+wheel  - )")); -} - -QWidget *MessagesWidget::createToolBar() { - QWidget *toolbar = new QWidget(this); - QHBoxLayout *layout = new QHBoxLayout(toolbar); - layout->setContentsMargins(0, 9, 0, 0); - layout->addWidget(suppress_add = new QPushButton("Suppress Highlighted")); - layout->addWidget(suppress_clear = new QPushButton()); - suppress_clear->setToolTip(tr("Clear suppressed")); - layout->addStretch(1); - QCheckBox *suppress_defined_signals = new QCheckBox(tr("Suppress Signals"), this); - suppress_defined_signals->setToolTip(tr("Suppress defined signals")); - suppress_defined_signals->setChecked(settings.suppress_defined_signals); - layout->addWidget(suppress_defined_signals); - - auto view_button = new ToolButton("three-dots", tr("View...")); - view_button->setMenu(menu); - view_button->setPopupMode(QToolButton::InstantPopup); - view_button->setStyleSheet("QToolButton::menu-indicator { image: none; }"); - layout->addWidget(view_button); - - QObject::connect(suppress_add, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted); - QObject::connect(suppress_clear, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted); - QObject::connect(suppress_defined_signals, &QCheckBox::stateChanged, this, [](int state) { can->suppressDefinedSignals(state); }); - - suppressHighlighted(); - return toolbar; -} - -void MessagesWidget::updateTitle() { - auto stats = std::accumulate( - model->items_.begin(), model->items_.end(), std::pair(), - [](const auto &pair, const auto &item) { - auto m = dbc()->msg(item.id); - return m ? std::make_pair(pair.first + 1, pair.second + m->sigs.size()) : pair; - }); - emit titleChanged(tr("%1 Messages (%2 DBC Messages, %3 Signals)") - .arg(model->items_.size()).arg(stats.first).arg(stats.second)); -} - -void MessagesWidget::selectMessage(const MessageId &msg_id) { - auto it = std::find_if(model->items_.cbegin(), model->items_.cend(), - [&msg_id](auto &item) { return item.id == msg_id; }); - if (it != model->items_.cend()) { - view->setCurrentIndex(model->index(std::distance(model->items_.cbegin(), it), 0)); - } -} - -void MessagesWidget::suppressHighlighted() { - int n = sender() == suppress_add ? can->suppressHighlighted() : (can->clearSuppressed(), 0); - suppress_clear->setText(n > 0 ? tr("Clear (%1)").arg(n) : tr("Clear")); - suppress_clear->setEnabled(n > 0); -} - -void MessagesWidget::headerContextMenuEvent(const QPoint &pos) { - menu->exec(header->mapToGlobal(pos)); -} - -void MessagesWidget::menuAboutToShow() { - menu->clear(); - for (int i = 0; i < header->count(); ++i) { - int logical_index = header->logicalIndex(i); - auto action = menu->addAction(model->headerData(logical_index, Qt::Horizontal).toString(), - [=](bool checked) { header->setSectionHidden(logical_index, !checked); }); - action->setCheckable(true); - action->setChecked(!header->isSectionHidden(logical_index)); - // Can't hide the name column - action->setEnabled(logical_index > 0); - } - menu->addSeparator(); - auto action = menu->addAction(tr("Multi-Line bytes"), this, &MessagesWidget::setMultiLineBytes); - action->setCheckable(true); - action->setChecked(settings.multiple_lines_hex); - - action = menu->addAction(tr("Show inactive messages"), model, &MessageListModel::showInactiveMessages); - action->setCheckable(true); - action->setChecked(model->show_inactive_messages); -} - -void MessagesWidget::setMultiLineBytes(bool multi) { - settings.multiple_lines_hex = multi; - delegate->setMultipleLines(multi); - view->updateBytesSectionSize(); - view->doItemsLayout(); -} - -// MessageListModel - -MessageListModel::MessageListModel(QObject *parent) : QAbstractTableModel(parent) { - connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool has_new_ids) { msgsReceived(msgs, has_new_ids); })); - connections_.push_back(dbc()->fileChanged.connect([this]() { dbcModified(); })); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { dbcModified(); })); -} - -QVariant MessageListModel::headerData(int section, Qt::Orientation orientation, int role) const { - if (orientation == Qt::Horizontal && role == Qt::DisplayRole) { - switch (section) { - case Column::NAME: return tr("Name"); - case Column::SOURCE: return tr("Bus"); - case Column::ADDRESS: return tr("ID"); - case Column::NODE: return tr("Node"); - case Column::FREQ: return tr("Freq"); - case Column::COUNT: return tr("Count"); - case Column::DATA: return tr("Bytes"); - } - } - return {}; -} - -QVariant MessageListModel::data(const QModelIndex &index, int role) const { - if (!index.isValid() || index.row() >= items_.size()) return {}; - - auto getFreq = [](float freq) { - if (freq > 0) { - return freq >= 0.95 ? QString::number(std::nearbyint(freq)) : QString::number(freq, 'f', 2); - } else { - return QStringLiteral("--"); - } - }; - - const static QString NA = QStringLiteral("N/A"); - const auto &item = items_[index.row()]; - if (role == Qt::DisplayRole) { - switch (index.column()) { - case Column::NAME: return item.name; - case Column::SOURCE: return item.id.source != INVALID_SOURCE ? QString::number(item.id.source) : NA; - case Column::ADDRESS: return QString::fromStdString(utils::toHexString(item.id.address)); - case Column::NODE: return item.node; - case Column::FREQ: return item.id.source != INVALID_SOURCE ? getFreq(can->lastMessage(item.id).freq) : NA; - case Column::COUNT: return item.id.source != INVALID_SOURCE ? QString::number(can->lastMessage(item.id).count) : NA; - case Column::DATA: return item.id.source != INVALID_SOURCE ? "" : NA; - } - } else if (role == ColorsRole) { - return QVariant::fromValue((void*)(&can->lastMessage(item.id).colors)); - } else if (role == BytesRole && index.column() == Column::DATA && item.id.source != INVALID_SOURCE) { - return QVariant::fromValue((void*)(&can->lastMessage(item.id).dat)); - } else if (role == Qt::ToolTipRole && index.column() == Column::NAME) { - auto msg = dbc()->msg(item.id); - auto tooltip = item.name; - if (msg && !msg->comment.empty()) tooltip += "
" + QString::fromStdString(msg->comment) + ""; - return tooltip; - } - return {}; -} - -void MessageListModel::setFilterStrings(const std::map &filters) { - filters_ = filters; - filterAndSort(); -} - -void MessageListModel::showInactiveMessages(bool show) { - show_inactive_messages = show; - filterAndSort(); -} - -void MessageListModel::dbcModified() { - dbc_messages_.clear(); - for (const auto &[_, m] : dbc()->getMessages(-1)) { - dbc_messages_.insert(MessageId{.source = INVALID_SOURCE, .address = m.address}); - } - filterAndSort(); -} - -void MessageListModel::sortItems(std::vector &items) { - auto compare = [this](const auto &l, const auto &r) { - switch (sort_column) { - case Column::NAME: return std::tie(l.name, l.id) < std::tie(r.name, r.id); - case Column::SOURCE: return std::tie(l.id.source, l.id.address) < std::tie(r.id.source, r.id.address); - case Column::ADDRESS: return std::tie(l.id.address, l.id.source) < std::tie(r.id.address, r.id.source); - case Column::NODE: return std::tie(l.node, l.id) < std::tie(r.node, r.id); - case Column::FREQ: return std::tie(can->lastMessage(l.id).freq, l.id) < std::tie(can->lastMessage(r.id).freq, r.id); - case Column::COUNT: return std::tie(can->lastMessage(l.id).count, l.id) < std::tie(can->lastMessage(r.id).count, r.id); - default: return false; // Default case to suppress compiler warning - } - }; - - if (sort_order == Qt::DescendingOrder) - std::stable_sort(items.rbegin(), items.rend(), compare); - else - std::stable_sort(items.begin(), items.end(), compare); -} - -static bool parseRange(const QString &filter, uint32_t value, int base = 10) { - // Parse out filter string into a range (e.g. "1" -> {1, 1}, "1-3" -> {1, 3}, "1-" -> {1, inf}) - unsigned int min = std::numeric_limits::min(); - unsigned int max = std::numeric_limits::max(); - auto s = filter.split('-'); - bool ok = s.size() >= 1 && s.size() <= 2; - if (ok && !s[0].isEmpty()) min = s[0].toUInt(&ok, base); - if (ok && s.size() == 1) { - max = min; - } else if (ok && s.size() == 2 && !s[1].isEmpty()) { - max = s[1].toUInt(&ok, base); - } - return ok && value >= min && value <= max; -} - -bool MessageListModel::match(const MessageListModel::Item &item) { - 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->second; - switch (it->first) { - case Column::NAME: { - match = item.name.contains(txt, Qt::CaseInsensitive); - if (!match) { - const auto m = dbc()->msg(item.id); - match = m && std::any_of(m->sigs.cbegin(), m->sigs.cend(), - [&txt](const auto &s) { return QString::fromStdString(s->name).contains(txt, Qt::CaseInsensitive); }); - } - break; - } - case Column::SOURCE: - match = parseRange(txt, item.id.source); - break; - case Column::ADDRESS: - match = QString::fromStdString(utils::toHexString(item.id.address)).contains(txt, Qt::CaseInsensitive); - match = match || parseRange(txt, item.id.address, 16); - break; - case Column::NODE: - match = item.node.contains(txt, Qt::CaseInsensitive); - break; - case Column::FREQ: - match = parseRange(txt, data.freq); - break; - case Column::COUNT: - match = parseRange(txt, data.count); - break; - case Column::DATA: - match = QString::fromStdString(utils::toHex(data.dat)).contains(txt, Qt::CaseInsensitive); - break; - } - } - return match; -} - -bool MessageListModel::filterAndSort() { - // merge CAN and DBC messages - std::vector all_messages; - all_messages.reserve(can->lastMessages().size() + dbc_messages_.size()); - auto dbc_msgs = dbc_messages_; - for (const auto &[id, m] : can->lastMessages()) { - all_messages.push_back(id); - dbc_msgs.erase(MessageId{.source = INVALID_SOURCE, .address = id.address}); - } - all_messages.insert(all_messages.end(), dbc_msgs.begin(), dbc_msgs.end()); - - // filter and sort - std::vector items; - items.reserve(all_messages.size()); - for (const auto &id : all_messages) { - if (show_inactive_messages || can->isMessageActive(id)) { - auto msg = dbc()->msg(id); - Item item = {.id = id, - .name = msg ? QString::fromStdString(msg->name) : QString::fromStdString(UNTITLED), - .node = msg ? QString::fromStdString(msg->transmitter) : QString()}; - if (match(item)) - items.emplace_back(item); - } - } - sortItems(items); - - if (items_ != items) { - beginResetModel(); - items_ = std::move(items); - endResetModel(); - return true; - } - return false; -} - -void MessageListModel::msgsReceived(const std::set *new_msgs, bool has_new_ids) { - if (has_new_ids || ((filters_.count(Column::FREQ) || filters_.count(Column::COUNT) || filters_.count(Column::DATA)) && - ++sort_threshold_ == settings.fps)) { - sort_threshold_ = 0; - if (filterAndSort()) return; - } - - // Update viewport - emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1)); -} - -void MessageListModel::sort(int column, Qt::SortOrder order) { - if (column != Column::DATA) { - sort_column = column; - sort_order = order; - filterAndSort(); - } -} - -// MessageView - -void MessageView::drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - const auto &item = ((MessageListModel*)model())->items_[index.row()]; - if (!can->isMessageActive(item.id)) { - QStyleOptionViewItem custom_option = option; - custom_option.palette.setBrush(QPalette::Text, custom_option.palette.color(QPalette::Disabled, QPalette::Text)); - auto color = QApplication::palette().color(QPalette::HighlightedText); - color.setAlpha(100); - custom_option.palette.setBrush(QPalette::HighlightedText, color); - QTreeView::drawRow(painter, custom_option, index); - } else { - QTreeView::drawRow(painter, option, index); - } - - QPen oldPen = painter->pen(); - const int gridHint = style()->styleHint(QStyle::SH_Table_GridLineColor, &option, this); - painter->setPen(QColor::fromRgba(static_cast(gridHint))); - // Draw bottom border for the row - painter->drawLine(option.rect.bottomLeft(), option.rect.bottomRight()); - // Draw vertical borders for each column - for (int i = 0; i < header()->count(); ++i) { - int sectionX = header()->sectionViewportPosition(i); - painter->drawLine(sectionX, option.rect.top(), sectionX, option.rect.bottom()); - } - painter->setPen(oldPen); -} - -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. - 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() { - auto delegate = ((MessageBytesDelegate *)itemDelegate()); - int max_bytes = 8; - if (!delegate->multipleLines()) { - for (const auto &[_, m] : can->lastMessages()) { - max_bytes = std::max(max_bytes, m.dat.size()); - } - } - setUniformRowHeights(!delegate->multipleLines()); - header()->resizeSection(MessageListModel::Column::DATA, delegate->sizeForBytes(max_bytes).width()); -} - -void MessageView::wheelEvent(QWheelEvent *event) { - if (event->modifiers() == Qt::ShiftModifier) { - QApplication::sendEvent(horizontalScrollBar(), event); - } else { - QTreeView::wheelEvent(event); - } -} - -// MessageViewHeader - -MessageViewHeader::MessageViewHeader(QWidget *parent) : QHeaderView(Qt::Horizontal, parent) { - QObject::connect(this, &QHeaderView::sectionResized, this, &MessageViewHeader::updateHeaderPositions); - QObject::connect(this, &QHeaderView::sectionMoved, this, &MessageViewHeader::updateHeaderPositions); -} - -void MessageViewHeader::updateFilters() { - std::map filters; - for (int i = 0; i < (int)editors.size(); i++) { - if (!editors[i]->text().isEmpty()) { - filters[i] = editors[i]->text(); - } - } - qobject_cast(model())->setFilterStrings(filters); -} - -void MessageViewHeader::updateHeaderPositions() { - QSize sz = QHeaderView::sizeHint(); - 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 = (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(edit, &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters); - editors.push_back(edit); - } - setViewportMargins(0, 0, 0, !editors.empty() ? editors[0]->sizeHint().height() : 0); - - QHeaderView::updateGeometries(); - updateHeaderPositions(); -} - -QSize MessageViewHeader::sizeHint() const { - QSize sz = QHeaderView::sizeHint(); - 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 deleted file mode 100644 index 7c9fc0253f..0000000000 --- a/openpilot/tools/cabana/messageswidget.h +++ /dev/null @@ -1,131 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/utils/qtutil.h" - -class MessageListModel : public QAbstractTableModel { -Q_OBJECT - -public: - enum Column { - NAME = 0, - SOURCE, - ADDRESS, - NODE, - FREQ, - COUNT, - DATA, - }; - - MessageListModel(QObject *parent); - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return Column::DATA + 1; } - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const; - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return items_.size(); } - void sort(int column, Qt::SortOrder order = Qt::AscendingOrder) override; - void setFilterStrings(const std::map &filters); - void showInactiveMessages(bool show); - void msgsReceived(const std::set *new_msgs, bool has_new_ids); - bool filterAndSort(); - void dbcModified(); - - struct Item { - MessageId id; - QString name; - QString node; - bool operator==(const Item &other) const { - return id == other.id && name == other.name && node == other.node; - } - }; - std::vector items_; - bool show_inactive_messages = true; - -private: - void sortItems(std::vector &items); - bool match(const MessageListModel::Item &id); - - std::map filters_; - std::set dbc_messages_; - int sort_column = 0; - Qt::SortOrder sort_order = Qt::AscendingOrder; - int sort_threshold_ = 0; - Connections connections_; -}; - -class MessageView : public QTreeView { - Q_OBJECT -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 wheelEvent(QWheelEvent *event) override; -}; - -class MessageViewHeader : public QHeaderView { - // https://stackoverflow.com/a/44346317 - Q_OBJECT -public: - MessageViewHeader(QWidget *parent); - void updateHeaderPositions(); - void updateGeometries() override; - QSize sizeHint() const override; - void updateFilters(); - - std::vector editors; -}; - -class MessagesWidget : public QWidget { - Q_OBJECT - -public: - MessagesWidget(QWidget *parent); - void selectMessage(const MessageId &message_id); - 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: - void msgSelectionChanged(const MessageId &message_id); - void titleChanged(const QString &title); - -protected: - QWidget *createToolBar(); - void headerContextMenuEvent(const QPoint &pos); - void menuAboutToShow(); - void setMultiLineBytes(bool multi); - void updateTitle(); - - MessageView *view; - MessageViewHeader *header; - MessageBytesDelegate *delegate; - std::optional current_msg_id; - MessageListModel *model; - QPushButton *suppress_add; - QPushButton *suppress_clear; - QMenu *menu; -}; diff --git a/openpilot/tools/cabana/routes.cc b/openpilot/tools/cabana/routes.cc index 50a1eacf8d..2d3560e176 100644 --- a/openpilot/tools/cabana/routes.cc +++ b/openpilot/tools/cabana/routes.cc @@ -6,7 +6,7 @@ #include #include "json11/json11.hpp" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" namespace routes { @@ -91,7 +91,7 @@ std::vector parseRoutes(const std::string &json, bool preserved) { void fetchDevices(DevicesCallback callback) { std::thread([callback = std::move(callback)]() { - std::string result = PyDownloader::getDevices(); + std::string result = PyTools::getDevices(); auto [success, error_code] = checkApiResponse(result); callback(success ? parseDevices(result) : std::vector{}, success, error_code); }).detach(); @@ -106,7 +106,7 @@ void fetchRoutes(const std::string &dongle_id, int period_days, RoutesCallback c } std::thread([dongle_id, start_ms, end_ms, preserved, callback = std::move(callback)]() { - std::string result = PyDownloader::getDeviceRoutes(dongle_id, start_ms, end_ms, preserved); + std::string result = PyTools::getDeviceRoutes(dongle_id, start_ms, end_ms, preserved); auto [success, error_code] = checkApiResponse(result); callback(success ? parseRoutes(result, preserved) : std::vector{}, success, error_code); }).detach(); diff --git a/openpilot/tools/cabana/routes.h b/openpilot/tools/cabana/routes.h index ef6fc8dee0..2d1cab3dea 100644 --- a/openpilot/tools/cabana/routes.h +++ b/openpilot/tools/cabana/routes.h @@ -21,7 +21,7 @@ struct RouteInfo { using DevicesCallback = std::function devices, bool success, int error_code)>; using RoutesCallback = std::function routes, bool success, int error_code)>; -// Parse a PyDownloader JSON response into (success, error_code). +// Parse a PyTools JSON response into (success, error_code). std::pair checkApiResponse(const std::string &result); int64_t nowUnixMs(); diff --git a/openpilot/tools/cabana/routesdialog.cc b/openpilot/tools/cabana/routesdialog.cc deleted file mode 100644 index 33b773edb9..0000000000 --- a/openpilot/tools/cabana/routesdialog.cc +++ /dev/null @@ -1,110 +0,0 @@ -#include "tools/cabana/routesdialog.h" - -#include -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/utils/util.h" - -// The RouteListWidget class extends QListWidget to display a custom message when empty -class RouteListWidget : public QListWidget { -public: - RouteListWidget(QWidget *parent = nullptr) : QListWidget(parent) {} - void setEmptyText(const QString &text) { - empty_text_ = text; - viewport()->update(); - } - void paintEvent(QPaintEvent *event) override { - QListWidget::paintEvent(event); - if (count() == 0) { - QPainter painter(viewport()); - painter.drawText(viewport()->rect(), Qt::AlignCenter, empty_text_); - } - } - QString empty_text_ = tr("No items"); -}; - -RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Remote routes")); - - QFormLayout *layout = new QFormLayout(this); - layout->addRow(tr("Device"), device_list_ = new QComboBox(this)); - layout->addRow(period_selector_ = new QComboBox(this)); - layout->addRow(route_list_ = new RouteListWidget(this)); - auto button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - layout->addRow(button_box); - - device_list_->addItem(tr("Loading...")); - period_selector_->addItem(tr("Last week"), 7); - period_selector_->addItem(tr("Last 2 weeks"), 14); - period_selector_->addItem(tr("Last month"), 30); - period_selector_->addItem(tr("Last 6 months"), 180); - period_selector_->addItem(tr("Preserved"), -1); - - connect(device_list_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); - connect(period_selector_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); - connect(route_list_, &QListWidget::itemDoubleClicked, this, &QDialog::accept); - connect(button_box, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - - routes::fetchDevices([this, alive = std::weak_ptr(alive_)](std::vector devices, bool success, int error_code) { - utils::runOnMainThread([this, alive, devices = std::move(devices), success, error_code]() { - if (!alive.expired()) setDeviceList(devices, success, error_code); - }); - }); -} - -void RoutesDialog::setDeviceList(const std::vector &devices, bool success, int error_code) { - if (success) { - device_list_->clear(); - for (const auto &device : devices) { - QString dongle_id = QString::fromStdString(device.dongle_id); - device_list_->addItem(dongle_id, dongle_id); - } - } else { - QMessageBox::warning(this, tr("Error"), error_code == 401 ? tr("Unauthorized. Authenticate with openpilot/tools/lib/auth.py") : tr("Network error")); - reject(); - } -} - -void RoutesDialog::fetchRoutes() { - if (device_list_->currentIndex() == -1 || device_list_->currentData().isNull()) - return; - - route_list_->clear(); - route_list_->setEmptyText(tr("Loading...")); - - int request_id = ++fetch_id_; - auto on_routes = [this, alive = std::weak_ptr(alive_), request_id](std::vector list, bool success, int) { - utils::runOnMainThread([this, alive, list = std::move(list), success, request_id]() { - if (!alive.expired() && fetch_id_ == request_id) setRouteList(list, success); - }); - }; - routes::fetchRoutes(device_list_->currentText().toStdString(), period_selector_->currentData().toInt(), std::move(on_routes)); -} - -void RoutesDialog::setRouteList(const std::vector &list, bool success) { - if (success) { - for (const auto &route : list) { - const int mins = static_cast((route.end_ms - route.start_ms) / 60000); - auto item = new QListWidgetItem(QString::fromStdString(routes::formatUnixMs(route.start_ms) + " " + std::to_string(mins) + "min")); - item->setData(Qt::UserRole, QString::fromStdString(route.name)); - route_list_->addItem(item); - } - if (route_list_->count() > 0) route_list_->setCurrentRow(0); - } else { - QMessageBox::warning(this, tr("Error"), tr("Failed to fetch routes. Check your network connection.")); - reject(); - } - route_list_->setEmptyText(tr("No items")); -} - -std::string RoutesDialog::route() { - auto current_item = route_list_->currentItem(); - return current_item ? current_item->data(Qt::UserRole).toString().toStdString() : ""; -} diff --git a/openpilot/tools/cabana/routesdialog.h b/openpilot/tools/cabana/routesdialog.h deleted file mode 100644 index 4983f1b558..0000000000 --- a/openpilot/tools/cabana/routesdialog.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include - -#include "tools/cabana/routes.h" - -class RouteListWidget; - -class RoutesDialog : public QDialog { - Q_OBJECT -public: - RoutesDialog(QWidget *parent); - std::string route(); - -protected: - void setDeviceList(const std::vector &devices, bool success, int error_code); - void setRouteList(const std::vector &list, bool success); - void fetchRoutes(); - - QComboBox *device_list_; - 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/settings.cc b/openpilot/tools/cabana/settings.cc index bacbbd5330..7f8e6473c2 100644 --- a/openpilot/tools/cabana/settings.cc +++ b/openpilot/tools/cabana/settings.cc @@ -468,7 +468,6 @@ void writeSetting(json11::Json::object &settings_json, const char *key, const st 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); op(s, "chart_height", settings.chart_height); op(s, "chart_range", settings.chart_range); @@ -480,6 +479,7 @@ void settingsOp(Store &s, SettingOperation op) { op(s, "video_splitter_state", settings.video_splitter_state); op(s, "recent_files", settings.recent_files); op(s, "message_header_state", settings.message_header_state); + op(s, "ui_state", settings.ui_state); op(s, "chart_series_type", settings.chart_series_type); op(s, "theme", settings.theme); op(s, "sparkline_range", settings.sparkline_range); @@ -488,6 +488,7 @@ void settingsOp(Store &s, SettingOperation op) { op(s, "log_path", settings.log_path); op(s, "drag_direction", (int &)settings.drag_direction); op(s, "suppress_defined_signals", settings.suppress_defined_signals); + op(s, "crop_video", settings.crop_video); op(s, "recent_dbc_file", settings.recent_dbc_file); op(s, "active_msg_id", settings.active_msg_id); op(s, "selected_msg_ids", settings.selected_msg_ids); @@ -508,7 +509,8 @@ Settings::Settings() { settingsOp(legacy_settings, [](const auto &s, const char *key, auto &value) { readLegacySetting(s, key, value); }); } } - fps = std::clamp(fps, 1, 100); + // settings written before the "Automatic" theme was dropped hold a 0 for it + if (theme != LIGHT_THEME && theme != DARK_THEME) theme = LIGHT_THEME; } // Must be called before main() returns: json11's internal statistics are constructed on first diff --git a/openpilot/tools/cabana/settings.h b/openpilot/tools/cabana/settings.h index 52353dc667..27138c0890 100644 --- a/openpilot/tools/cabana/settings.h +++ b/openpilot/tools/cabana/settings.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "tools/cabana/core/observable.h" @@ -17,6 +18,9 @@ public: std::vector window_state; std::vector message_header_state; + // UI layout state (dock layout, window geometry, table state), owned by the imgui frontend + std::string ui_state; + Observable<> changed; }; diff --git a/openpilot/tools/cabana/settingsdialog.cc b/openpilot/tools/cabana/settingsdialog.cc deleted file mode 100644 index 5dd8b7617c..0000000000 --- a/openpilot/tools/cabana/settingsdialog.cc +++ /dev/null @@ -1,94 +0,0 @@ -#include "tools/cabana/settingsdialog.h" - -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/settings.h" -#include "tools/cabana/utils/qtutil.h" - -const int MIN_CACHE_MINIUTES = 30; -const int MAX_CACHE_MINIUTES = 120; - -SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Settings")); - QVBoxLayout *main_layout = new QVBoxLayout(this); - QGroupBox *groupbox = new QGroupBox("General"); - QFormLayout *form_layout = new QFormLayout(groupbox); - - form_layout->addRow(tr("Color Theme"), theme = new QComboBox(this)); - theme->setToolTip(tr("You may need to restart cabana after changes theme")); - theme->addItems({tr("Automatic"), tr("Light"), tr("Dark")}); - theme->setCurrentIndex(settings.theme); - - form_layout->addRow("FPS", fps = new QSpinBox(this)); - fps->setRange(10, 100); - fps->setSingleStep(10); - fps->setValue(settings.fps); - - form_layout->addRow(tr("Max Cached Minutes"), cached_minutes = new QSpinBox(this)); - cached_minutes->setRange(MIN_CACHE_MINIUTES, MAX_CACHE_MINIUTES); - cached_minutes->setSingleStep(1); - cached_minutes->setValue(settings.max_cached_minutes); - main_layout->addWidget(groupbox); - - groupbox = new QGroupBox("New Signal Settings"); - form_layout = new QFormLayout(groupbox); - form_layout->addRow(tr("Drag Direction"), drag_direction = new QComboBox(this)); - drag_direction->addItems({tr("MSB First"), tr("LSB First"), tr("Always Little Endian"), tr("Always Big Endian")}); - drag_direction->setCurrentIndex(settings.drag_direction); - main_layout->addWidget(groupbox); - - groupbox = new QGroupBox("Chart"); - form_layout = new QFormLayout(groupbox); - form_layout->addRow(tr("Chart Height"), chart_height = new QSpinBox(this)); - chart_height->setRange(100, 500); - chart_height->setSingleStep(10); - chart_height->setValue(settings.chart_height); - main_layout->addWidget(groupbox); - - log_livestream = new QGroupBox(tr("Enable live stream logging"), this); - log_livestream->setCheckable(true); - log_livestream->setChecked(settings.log_livestream); - QHBoxLayout *path_layout = new QHBoxLayout(log_livestream); - path_layout->addWidget(log_path = new QLineEdit(QString::fromStdString(settings.log_path), this)); - log_path->setReadOnly(true); - auto browse_btn = new QPushButton(tr("B&rowse...")); - path_layout->addWidget(browse_btn); - main_layout->addWidget(log_livestream); - - auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - main_layout->addWidget(buttonBox); - setFixedSize(400, sizeHint().height()); - - QObject::connect(browse_btn, &QPushButton::clicked, [this]() { - QString fn = QFileDialog::getExistingDirectory( - this, tr("Log File Location"), - QString::fromStdString(utils::homePath()), - QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); - if (!fn.isEmpty()) { - log_path->setText(fn); - } - }); - QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); - QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &SettingsDialog::save); -} - -void SettingsDialog::save() { - if (std::exchange(settings.theme, theme->currentIndex()) != settings.theme) { - // set theme before emit changed - utils::setTheme(settings.theme); - } - settings.fps = fps->value(); - settings.max_cached_minutes = cached_minutes->value(); - settings.chart_height = chart_height->value(); - settings.log_livestream = log_livestream->isChecked(); - settings.log_path = log_path->text().toStdString(); - settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex(); - settings.changed(); - QDialog::accept(); -} diff --git a/openpilot/tools/cabana/settingsdialog.h b/openpilot/tools/cabana/settingsdialog.h deleted file mode 100644 index c5c723c1cb..0000000000 --- a/openpilot/tools/cabana/settingsdialog.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -class SettingsDialog : public QDialog { -public: - SettingsDialog(QWidget *parent); - void save(); - QSpinBox *fps; - QSpinBox *cached_minutes; - QSpinBox *chart_height; - QComboBox *chart_series_type; - QComboBox *theme; - QGroupBox *log_livestream; - QLineEdit *log_path; - QComboBox *drag_direction; -}; diff --git a/openpilot/tools/cabana/signalview.cc b/openpilot/tools/cabana/signalview.cc deleted file mode 100644 index de80ce2a3f..0000000000 --- a/openpilot/tools/cabana/signalview.cc +++ /dev/null @@ -1,719 +0,0 @@ -#include "tools/cabana/signalview.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/utils/qtutil.h" - -// SignalModel - -static QString signalTypeToString(cabana::Signal::Type type) { - if (type == cabana::Signal::Type::Multiplexor) return "Multiplexor Signal"; - else if (type == cabana::Signal::Type::Multiplexed) return "Multiplexed Signal"; - else return "Normal Signal"; -} - -SignalModel::SignalModel(QObject *parent) : root(new Item), QAbstractItemModel(parent) { - connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); - connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { handleMsgChanged(id); })); - connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { handleMsgChanged(id); })); - connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); - connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); - connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { handleSignalRemoved(sig); })); -} - -void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { - Item *parent_item = new Item{.type = Item::Sig, .parent = root_item, .sig = sig, .title = QString::fromStdString(sig->name)}; - root_item->children.insert(root_item->children.begin() + pos, parent_item); - QString titles[]{"Name", "Size", "Receiver Nodes", "Little Endian", "Signed", "Offset", "Factor", "Type", - "Multiplex Value", "Extra Info", "Unit", "Comment", "Minimum Value", "Maximum Value", "Value Table"}; - for (int i = 0; i < std::size(titles); ++i) { - auto item = new Item{.type = (Item::Type)(i + Item::Name), .parent = parent_item, .sig = sig, .title = titles[i]}; - parent_item->children.push_back(item); - if (item->type == Item::ExtraInfo) { - parent_item = item; - } - } -} - -void SignalModel::setMessage(const MessageId &id) { - msg_id = id; - filter_str = ""; - refresh(); -} - -void SignalModel::setFilter(const QString &txt) { - filter_str = txt; - refresh(); -} - -void SignalModel::refresh() { - beginResetModel(); - root.reset(new SignalModel::Item); - if (auto msg = dbc()->msg(msg_id)) { - for (auto s : msg->getSignals()) { - if (filter_str.isEmpty() || QString::fromStdString(s->name).contains(filter_str, Qt::CaseInsensitive)) { - insertItem(root.get(), root->children.size(), s); - } - } - } - endResetModel(); -} - -SignalModel::Item *SignalModel::getItem(const QModelIndex &index) const { - auto item = index.isValid() ? (SignalModel::Item *)index.internalPointer() : nullptr; - return item ? item : root.get(); -} - -int SignalModel::rowCount(const QModelIndex &parent) const { - if (parent.isValid() && parent.column() > 0) return 0; - - return getItem(parent)->children.size(); -} - -Qt::ItemFlags SignalModel::flags(const QModelIndex &index) const { - if (!index.isValid()) return Qt::NoItemFlags; - - auto item = getItem(index); - Qt::ItemFlags flags = Qt::ItemIsSelectable | Qt::ItemIsEnabled; - if (index.column() == 1 && item->children.empty()) { - flags |= (item->type == Item::Endian || item->type == Item::Signed) ? Qt::ItemIsUserCheckable : Qt::ItemIsEditable; - } - if (item->type == Item::MultiplexValue && item->sig->type != cabana::Signal::Type::Multiplexed) { - flags &= ~Qt::ItemIsEnabled; - } - return flags; -} - -int SignalModel::signalRow(const cabana::Signal *sig) const { - for (int i = 0; i < root->children.size(); ++i) { - if (root->children[i]->sig == sig) return i; - } - return -1; -} - -QModelIndex SignalModel::index(int row, int column, const QModelIndex &parent) const { - if (parent.isValid() && parent.column() != 0) return {}; - - auto parent_item = getItem(parent); - if (parent_item && row < parent_item->children.size()) { - return createIndex(row, column, parent_item->children[row]); - } - return {}; -} - -QModelIndex SignalModel::parent(const QModelIndex &index) const { - if (!index.isValid()) return {}; - Item *parent_item = getItem(index)->parent; - return !parent_item || parent_item == root.get() ? QModelIndex() : createIndex(parent_item->row(), 0, parent_item); -} - -QVariant SignalModel::data(const QModelIndex &index, int role) const { - if (index.isValid()) { - const Item *item = getItem(index); - if (role == Qt::DisplayRole || role == Qt::EditRole) { - if (index.column() == 0) { - return item->type == Item::Sig ? QString::fromStdString(item->sig->name) : item->title; - } else { - switch (item->type) { - case Item::Sig: return item->sig_val; - case Item::Name: return QString::fromStdString(item->sig->name); - case Item::Size: return item->sig->size; - case Item::Node: return QString::fromStdString(item->sig->receiver_name); - case Item::SignalType: return signalTypeToString(item->sig->type); - case Item::MultiplexValue: return item->sig->multiplex_value; - case Item::Offset: return QString::fromStdString(doubleToString(item->sig->offset)); - case Item::Factor: return QString::fromStdString(doubleToString(item->sig->factor)); - case Item::Unit: return QString::fromStdString(item->sig->unit); - case Item::Comment: return QString::fromStdString(item->sig->comment); - case Item::Min: return QString::fromStdString(doubleToString(item->sig->min)); - case Item::Max: return QString::fromStdString(doubleToString(item->sig->max)); - case Item::Desc: { - QStringList val_desc; - for (auto &[val, desc] : item->sig->val_desc) { - val_desc << QString("%1 \"%2\"").arg(val).arg(QString::fromStdString(desc)); - } - return val_desc.join(" "); - } - default: break; - } - } - } else if (role == Qt::CheckStateRole && index.column() == 1) { - if (item->type == Item::Endian) return item->sig->is_little_endian ? Qt::Checked : Qt::Unchecked; - if (item->type == Item::Signed) return item->sig->is_signed ? Qt::Checked : Qt::Unchecked; - } else if (role == Qt::ToolTipRole && item->type == Item::Sig) { - return (index.column() == 0) ? QString::fromStdString(utils::signalToolTip(item->sig)) : QString(); - } - } - return {}; -} - -bool SignalModel::setData(const QModelIndex &index, const QVariant &value, int role) { - if (role != Qt::EditRole && role != Qt::CheckStateRole) return false; - - Item *item = getItem(index); - cabana::Signal s = *item->sig; - switch (item->type) { - case Item::Name: s.name = value.toString().toStdString(); break; - case Item::Size: s.size = value.toInt(); break; - case Item::Node: s.receiver_name = value.toString().trimmed().toStdString(); break; - case Item::SignalType: s.type = (cabana::Signal::Type)value.toInt(); break; - case Item::MultiplexValue: s.multiplex_value = value.toInt(); break; - case Item::Endian: s.is_little_endian = value.toBool(); break; - case Item::Signed: s.is_signed = value.toBool(); break; - case Item::Offset: s.offset = value.toDouble(); break; - case Item::Factor: s.factor = value.toDouble(); break; - case Item::Unit: s.unit = value.toString().toStdString(); break; - case Item::Comment: s.comment = value.toString().toStdString(); break; - case Item::Min: s.min = value.toDouble(); break; - case Item::Max: s.max = value.toDouble(); break; - case Item::Desc: s.val_desc = value.value(); break; - default: return false; - } - bool ret = saveSignal(item->sig, s); - emit dataChanged(index, index, {Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole}); - return ret; -} - -bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) { - auto msg = dbc()->msg(msg_id); - if (s.name != origin_s->name && msg->sig(s.name) != nullptr) { - QString text = tr("There is already a signal with the same name '%1'").arg(QString::fromStdString(s.name)); - QMessageBox::warning(nullptr, tr("Failed to save signal"), text); - return false; - } - - if (s.is_little_endian != origin_s->is_little_endian) { - s.start_bit = flipBitPos(s.start_bit); - } - UndoStack::instance()->push(new EditSignalCommand(msg_id, origin_s, s)); - return true; -} - -void SignalModel::handleMsgChanged(MessageId id) { - if (id.address == msg_id.address) { - refresh(); - } -} - -void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) { - if (id == msg_id) { - if (filter_str.isEmpty()) { - int i = dbc()->msg(msg_id)->indexOf(sig); - beginInsertRows({}, i, i); - insertItem(root.get(), i, sig); - endInsertRows(); - } else if (QString::fromStdString(sig->name).contains(filter_str, Qt::CaseInsensitive)) { - refresh(); - } - } -} - -void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { - if (int row = signalRow(sig); row != -1) { - emit dataChanged(index(row, 0), index(row, 1), {Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole}); - - if (filter_str.isEmpty()) { - // move row when the order changes. - int to = dbc()->msg(msg_id)->indexOf(sig); - if (to != row) { - beginMoveRows({}, row, row, {}, to > row ? to + 1 : to); - auto item = root->children[row]; - root->children.erase(root->children.begin() + row); - root->children.insert(root->children.begin() + to, item); - endMoveRows(); - } - } - } -} - -void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { - if (int row = signalRow(sig); row != -1) { - beginRemoveRows({}, row, row); - delete root->children[row]; - root->children.erase(root->children.begin() + row); - endRemoveRows(); - } -} - -// SignalItemDelegate - -SignalItemDelegate::SignalItemDelegate(QObject *parent) : QStyledItemDelegate(parent) { - name_validator = new NameValidator(this); - node_validator = new NodeValidator(this); - double_validator = new DoubleValidator(this); - - label_font.setPointSize(8); - minmax_font.setPixelSize(10); -} - -QSize SignalItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { - int width = option.widget->size().width() / 2; - if (index.column() == 0) { - int spacing = option.widget->style()->pixelMetric(QStyle::PM_TreeViewIndentation) + color_label_width + 8; - auto text = index.data(Qt::DisplayRole).toString(); - auto item = (SignalModel::Item *)index.internalPointer(); - if (item->type == SignalModel::Item::Sig && item->sig->type != cabana::Signal::Type::Normal) { - text += item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value); - spacing += (option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1) * 2; - } - width = std::min(option.widget->size().width() / 3.0, option.fontMetrics.horizontalAdvance(text) + spacing); - } - return {width, option.fontMetrics.height() + option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2}; -} - -void SignalItemDelegate::updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto item = (SignalModel::Item *)index.internalPointer(); - if (editor && item->type == SignalModel::Item::Sig && index.column() == 1) { - QRect geom = option.rect; - geom.setLeft(geom.right() - editor->sizeHint().width()); - editor->setGeometry(geom); - button_size = geom.size(); - return; - } - QStyledItemDelegate::updateEditorGeometry(editor, option, index); -} - -void SignalItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - const int h_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; - const int v_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin); - auto item = static_cast(index.internalPointer()); - - QRect rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin); - painter->setRenderHint(QPainter::Antialiasing); - if (option.state & QStyle::State_Selected) { - painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight)); - } - - if (index.column() == 0) { - if (item->type == SignalModel::Item::Sig) { - // color label - QPainterPath path; - QRect icon_rect{rect.x(), rect.y(), color_label_width, rect.height()}; - path.addRoundedRect(icon_rect, 3, 3); - painter->setPen(item->highlight ? Qt::white : Qt::black); - painter->setFont(label_font); - 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); - // multiplexer indicator - if (item->sig->type != cabana::Signal::Type::Normal) { - QString indicator = item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value); - QRect indicator_rect{rect.x(), rect.y(), option.fontMetrics.horizontalAdvance(indicator), rect.height()}; - painter->setBrush(Qt::gray); - painter->setPen(Qt::NoPen); - painter->drawRoundedRect(indicator_rect, 3, 3); - painter->setPen(Qt::white); - painter->drawText(indicator_rect, Qt::AlignCenter, indicator); - rect.setLeft(indicator_rect.right() + h_margin * 2); - } - } else { - rect.setLeft(option.widget->style()->pixelMetric(QStyle::PM_TreeViewIndentation) + color_label_width + h_margin * 3); - } - - // name - auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, rect.width()); - painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text)); - painter->setFont(option.font); - painter->drawText(rect, option.displayAlignment, text); - } else if (index.column() == 1) { - if (!item->sparkline.pixmap.isNull()) { - QSize sparkline_size = item->sparkline.pixmap.size() / item->sparkline.pixmap.devicePixelRatio(); - painter->drawPixmap(QRect(rect.topLeft(), sparkline_size), item->sparkline.pixmap); - // min-max value - painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text)); - rect.adjust(sparkline_size.width() + 1, 0, 0, 0); - int value_adjust = 10; - if (!item->sparkline.isEmpty() && (item->highlight || option.state & QStyle::State_Selected)) { - painter->drawLine(rect.topLeft(), rect.bottomLeft()); - rect.adjust(5, -v_margin, 0, v_margin); - painter->setFont(minmax_font); - QString min = QString::number(item->sparkline.min_val); - QString max = QString::number(item->sparkline.max_val); - painter->drawText(rect, Qt::AlignLeft | Qt::AlignTop, max); - painter->drawText(rect, Qt::AlignLeft | Qt::AlignBottom, min); - QFontMetrics fm(minmax_font); - value_adjust = std::max(fm.horizontalAdvance(min), fm.horizontalAdvance(max)) + 5; - } else if (!item->sparkline.isEmpty() && item->sig->type == cabana::Signal::Type::Multiplexed) { - // display freq of multiplexed signal - painter->setFont(label_font); - QString freq = QString("%1 hz").arg(item->sparkline.freq(), 0, 'g', 2); - painter->drawText(rect.adjusted(5, 0, 0, 0), Qt::AlignLeft | Qt::AlignVCenter, freq); - value_adjust = QFontMetrics(label_font).horizontalAdvance(freq) + 10; - } - // signal value - painter->setFont(option.font); - rect.adjust(value_adjust, 0, -button_size.width(), 0); - auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, rect.width()); - painter->drawText(rect, Qt::AlignRight | Qt::AlignVCenter, text); - } else { - QStyledItemDelegate::paint(painter, option, index); - } - } -} - -QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto item = (SignalModel::Item *)index.internalPointer(); - if (item->type == SignalModel::Item::Name || item->type == SignalModel::Item::Node || item->type == SignalModel::Item::Offset || - item->type == SignalModel::Item::Factor || item->type == SignalModel::Item::MultiplexValue || - item->type == SignalModel::Item::Min || item->type == SignalModel::Item::Max) { - QLineEdit *e = new QLineEdit(parent); - e->setFrame(false); - if (item->type == SignalModel::Item::Name) e->setValidator(name_validator); - else if (item->type == SignalModel::Item::Node) e->setValidator(node_validator); - else e->setValidator(double_validator); - - return e; - } else if (item->type == SignalModel::Item::Size) { - QSpinBox *spin = new QSpinBox(parent); - spin->setFrame(false); - spin->setRange(1, CAN_MAX_DATA_BYTES); - return spin; - } else if (item->type == SignalModel::Item::SignalType) { - QComboBox *c = new QComboBox(parent); - c->addItem(signalTypeToString(cabana::Signal::Type::Normal), (int)cabana::Signal::Type::Normal); - if (!dbc()->msg(((SignalModel *)index.model())->msg_id)->multiplexor) { - c->addItem(signalTypeToString(cabana::Signal::Type::Multiplexor), (int)cabana::Signal::Type::Multiplexor); - } else if (item->sig->type != cabana::Signal::Type::Multiplexor) { - c->addItem(signalTypeToString(cabana::Signal::Type::Multiplexed), (int)cabana::Signal::Type::Multiplexed); - } - return c; - } else if (item->type == SignalModel::Item::Desc) { - ValueDescriptionDlg dlg(item->sig->val_desc, parent); - dlg.setWindowTitle(QString::fromStdString(item->sig->name)); - if (dlg.exec()) { - ((QAbstractItemModel *)index.model())->setData(index, QVariant::fromValue(dlg.val_desc)); - } - return nullptr; - } - return QStyledItemDelegate::createEditor(parent, option, index); -} - -void SignalItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const { - auto item = (SignalModel::Item *)index.internalPointer(); - if (item->type == SignalModel::Item::SignalType) { - model->setData(index, ((QComboBox*)editor)->currentData().toInt()); - return; - } - QStyledItemDelegate::setModelData(editor, model, index); -} - -// SignalView - -SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QFrame(parent) { - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - // title bar - QWidget *title_bar = new QWidget(this); - QHBoxLayout *hl = new QHBoxLayout(title_bar); - hl->addWidget(signal_count_lb = new QLabel()); - filter_edit = new QLineEdit(this); - filter_edit->setValidator(new NonWhitespaceValidator(this)); - filter_edit->setClearButtonEnabled(true); - filter_edit->setPlaceholderText(tr("Filter Signal")); - hl->addWidget(filter_edit); - hl->addStretch(1); - - // WARNING: increasing the maximum range can result in severe performance degradation. - // 30s is a reasonable value at present. - const int max_range = 30; // 30s - settings.sparkline_range = std::clamp(settings.sparkline_range, 1, max_range); - hl->addWidget(sparkline_label = new QLabel()); - hl->addWidget(sparkline_range_slider = new QSlider(Qt::Horizontal, this)); - sparkline_range_slider->setRange(1, max_range); - sparkline_range_slider->setValue(settings.sparkline_range); - sparkline_range_slider->setToolTip(tr("Sparkline time range")); - - auto collapse_btn = new ToolButton("dash-square", tr("Collapse All")); - collapse_btn->setIconSize({12, 12}); - hl->addWidget(collapse_btn); - - // tree view - tree = new TreeView(this); - tree->setModel(model = new SignalModel(this)); - tree->setItemDelegate(delegate = new SignalItemDelegate(this)); - tree->setFrameShape(QFrame::NoFrame); - tree->setHeaderHidden(true); - tree->setMouseTracking(true); - tree->setExpandsOnDoubleClick(false); - tree->setEditTriggers(QAbstractItemView::AllEditTriggers); - tree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); - tree->header()->setStretchLastSection(true); - tree->setMinimumHeight(300); - - // Use a distinctive background for the whole row containing a QSpinBox or QLineEdit - QString nodeBgColor = palette().color(QPalette::AlternateBase).name(QColor::HexArgb); - tree->setStyleSheet(QString("QSpinBox{background-color:%1;border:none;} QLineEdit{background-color:%1;}").arg(nodeBgColor)); - - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - main_layout->addWidget(title_bar); - main_layout->addWidget(tree); - updateToolBar(); - - QObject::connect(filter_edit, &QLineEdit::textEdited, model, &SignalModel::setFilter); - QObject::connect(sparkline_range_slider, &QSlider::valueChanged, this, &SignalView::setSparklineRange); - QObject::connect(collapse_btn, &QPushButton::clicked, tree, &QTreeView::collapseAll); - QObject::connect(tree, &QAbstractItemView::clicked, this, &SignalView::rowClicked); - QObject::connect(tree, &QTreeView::viewportEntered, [this]() { emit highlight(nullptr); }); - 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); - connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); - connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); - QObject::connect(tree->verticalScrollBar(), &QScrollBar::valueChanged, [this]() { updateState(); }); - QObject::connect(tree->verticalScrollBar(), &QScrollBar::rangeChanged, [this]() { updateState(); }); - connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); - QObject::connect(tree->header(), &QHeaderView::sectionResized, [this](int logicalIndex, int oldSize, int newSize) { - if (logicalIndex == 1) { - value_column_width = newSize; - updateState(); - } - }); - - setWhatsThis(tr(R"( - Signal view
- - )")); -} - -void SignalView::setMessage(const MessageId &id) { - max_value_width = 0; - filter_edit->clear(); - model->setMessage(id); -} - -void SignalView::rowsChanged() { - for (int i = 0; i < model->rowCount(); ++i) { - auto index = model->index(i, 1); - if (!tree->indexWidget(index)) { - QWidget *w = new QWidget(this); - QHBoxLayout *h = new QHBoxLayout(w); - int v_margin = style()->pixelMetric(QStyle::PM_FocusFrameVMargin); - int h_margin = style()->pixelMetric(QStyle::PM_FocusFrameHMargin); - h->setContentsMargins(0, v_margin, -h_margin, v_margin); - h->setSpacing(style()->pixelMetric(QStyle::PM_ToolBarItemSpacing)); - - auto remove_btn = new ToolButton("x", tr("Remove signal")); - auto plot_btn = new ToolButton("graph-up", ""); - plot_btn->setCheckable(true); - h->addWidget(plot_btn); - h->addWidget(remove_btn); - - tree->setIndexWidget(index, w); - auto sig = model->getItem(index)->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); - }); - } - } - updateToolBar(); - updateChartState(); - updateState(); -} - -void SignalView::rowClicked(const QModelIndex &index) { - auto item = model->getItem(index); - if (item->type == SignalModel::Item::Sig || item->type == SignalModel::Item::ExtraInfo) { - auto expand_index = model->index(index.row(), 0, index.parent()); - tree->setExpanded(expand_index, !tree->isExpanded(expand_index)); - } -} - -void SignalView::selectSignal(const cabana::Signal *sig, bool expand) { - if (int row = model->signalRow(sig); row != -1) { - auto idx = model->index(row, 0); - if (expand) { - tree->setExpanded(idx, !tree->isExpanded(idx)); - } - tree->scrollTo(idx, QAbstractItemView::PositionAtTop); - tree->setCurrentIndex(idx); - } -} - -void SignalView::updateChartState() { - int i = 0; - for (auto item : model->root->children) { - bool chart_opened = charts->hasSignal(model->msg_id, item->sig); - auto buttons = tree->indexWidget(model->index(i, 1))->findChildren(); - if (buttons.size() > 0) { - buttons[0]->setChecked(chart_opened); - buttons[0]->setToolTip(chart_opened ? tr("Close Plot") : tr("Show Plot\nSHIFT click to add to previous opened plot")); - } - ++i; - } -} - -void SignalView::signalHovered(const cabana::Signal *sig) { - auto &children = model->root->children; - for (int i = 0; i < children.size(); ++i) { - bool highlight = children[i]->sig == sig; - if (std::exchange(children[i]->highlight, highlight) != highlight) { - emit model->dataChanged(model->index(i, 0), model->index(i, 0), {Qt::DecorationRole}); - emit model->dataChanged(model->index(i, 1), model->index(i, 1), {Qt::DisplayRole}); - } - } -} - -void SignalView::updateToolBar() { - signal_count_lb->setText(tr("Signals: %1").arg(model->rowCount())); - sparkline_label->setText(QString::fromStdString(utils::formatSeconds(settings.sparkline_range))); -} - -void SignalView::setSparklineRange(int value) { - settings.sparkline_range = value; - updateToolBar(); - updateState(); -} - -void SignalView::handleSignalAdded(MessageId id, const cabana::Signal *sig) { - if (id.address == model->msg_id.address) { - selectSignal(sig); - } -} - -void SignalView::handleSignalUpdated(const cabana::Signal *sig) { - if (int row = model->signalRow(sig); row != -1) - updateState(); -} - -std::pair SignalView::visibleSignalRange() { - auto topLevelIndex = [](QModelIndex index) { - while (index.isValid() && index.parent().isValid()) index = index.parent(); - return index; - }; - - const auto viewport_rect = tree->viewport()->rect(); - QModelIndex first_visible = tree->indexAt(viewport_rect.topLeft()); - if (first_visible.parent().isValid()) { - first_visible = topLevelIndex(first_visible); - first_visible = first_visible.siblingAtRow(first_visible.row() + 1); - } - - QModelIndex last_visible = topLevelIndex(tree->indexAt(viewport_rect.bottomRight())); - if (!last_visible.isValid()) { - last_visible = model->index(model->rowCount() - 1, 0); - } - return {first_visible, last_visible}; -} - -void SignalView::updateState(const std::set *msgs) { - const auto &last_msg = can->lastMessage(model->msg_id); - if (model->rowCount() == 0 || (msgs && !msgs->count(model->msg_id)) || last_msg.dat.size() == 0) return; - - for (auto item : model->root->children) { - double value = 0; - if (item->sig->getValue(last_msg.dat.data(), last_msg.dat.size(), &value)) { - item->sig_val = QString::fromStdString(item->sig->formatValue(value)); - max_value_width = std::max(max_value_width, fontMetrics().horizontalAdvance(item->sig_val)); - } - } - - auto [first_visible, last_visible] = visibleSignalRange(); - if (first_visible.isValid() && last_visible.isValid()) { - const static int min_max_width = QFontMetrics(delegate->minmax_font).horizontalAdvance("-000.00") + 5; - int available_width = value_column_width - delegate->button_size.width(); - int value_width = std::min(max_value_width + min_max_width, available_width / 2); - QSize size(available_width - value_width, - delegate->button_size.height() - style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2); - - auto [first, last] = can->eventsInRange(model->msg_id, std::make_pair(last_msg.ts -settings.sparkline_range, last_msg.ts)); - std::vector> futures; - for (int i = first_visible.row(); i <= last_visible.row(); ++i) { - auto item = model->getItem(model->index(i, 1)); - futures.push_back(std::async(std::launch::async, - &Sparkline::update, &item->sparkline, item->sig, first, last, settings.sparkline_range, size)); - } - for (auto &f : futures) f.get(); - } - - for (int i = 0; i < model->rowCount(); ++i) { - emit model->dataChanged(model->index(i, 1), model->index(i, 1), {Qt::DisplayRole}); - } -} - -void SignalView::resizeEvent(QResizeEvent* event) { - updateState(); - QFrame::resizeEvent(event); -} - -// ValueDescriptionDlg - -ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions, QWidget *parent) : QDialog(parent) { - QHBoxLayout *toolbar_layout = new QHBoxLayout(); - QPushButton *add = new QPushButton(utils::icon("plus"), ""); - QPushButton *remove = new QPushButton(utils::icon("dash"), ""); - remove->setEnabled(false); - toolbar_layout->addWidget(add); - toolbar_layout->addWidget(remove); - toolbar_layout->addStretch(0); - - table = new QTableWidget(descriptions.size(), 2, this); - table->setItemDelegate(new Delegate(this)); - table->setHorizontalHeaderLabels({"Value", "Description"}); - table->horizontalHeader()->setStretchLastSection(true); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed); - table->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - - int row = 0; - for (auto &[val, desc] : descriptions) { - table->setItem(row, 0, new QTableWidgetItem(QString::number(val))); - table->setItem(row, 1, new QTableWidgetItem(QString::fromStdString(desc))); - ++row; - } - - auto btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->addLayout(toolbar_layout); - main_layout->addWidget(table); - main_layout->addWidget(btn_box); - setMinimumWidth(500); - - QObject::connect(btn_box, &QDialogButtonBox::accepted, this, &ValueDescriptionDlg::save); - QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - QObject::connect(add, &QPushButton::clicked, [this]() { - table->setRowCount(table->rowCount() + 1); - table->setItem(table->rowCount() - 1, 0, new QTableWidgetItem); - table->setItem(table->rowCount() - 1, 1, new QTableWidgetItem); - }); - QObject::connect(remove, &QPushButton::clicked, [this]() { table->removeRow(table->currentRow()); }); - QObject::connect(table, &QTableWidget::itemSelectionChanged, [=]() { - remove->setEnabled(table->currentRow() != -1); - }); -} - -void ValueDescriptionDlg::save() { - for (int i = 0; i < table->rowCount(); ++i) { - QString val = table->item(i, 0)->text().trimmed(); - QString desc = table->item(i, 1)->text().trimmed(); - if (!val.isEmpty() && !desc.isEmpty()) { - val_desc.push_back({val.toDouble(), desc.toStdString()}); - } - } - QDialog::accept(); -} - -QWidget *ValueDescriptionDlg::Delegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const { - QLineEdit *edit = new QLineEdit(parent); - edit->setFrame(false); - if (index.column() == 0) { - edit->setValidator(new DoubleValidator(parent)); - } - return edit; -} diff --git a/openpilot/tools/cabana/signalview.h b/openpilot/tools/cabana/signalview.h deleted file mode 100644 index e2a99ebbd1..0000000000 --- a/openpilot/tools/cabana/signalview.h +++ /dev/null @@ -1,155 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/chart/sparkline.h" - -class SignalModel : public QAbstractItemModel { - Q_OBJECT -public: - struct Item { - enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc }; - ~Item() { for (auto c : children) delete c; } - inline int row() { - auto it = std::find(parent->children.begin(), parent->children.end(), this); - return it != parent->children.end() ? std::distance(parent->children.begin(), it) : -1; - } - - Type type = Type::Root; - Item *parent = nullptr; - std::vector children; - - const cabana::Signal *sig = nullptr; - QString title; - bool highlight = false; - QString sig_val = "-"; - Sparkline sparkline; - }; - - SignalModel(QObject *parent); - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 2; } - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override; - QModelIndex parent(const QModelIndex &index) const override; - Qt::ItemFlags flags(const QModelIndex &index) const override; - bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; - void setMessage(const MessageId &id); - void setFilter(const QString &txt); - bool saveSignal(const cabana::Signal *origin_s, cabana::Signal &s); - Item *getItem(const QModelIndex &index) const; - int signalRow(const cabana::Signal *sig) const; - -private: - void insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig); - void handleSignalAdded(MessageId id, const cabana::Signal *sig); - void handleSignalUpdated(const cabana::Signal *sig); - void handleSignalRemoved(const cabana::Signal *sig); - void handleMsgChanged(MessageId id); - void refresh(); - - MessageId msg_id; - QString filter_str; - std::unique_ptr root; - Connections connections_; - friend class SignalView; - friend class SignalItemDelegate; -}; - -class ValueDescriptionDlg : public QDialog { -public: - ValueDescriptionDlg(const ValueDescription &descriptions, QWidget *parent); - ValueDescription val_desc; - -private: - struct Delegate : public QStyledItemDelegate { - Delegate(QWidget *parent) : QStyledItemDelegate(parent) {} - QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - }; - - void save(); - QTableWidget *table; -}; - -class SignalItemDelegate : public QStyledItemDelegate { -public: - SignalItemDelegate(QObject *parent); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override; - - QValidator *name_validator, *double_validator, *node_validator; - QFont label_font, minmax_font; - const int color_label_width = 18; - mutable QSize button_size; -}; - -class SignalView : public QFrame { - Q_OBJECT - -public: - SignalView(ChartsWidget *charts, QWidget *parent); - void setMessage(const MessageId &id); - void signalHovered(const cabana::Signal *sig); - void updateChartState(); - void selectSignal(const cabana::Signal *sig, bool expand = false); - void rowClicked(const QModelIndex &index); - SignalModel *model = nullptr; - -signals: - void highlight(const cabana::Signal *sig); - void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); - -private: - void rowsChanged(); - void resizeEvent(QResizeEvent* event) override; - void updateToolBar(); - void setSparklineRange(int value); - void handleSignalAdded(MessageId id, const cabana::Signal *sig); - void handleSignalUpdated(const cabana::Signal *sig); - void updateState(const std::set *msgs = nullptr); - std::pair visibleSignalRange(); - - struct TreeView : public QTreeView { - TreeView(QWidget *parent) : QTreeView(parent) {} - void rowsInserted(const QModelIndex &parent, int start, int end) override { - ((SignalView *)parentWidget())->rowsChanged(); - // update widget geometries in QTreeView::rowsInserted - QTreeView::rowsInserted(parent, start, end); - } - void setModel(QAbstractItemModel *m) override { - QTreeView::setModel(m); - // Bypass the slow call to QTreeView::dataChanged. - 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); - QTreeView::leaveEvent(event); - } - }; - int max_value_width = 0; - int value_column_width = 0; - TreeView *tree; - QLabel *sparkline_label; - QSlider *sparkline_range_slider; - QLineEdit *filter_edit; - ChartsWidget *charts; - QLabel *signal_count_lb; - SignalItemDelegate *delegate; - Connections connections_; -}; diff --git a/openpilot/tools/cabana/streams/abstractstream.cc b/openpilot/tools/cabana/streams/abstractstream.cc index 7be579269d..1091bf95c5 100644 --- a/openpilot/tools/cabana/streams/abstractstream.cc +++ b/openpilot/tools/cabana/streams/abstractstream.cc @@ -164,7 +164,7 @@ bool AbstractStream::isMessageActive(const MessageId &id) const { return delta < 1.5; } - return delta < (5.0 / m.freq) + (1.0 / settings.fps); + return delta < (5.0 / m.freq) + (1.0 / STREAM_UPDATE_FPS); } void AbstractStream::updateLastMsgsTo(double sec) { @@ -232,6 +232,10 @@ void AbstractStream::mergeEvents(const std::vector &events) { msg_events[{.source = e->src, .address = e->address}].push_back(e); } + insertEvents(events, msg_events); +} + +void AbstractStream::insertEvents(const std::vector &events, const MessageEventsMap &msg_events) { if (!events.empty()) { for (const auto &[id, new_e] : msg_events) { if (!new_e.empty()) { diff --git a/openpilot/tools/cabana/streams/abstractstream.h b/openpilot/tools/cabana/streams/abstractstream.h index b992b086fd..45a9af92f4 100644 --- a/openpilot/tools/cabana/streams/abstractstream.h +++ b/openpilot/tools/cabana/streams/abstractstream.h @@ -42,7 +42,7 @@ public: inline double currentSec() const { return current_sec_; } inline uint64_t toMonoTime(double sec) const { return beginMonoTime() + std::max(sec, 0.0) * 1e9; } - inline double toSeconds(uint64_t mono_time) const { return std::max(0.0, (mono_time - beginMonoTime()) / 1e9); } + inline double toSeconds(uint64_t mono_time) const { return mono_time > beginMonoTime() ? (mono_time - beginMonoTime()) / 1e9 : 0.0; } inline const std::unordered_map &lastMessages() const { return last_msgs; } bool isMessageActive(const MessageId &id) const; @@ -74,6 +74,7 @@ protected: void cancelWaits(); // call before joining threads, the main thread isn't pumping events during destruction void requestUpdateLastMessages() { postToMainThread([this]() { updateLastMessages(); }); } void mergeEvents(const std::vector &events); + void insertEvents(const std::vector &events, const MessageEventsMap &msg_events); const CanEvent *newEvent(uint64_t mono_time, const cereal::CanData::Reader &c); void updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size); void waitForSeekFinshed(); diff --git a/openpilot/tools/cabana/streams/livestream.cc b/openpilot/tools/cabana/streams/livestream.cc index ec71a6794d..081e63e248 100644 --- a/openpilot/tools/cabana/streams/livestream.cc +++ b/openpilot/tools/cabana/streams/livestream.cc @@ -48,7 +48,6 @@ LiveStream::~LiveStream() { void LiveStream::start() { 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); @@ -62,7 +61,7 @@ void LiveStream::stop() { void LiveStream::updateThread() { while (!exit_) { - std::this_thread::sleep_for(std::chrono::milliseconds(1000 / fps_)); + std::this_thread::sleep_for(std::chrono::milliseconds(1000 / STREAM_UPDATE_FPS)); // coalesce: skip the request if the main thread hasn't processed the previous one yet. if (!update_pending_.exchange(true)) { requestUpdateLastMessages(); @@ -90,7 +89,6 @@ void LiveStream::handleEvent(kj::ArrayPtr data) { // called on the main thread via requestUpdateLastMessages() void LiveStream::updateLastMessages() { update_pending_ = false; - fps_ = settings.fps; { // merge events received from live stream thread. std::lock_guard lk(lock); diff --git a/openpilot/tools/cabana/streams/livestream.h b/openpilot/tools/cabana/streams/livestream.h index 22587335b0..1a17d481f1 100644 --- a/openpilot/tools/cabana/streams/livestream.h +++ b/openpilot/tools/cabana/streams/livestream.h @@ -37,7 +37,6 @@ private: std::mutex lock; std::thread stream_thread, update_thread; std::atomic update_pending_ = false; - std::atomic fps_ = 10; std::vector received_events_; std::chrono::system_clock::time_point begin_date_time; diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index 944cb10fd9..c0ab46abc9 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -19,8 +19,11 @@ ReplayStream::ReplayStream() { ReplayStream::~ReplayStream() { cancelWaits(); + if (replay) replay->stop(); } +// runs on replay's merge thread: a segment of CAN data takes ~30 ms to parse and group, which dropped +// frames when it ran on the main thread. Only the sorted insert and the merged signal need the main thread. void ReplayStream::mergeSegments() { auto event_data = replay->getEventData(); for (const auto &[n, seg] : event_data->segments) { @@ -29,16 +32,19 @@ void ReplayStream::mergeSegments() { std::vector new_events; new_events.reserve(seg->log->events.size()); + MessageEventsMap msg_events; for (const Event &e : seg->log->events) { if (e.which == cereal::Event::Which::CAN) { capnp::FlatArrayMessageReader reader(e.data); auto event = reader.getRoot(); for (const auto &c : event.getCan()) { - new_events.push_back(newEvent(e.mono_time, c)); + const CanEvent *ce = newEvent(e.mono_time, c); + new_events.push_back(ce); + msg_events[{.source = ce->src, .address = ce->address}].push_back(ce); } } } - mergeEvents(new_events); + postToMainThreadAndWait([&]() { insertEvents(new_events, msg_events); }); } } } @@ -56,7 +62,7 @@ bool ReplayStream::loadRoute(const std::string &route, const std::string &data_d waitForSeekFinshed(); }; replay->onQLogLoaded = [this](std::shared_ptr qlog) { postToMainThread([this, qlog]() { qLogLoaded(qlog); }); }; - replay->onSegmentsMerged = [this]() { postToMainThreadAndWait([this]() { mergeSegments(); }); }; + replay->onSegmentsMerged = [this]() { mergeSegments(); }; bool success = replay->load(); if (!success) { @@ -97,7 +103,7 @@ bool ReplayStream::eventFilter(const Event *event) { } double ts = millis_since_boot(); - if ((ts - prev_update_ts) > (1000.0 / settings.fps)) { + if ((ts - prev_update_ts) > (1000.0 / STREAM_UPDATE_FPS)) { requestUpdateLastMessages(); prev_update_ts = ts; } diff --git a/openpilot/tools/cabana/streamselector.cc b/openpilot/tools/cabana/streamselector.cc deleted file mode 100644 index 31532737f3..0000000000 --- a/openpilot/tools/cabana/streamselector.cc +++ /dev/null @@ -1,330 +0,0 @@ -#include "tools/cabana/streamselector.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/streams/devicestream.h" -#include "tools/cabana/streams/replaystream.h" -#include "tools/cabana/routesdialog.h" -#include "tools/cabana/utils/qtutil.h" - -// OpenReplayWidget - -OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QGridLayout *grid_layout = new QGridLayout(this); - grid_layout->addWidget(new QLabel(tr("Route")), 0, 0); - grid_layout->addWidget(route_edit = new QLineEdit(this), 0, 1); - route_edit->setPlaceholderText(tr("Enter route name or browse for local/remote route")); - auto browse_remote_btn = new QPushButton(tr("Remote route..."), this); - grid_layout->addWidget(browse_remote_btn, 0, 2); - auto browse_local_btn = new QPushButton(tr("Local route..."), this); - grid_layout->addWidget(browse_local_btn, 0, 3); - - QHBoxLayout *camera_layout = new QHBoxLayout(); - for (auto c : {tr("Road camera"), tr("Driver camera"), tr("Wide road camera")}) - camera_layout->addWidget(cameras.emplace_back(new QCheckBox(c, this))); - cameras[0]->setChecked(true); - camera_layout->addStretch(1); - grid_layout->addItem(camera_layout, 1, 1); - - setMinimumWidth(550); - QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() { - QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), QString::fromStdString(settings.last_route_dir)); - if (!dir.isEmpty()) { - route_edit->setText(dir); - settings.last_route_dir = std::filesystem::absolute(dir.toStdString()).parent_path().string(); - } - }); - QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { - RoutesDialog route_dlg(this); - if (route_dlg.exec()) { - route_edit->setText(QString::fromStdString(route_dlg.route())); - } - }); -} - -AbstractStream *OpenReplayWidget::open() { - QString route = route_edit->text(); - QString data_dir; - if (int idx = route.lastIndexOf('/'); idx != -1 && util::file_exists(route.toStdString())) { - data_dir = route.mid(0, idx + 1); - route = route.mid(idx + 1); - } - - bool is_valid_format = Route::parseRoute(route.toStdString()).str.size() > 0; - if (!is_valid_format) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Invalid route format: '%1'").arg(route)); - } else { - auto replay_stream = std::make_unique(); - Connection err = replay_stream->error.connect([](const std::string &msg) { - QMessageBox::warning(nullptr, tr("Error"), QString::fromStdString(msg)); - }); - uint32_t flags = REPLAY_FLAG_NONE; - if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_CABIN_CAMERA; - if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_WIDE_ROAD; - if (flags == REPLAY_FLAG_NONE && !cameras[0]->isChecked()) flags = REPLAY_FLAG_NO_VIPC; - - if (replay_stream->loadRoute(route.toStdString(), data_dir.toStdString(), flags)) { - return replay_stream.release(); - } - } - return nullptr; -} - -// OpenPandaWidget - -static const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U}; -static const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U}; - -OpenPandaWidget::OpenPandaWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - form_layout = new QFormLayout(this); - if (can && dynamic_cast(can) != nullptr) { - form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(QString::fromStdString(can->routeName())))); - form_layout->addWidget(new QLabel("Close the current connection via [File menu -> Close Stream] before connecting to another Panda.")); - QTimer::singleShot(0, [this]() { emit enableOpenButton(false); }); - return; - } - - QHBoxLayout *serial_layout = new QHBoxLayout(); - serial_layout->addWidget(serial_edit = new QComboBox()); - - QPushButton *refresh = new QPushButton(tr("Refresh")); - refresh->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - serial_layout->addWidget(refresh); - form_layout->addRow(tr("Serial"), serial_layout); - - QObject::connect(refresh, &QPushButton::clicked, this, &OpenPandaWidget::refreshSerials); - QObject::connect(serial_edit, &QComboBox::currentTextChanged, this, &OpenPandaWidget::buildConfigForm); - - // Populate serials - refreshSerials(); - buildConfigForm(); -} - -void OpenPandaWidget::refreshSerials() { - serial_edit->clear(); - for (auto serial : Panda::list()) { - serial_edit->addItem(QString::fromStdString(serial)); - } -} - -void OpenPandaWidget::buildConfigForm() { - for (int i = form_layout->rowCount() - 1; i > 0; --i) { - form_layout->removeRow(i); - } - - QString serial = serial_edit->currentText(); - bool has_fd = false; - bool has_panda = !serial.isEmpty(); - if (has_panda) { - try { - Panda panda(serial.toStdString()); - has_fd = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2); - } catch (const std::exception& e) { - fprintf(stderr, "failed to open panda %s\n", serial.toUtf8().constData()); - has_panda = false; - } - } - - if (has_panda) { - config.serial = serial.toStdString(); - config.bus_config.resize(3); - for (int i = 0; i < config.bus_config.size(); i++) { - QHBoxLayout *bus_layout = new QHBoxLayout; - - // CAN Speed - bus_layout->addWidget(new QLabel(tr("CAN Speed (kbps):"))); - QComboBox *can_speed = new QComboBox; - for (int j = 0; j < std::size(speeds); j++) { - can_speed->addItem(QString::number(speeds[j])); - - if (data_speeds[j] == config.bus_config[i].can_speed_kbps) { - can_speed->setCurrentIndex(j); - } - } - QObject::connect(can_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].can_speed_kbps = speeds[index];}); - bus_layout->addWidget(can_speed); - - // CAN-FD Speed - if (has_fd) { - QCheckBox *enable_fd = new QCheckBox("CAN-FD"); - bus_layout->addWidget(enable_fd); - bus_layout->addWidget(new QLabel(tr("Data Speed (kbps):"))); - QComboBox *data_speed = new QComboBox; - for (int j = 0; j < std::size(data_speeds); j++) { - data_speed->addItem(QString::number(data_speeds[j])); - - if (data_speeds[j] == config.bus_config[i].data_speed_kbps) { - data_speed->setCurrentIndex(j); - } - } - - data_speed->setEnabled(false); - bus_layout->addWidget(data_speed); - - QObject::connect(data_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].data_speed_kbps = data_speeds[index];}); - QObject::connect(enable_fd, &QCheckBox::stateChanged, data_speed, &QComboBox::setEnabled); - QObject::connect(enable_fd, &QCheckBox::stateChanged, [=](int state) {config.bus_config[i].can_fd = (bool)state;}); - } - - form_layout->addRow(tr("Bus %1:").arg(i), bus_layout); - } - } else { - config.serial = ""; - form_layout->addWidget(new QLabel(tr("No panda found"))); - } -} - -AbstractStream *OpenPandaWidget::open() { - try { - return new PandaStream(config); - } catch (std::exception &e) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to panda: '%1'").arg(e.what())); - return nullptr; - } -} - -// OpenDeviceWidget - -OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QRadioButton *msgq = new QRadioButton(tr("MSGQ")); - QRadioButton *zmq = new QRadioButton(tr("ZMQ")); - ip_address = new QLineEdit(this); - ip_address->setPlaceholderText(tr("Enter device Ip Address")); - ip_address->setValidator(new IpAddressValidator(this)); - - group = new QButtonGroup(this); - group->addButton(msgq, 0); - group->addButton(zmq, 1); - - QFormLayout *form_layout = new QFormLayout(this); - form_layout->addRow(msgq); - form_layout->addRow(zmq, ip_address); - QObject::connect(group, qOverload(&QButtonGroup::buttonToggled), [=](QAbstractButton *button, bool checked) { - ip_address->setEnabled(button == zmq && checked); - }); - zmq->setChecked(true); -} - -AbstractStream *OpenDeviceWidget::open() { - std::string ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text().toStdString(); - bool msgq = group->checkedId() == 0; - return new DeviceStream(msgq ? "" : ip); -} - -#ifdef __linux__ -// OpenSocketCanWidget - -OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->addStretch(1); - - QFormLayout *form_layout = new QFormLayout(); - - QHBoxLayout *device_layout = new QHBoxLayout(); - device_edit = new QComboBox(); - device_edit->setFixedWidth(300); - device_layout->addWidget(device_edit); - - QPushButton *refresh = new QPushButton(tr("Refresh")); - refresh->setFixedWidth(100); - device_layout->addWidget(refresh); - form_layout->addRow(tr("Device"), device_layout); - main_layout->addLayout(form_layout); - - main_layout->addStretch(1); - - QObject::connect(refresh, &QPushButton::clicked, this, &OpenSocketCanWidget::refreshDevices); - QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText().toStdString(); }); - - // Populate devices - refreshDevices(); -} - -void OpenSocketCanWidget::refreshDevices() { - device_edit->clear(); - // Scan /sys/class/net/ for CAN interfaces (type 280 = ARPHRD_CAN) - std::error_code ec; - for (const auto &entry : std::filesystem::directory_iterator("/sys/class/net", ec)) { - std::ifstream type_file(entry.path() / "type"); - int type = 0; - if (type_file >> type && type == 280) { - device_edit->addItem(QString::fromStdString(entry.path().filename().string())); - } - } -} - -AbstractStream *OpenSocketCanWidget::open() { - try { - return new SocketCanStream(config); - } catch (std::exception &e) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to SocketCAN device: '%1'").arg(e.what())); - return nullptr; - } -} -#endif - -// StreamSelector - -StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Open stream")); - QVBoxLayout *layout = new QVBoxLayout(this); - tab = new QTabWidget(this); - layout->addWidget(tab); - - QHBoxLayout *dbc_layout = new QHBoxLayout(); - dbc_file = new QLineEdit(this); - dbc_file->setReadOnly(true); - dbc_file->setPlaceholderText(tr("Choose a dbc file to open")); - QPushButton *file_btn = new QPushButton(tr("Browse...")); - dbc_layout->addWidget(new QLabel(tr("dbc File"))); - dbc_layout->addWidget(dbc_file); - dbc_layout->addWidget(file_btn); - layout->addLayout(dbc_layout); - - QFrame *line = new QFrame(this); - line->setFrameStyle(QFrame::HLine | QFrame::Sunken); - layout->addWidget(line); - - btn_box = new QDialogButtonBox(QDialogButtonBox::Open | QDialogButtonBox::Cancel); - layout->addWidget(btn_box); - - addStreamWidget(new OpenReplayWidget, tr("&Replay")); - addStreamWidget(new OpenPandaWidget, tr("&Panda")); -#ifdef __linux__ - if (SocketCanStream::available()) { - addStreamWidget(new OpenSocketCanWidget, tr("&SocketCAN")); - } -#endif - addStreamWidget(new OpenDeviceWidget, tr("&Device")); - - QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - QObject::connect(btn_box, &QDialogButtonBox::accepted, [=]() { - setEnabled(false); - if (stream_ = ((AbstractOpenStreamWidget *)tab->currentWidget())->open(); stream_) { - accept(); - } - setEnabled(true); - }); - QObject::connect(file_btn, &QPushButton::clicked, [this]() { - 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 = std::filesystem::absolute(fn.toStdString()).parent_path().string(); - } - }); -} - -void StreamSelector::addStreamWidget(AbstractOpenStreamWidget *w, const QString &title) { - tab->addTab(w, title); - auto open_btn = btn_box->button(QDialogButtonBox::Open); - QObject::connect(w, &AbstractOpenStreamWidget::enableOpenButton, open_btn, &QPushButton::setEnabled); -} diff --git a/openpilot/tools/cabana/streamselector.h b/openpilot/tools/cabana/streamselector.h deleted file mode 100644 index 1210806a3b..0000000000 --- a/openpilot/tools/cabana/streamselector.h +++ /dev/null @@ -1,99 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/streams/pandastream.h" -#ifdef __linux__ -#include "tools/cabana/streams/socketcanstream.h" -#endif - -class AbstractOpenStreamWidget : public QWidget { - Q_OBJECT -public: - AbstractOpenStreamWidget(QWidget *parent = nullptr) : QWidget(parent) {} - virtual AbstractStream *open() = 0; - -signals: - void enableOpenButton(bool); -}; - -class OpenReplayWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenReplayWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - QLineEdit *route_edit; - std::vector cameras; -}; - -class OpenPandaWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenPandaWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - void refreshSerials(); - void buildConfigForm(); - - QComboBox *serial_edit; - QFormLayout *form_layout; - PandaStreamConfig config = {}; -}; - -class OpenDeviceWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenDeviceWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - QLineEdit *ip_address; - QButtonGroup *group; -}; - -#ifdef __linux__ -// no Q_OBJECT: moc does not define __linux__ and would otherwise skip this class -class OpenSocketCanWidget : public AbstractOpenStreamWidget { -public: - OpenSocketCanWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - void refreshDevices(); - - QComboBox *device_edit; - SocketCanStreamConfig config = {}; -}; -#endif - -class StreamSelector : public QDialog { - Q_OBJECT - -public: - StreamSelector(QWidget *parent = nullptr); - void addStreamWidget(AbstractOpenStreamWidget *w, const QString &title); - QString dbcFile() const { return dbc_file->text(); } - AbstractStream *stream() const { return stream_; } - -private: - AbstractStream *stream_ = nullptr; - QLineEdit *dbc_file; - QTabWidget *tab; - QDialogButtonBox *btn_box; -}; diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 5c5fce9864..5f6b8da31f 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -1,17 +1,38 @@ +#include +#include +#include #include #include #include #include +#include +#include #include "common/tests/native_test.h" #include "tools/cabana/dbc/dbcfile.h" +#include "tools/cabana/core/heatmap.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/routes.h" +#include "tools/cabana/ui/qtstate.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/chart/downsample.h" #include "tools/cabana/utils/strings.h" const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; +void test_message_id_parsing() { + for (const auto &text : {"", "1", ":123", "1:", "-1:123", "256:1", "1:100000000", "1:1junk", "1junk:1", "1:1:1"}) { + REQUIRE(!MessageId::parse(text)); + REQUIRE(MessageId::fromString(text) == MessageId{}); + } + const MessageId expected{255, 0xffffffff}; + REQUIRE(MessageId::parse("255:FFFFFFFF") == expected); + REQUIRE(MessageId::parse("255:ffffffff") == expected); + REQUIRE(MessageId::parse(expected.toString()) == expected); + REQUIRE(MessageId::parse("0:0") == MessageId{}); +} + void test_generate_dbc() { std::string fn = std::string(OPENDBC_FILE_PATH) + "/tesla_can.dbc"; DBCFile dbc_origin(fn); @@ -193,10 +214,10 @@ void test_format_seconds() { REQUIRE(utils::formatSeconds(0) == "00:00"); REQUIRE(utils::formatSeconds(59.4) == "00:59"); REQUIRE(utils::formatSeconds(-1) == "00:00"); - REQUIRE(utils::formatSeconds(61.234, true) == "01:01.234"); + REQUIRE(utils::formatSeconds(61.234, true) == "01:01.23"); REQUIRE(utils::formatSeconds(3599.9) == "59:59"); REQUIRE(utils::formatSeconds(3601) == "01:00:01"); - REQUIRE(utils::formatSeconds(3601.5, true) == "01:00:01.500"); + REQUIRE(utils::formatSeconds(3601.5, true) == "01:00:01.50"); const char *tz = getenv("TZ"); const bool had_tz = tz != nullptr; @@ -204,7 +225,7 @@ void test_format_seconds() { setenv("TZ", "UTC", 1); tzset(); REQUIRE(utils::formatSeconds(0, false, true) == "1970-01-01 00:00:00"); - REQUIRE(utils::formatSeconds(1700000000.123, true, true) == "2023-11-14 22:13:20.123"); + REQUIRE(utils::formatSeconds(1700000000.123, true, true) == "2023-11-14 22:13:20.12"); if (had_tz) { setenv("TZ", saved_tz.c_str(), 1); } else { @@ -299,9 +320,128 @@ void test_route_json() { REQUIRE(routes::parseRoutes("not json", false).empty()); } +static std::vector fromHex(const std::string &hex) { + std::vector out; + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + out.push_back((uint8_t)std::stoul(hex.substr(i, 2), nullptr, 16)); + } + return out; +} + +void test_qt_state_blobs() { + // blobs written by the Qt frontend + auto geometry = qtstate::parseQtGeometry(fromHex( + "01d9d0cb000300000000000000000014000004ff000003330000000000000014000004ff" + "00000333000000000000000006400000000000000014000004ff00000333")); + REQUIRE(geometry.has_value()); + REQUIRE(geometry->x == 0); + REQUIRE(geometry->y == 20); + REQUIRE(geometry->w == 1280); + REQUIRE(geometry->h == 800); + REQUIRE(geometry->maximized == false); + + auto splitter = qtstate::parseQtSplitter(fromHex("000000ff0000000100000002000000960000006801ffffffff010000000200")); + REQUIRE(splitter.has_value()); + REQUIRE(std::fabs(splitter->ratio - 150.0f / 254.0f) < 1e-6f); + + auto header = qtstate::parseQtHeaderState(fromHex( + "000000ff000000000000000100000000000000000100000000000000000000000000000000000003360000000701" + "01000100000000000000000000000068ffffffff0000008400000000000000070000006800000001000000000000" + "00680000000100000000000000680000000100000000000000680000000100000000000000680000000100000000" + "000000680000000100000000000000c60000000100000002000003e800000000c6")); + REQUIRE(header.has_value()); + REQUIRE(header->sort_section == 0); + REQUIRE(header->sort_order == 0); + REQUIRE(header->sort_shown == true); + const int expected_width[] = {104, 104, 104, 104, 104, 104, 198}; + for (int i = 0; i < qtstate::kMessageColumnCount; ++i) { + REQUIRE(header->visual[i] == i); + REQUIRE(header->width[i] == expected_width[i]); + REQUIRE(header->hidden[i] == false); + } + + // empty, truncated and wrong magic blobs are rejected + REQUIRE(!qtstate::parseQtGeometry({}).has_value()); + REQUIRE(!qtstate::parseQtSplitter({}).has_value()); + REQUIRE(!qtstate::parseQtHeaderState({}).has_value()); + REQUIRE(!qtstate::parseQtGeometry(fromHex("01d9d0cb00030000000000000000")).has_value()); + REQUIRE(!qtstate::parseQtSplitter(fromHex("000000ff000000010000000200000096")).has_value()); + REQUIRE(!qtstate::parseQtHeaderState(fromHex("000000ff0000000000000001000000000000000001")).has_value()); + REQUIRE(!qtstate::parseQtGeometry(fromHex("deadbeef000300000000000000000014000004ff00000333")).has_value()); + REQUIRE(!qtstate::parseQtSplitter(fromHex("000000fe0000000100000002000000960000006801ffffffff010000000200")).has_value()); + REQUIRE(!qtstate::parseQtHeaderState(fromHex("000000fe00000000000000010000000000000000010000000000000000")).has_value()); +} + +void test_parallel_failure_joins_workers() { + for (bool fail_on_caller : {true, false}) { + std::atomic finished = 0; + const size_t chunks = std::clamp(std::thread::hardware_concurrency(), 2, 4) + 1; + bool caught = false; + try { + parallelFor(chunks, [&](size_t begin, size_t end) { + if (begin == (fail_on_caller ? 0u : 1u)) throw std::runtime_error("task failed"); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + ++finished; + }); + } catch (const std::runtime_error &) { + caught = true; + } + REQUIRE(caught); + REQUIRE(finished == chunks - 1); + } +} + +void test_pixel_envelope() { + struct Point { + double x, y; + Point(double x, double y) : x(x), y(y) {} + }; + std::vector points; + for (int i = 0; i < 1000; ++i) points.emplace_back(i * 0.001, i == 203 ? 99 : i == 201 ? -99 : 0); + const auto result = chart::pixelEnvelope(points.begin(), points.end(), 0, 1, 10); + REQUIRE(result.size() <= 40); + REQUIRE(result.front().x == points.front().x); + REQUIRE(result.back().x == points.back().x); + REQUIRE(std::is_sorted(result.begin(), result.end(), [](const auto &a, const auto &b) { return a.x < b.x; })); + REQUIRE(std::any_of(result.begin(), result.end(), [](const auto &p) { return p.x == .201 && p.y == -99; })); + REQUIRE(std::any_of(result.begin(), result.end(), [](const auto &p) { return p.x == .203 && p.y == 99; })); + const std::vector step{{-1, 0}, {0, 0}, {0, 10}, {0, -10}, {0, 0}, {2, 0}}; + const auto edge = chart::pixelEnvelope(step.begin(), step.end(), 0, 1, 2); + REQUIRE(edge.front().x == -1); + REQUIRE(edge.back().x == 2); + REQUIRE(std::any_of(edge.begin(), edge.end(), [](const auto &p) { return p.y == 10; })); + REQUIRE(std::any_of(edge.begin(), edge.end(), [](const auto &p) { return p.y == -10; })); + REQUIRE(chart::pixelEnvelope(points.begin(), points.begin(), 0, 1, 10).empty()); +} + +void test_heatmap_counts() { + HeatmapCounts counts(3); + for (int value = 0; value < 256; ++value) { + const uint8_t data[] = {static_cast(value), 42, static_cast(value / 16)}; + counts.add(data, sizeof(data)); + } + REQUIRE(counts.bytes == std::vector({255, 0, 15})); + REQUIRE(counts.bits[0] == std::array({1, 3, 7, 15, 31, 63, 127, 255})); + REQUIRE(counts.bits[1] == std::array{}); + + // Every range starts with a baseline, without a transition from outside it. + HeatmapCounts range(3); + const uint8_t first[] = {255, 42, 15}; + range.add(first, 3); + REQUIRE(range.bytes == std::vector({0, 0, 0})); + const uint8_t short_data[] = {0}; + range.add(short_data, 1); + range.add(first, 3); + REQUIRE(range.bytes == std::vector({2, 0, 0})); + REQUIRE(HeatmapCounts(0).bits.empty()); +} + void test_cabana_core() { + test_heatmap_counts(); + test_pixel_envelope(); test_format_seconds(); test_to_hex(); + test_message_id_parsing(); test_signal_tooltip(); test_generate_dbc(); test_comment_order(); @@ -313,6 +453,8 @@ void test_cabana_core() { test_route_timestamps(); test_route_api_response(); test_route_json(); + test_parallel_failure_joins_workers(); + test_qt_state_blobs(); } int main() { diff --git a/openpilot/tools/cabana/tests/test_cabana_ui.py b/openpilot/tools/cabana/tests/test_cabana_ui.py new file mode 100644 index 0000000000..aecd7e6ed7 --- /dev/null +++ b/openpilot/tools/cabana/tests/test_cabana_ui.py @@ -0,0 +1,13 @@ +import subprocess +from pathlib import Path + +from openpilot.common.test import OpenpilotTestCase + +CABANA_DIR = Path(__file__).parent.parent + + +class TestCabanaUi(OpenpilotTestCase): + def test_help(self): + result = subprocess.run(["./cabana", "-h"], cwd=CABANA_DIR, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stderr diff --git a/openpilot/tools/cabana/tools/findsignal.cc b/openpilot/tools/cabana/tools/findsignal.cc deleted file mode 100644 index 2e533741cc..0000000000 --- a/openpilot/tools/cabana/tools/findsignal.cc +++ /dev/null @@ -1,286 +0,0 @@ -#include "tools/cabana/tools/findsignal.h" - -#include -#include - -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/utils/qtutil.h" - -// FindSignalModel - -QVariant FindSignalModel::headerData(int section, Qt::Orientation orientation, int role) const { - static QString titles[] = {"Id", "Start Bit, size", "(time, value)"}; - if (role != Qt::DisplayRole) return {}; - return orientation == Qt::Horizontal ? titles[section] : QString::number(section + 1); -} - -QVariant FindSignalModel::data(const QModelIndex &index, int role) const { - if (role == Qt::DisplayRole) { - const auto &s = filtered_signals[index.row()]; - switch (index.column()) { - case 0: return QString::fromStdString(s.id.toString()); - case 1: return QString("%1, %2").arg(s.sig.start_bit).arg(s.sig.size); - case 2: return s.values.join(" "); - } - } - return {}; -} - -void FindSignalModel::search(std::function cmp) { - beginResetModel(); - - std::mutex lock; - const auto prev_sigs = !histories.empty() ? histories.back() : initial_signals; - filtered_signals.clear(); - filtered_signals.reserve(prev_sigs.size()); - - unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency()); - size_t chunk = (prev_sigs.size() + num_threads - 1) / num_threads; - std::vector threads; - for (unsigned int t = 0; t < num_threads && t * chunk < (size_t)prev_sigs.size(); ++t) { - size_t start = t * chunk; - size_t end = std::min(start + chunk, (size_t)prev_sigs.size()); - threads.emplace_back([&, start, end]() { - for (size_t i = start; i < end; ++i) { - const auto &s = prev_sigs[i]; - const auto &events = can->events(s.id); - auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent()); - auto last = events.cend(); - if (last_time < std::numeric_limits::max()) { - last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent()); - } - - auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); }); - if (it != last) { - auto values = s.values; - values += QString("(%1, %2)").arg(can->toSeconds((*it)->mono_time), 0, 'f', 3).arg(get_raw_value((*it)->dat, (*it)->size, s.sig)); - std::lock_guard lk(lock); - filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values}); - } - } - }); - } - for (auto &th : threads) th.join(); - - histories.push_back(filtered_signals); - - endResetModel(); -} - -void FindSignalModel::undo() { - if (!histories.empty()) { - beginResetModel(); - histories.pop_back(); - filtered_signals.clear(); - if (!histories.empty()) filtered_signals = histories.back(); - endResetModel(); - } -} - -void FindSignalModel::reset() { - beginResetModel(); - histories.clear(); - filtered_signals.clear(); - initial_signals.clear(); - endResetModel(); -} - -// FindSignalDlg -FindSignalDlg::FindSignalDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags() | Qt::Window) { - setWindowTitle(tr("Find Signal")); - setAttribute(Qt::WA_DeleteOnClose); - QVBoxLayout *main_layout = new QVBoxLayout(this); - - // Messages group - message_group = new QGroupBox(tr("Messages"), this); - QFormLayout *message_layout = new QFormLayout(message_group); - message_layout->addRow(tr("Bus"), bus_edit = new QLineEdit()); - bus_edit->setPlaceholderText(tr("comma-separated values. Leave blank for all")); - message_layout->addRow(tr("Address"), address_edit = new QLineEdit()); - address_edit->setPlaceholderText(tr("comma-separated hex values. Leave blank for all")); - QHBoxLayout *hlayout = new QHBoxLayout(); - hlayout->addWidget(first_time_edit = new QLineEdit("0")); - hlayout->addWidget(new QLabel("-")); - hlayout->addWidget(last_time_edit = new QLineEdit("MAX")); - hlayout->addWidget(new QLabel("seconds")); - hlayout->addStretch(0); - message_layout->addRow(tr("Time"), hlayout); - - // Signal group - properties_group = new QGroupBox(tr("Signal")); - QFormLayout *property_layout = new QFormLayout(properties_group); - property_layout->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint); - - hlayout = new QHBoxLayout(); - hlayout->addWidget(min_size = new QSpinBox); - hlayout->addWidget(new QLabel("-")); - hlayout->addWidget(max_size = new QSpinBox); - hlayout->addWidget(litter_endian = new QCheckBox(tr("Little endian"))); - hlayout->addWidget(is_signed = new QCheckBox(tr("Signed"))); - hlayout->addStretch(0); - min_size->setRange(1, 64); - max_size->setRange(1, 64); - min_size->setValue(8); - max_size->setValue(8); - litter_endian->setChecked(true); - property_layout->addRow(tr("Size"), hlayout); - property_layout->addRow(tr("Factor"), factor_edit = new QLineEdit("1.0")); - property_layout->addRow(tr("Offset"), offset_edit = new QLineEdit("0.0")); - - // find group - QGroupBox *find_group = new QGroupBox(tr("Find signal"), this); - QVBoxLayout *vlayout = new QVBoxLayout(find_group); - hlayout = new QHBoxLayout(); - hlayout->addWidget(new QLabel(tr("Value"))); - hlayout->addWidget(compare_cb = new QComboBox(this)); - hlayout->addWidget(value1 = new QLineEdit); - hlayout->addWidget(to_label = new QLabel("-")); - hlayout->addWidget(value2 = new QLineEdit); - hlayout->addWidget(undo_btn = new QPushButton(tr("Undo prev find"), this)); - hlayout->addWidget(search_btn = new QPushButton(tr("Find"))); - hlayout->addWidget(reset_btn = new QPushButton(tr("Reset"), this)); - vlayout->addLayout(hlayout); - - compare_cb->addItems({"=", ">", ">=", "!=", "<", "<=", "between"}); - value1->setFocus(Qt::OtherFocusReason); - value2->setVisible(false); - to_label->setVisible(false); - undo_btn->setEnabled(false); - reset_btn->setEnabled(false); - - auto double_validator = new DoubleValidator(this); - for (auto edit : {value1, value2, factor_edit, offset_edit, first_time_edit, last_time_edit}) { - edit->setValidator(double_validator); - } - - vlayout->addWidget(view = new QTableView(this)); - view->setContextMenuPolicy(Qt::CustomContextMenu); - view->horizontalHeader()->setStretchLastSection(true); - view->horizontalHeader()->setSelectionMode(QAbstractItemView::NoSelection); - view->setSelectionBehavior(QAbstractItemView::SelectRows); - view->setModel(model = new FindSignalModel(this)); - - hlayout = new QHBoxLayout(); - hlayout->addWidget(message_group); - hlayout->addWidget(properties_group); - main_layout->addLayout(hlayout); - main_layout->addWidget(find_group); - main_layout->addWidget(stats_label = new QLabel()); - - setMinimumSize({700, 650}); - QObject::connect(search_btn, &QPushButton::clicked, this, &FindSignalDlg::search); - QObject::connect(undo_btn, &QPushButton::clicked, model, &FindSignalModel::undo); - QObject::connect(model, &QAbstractItemModel::modelReset, this, &FindSignalDlg::modelReset); - QObject::connect(reset_btn, &QPushButton::clicked, model, &FindSignalModel::reset); - QObject::connect(view, &QTableView::customContextMenuRequested, this, &FindSignalDlg::customMenuRequested); - QObject::connect(view, &QTableView::doubleClicked, [this](const QModelIndex &index) { - if (index.isValid()) emit openMessage(model->filtered_signals[index.row()].id); - }); - QObject::connect(compare_cb, qOverload(&QComboBox::currentIndexChanged), [=](int index) { - to_label->setVisible(index == compare_cb->count() - 1); - value2->setVisible(index == compare_cb->count() - 1); - }); -} - -void FindSignalDlg::search() { - if (model->histories.empty()) { - setInitialSignals(); - } - auto v1 = value1->text().toDouble(); - auto v2 = value2->text().toDouble(); - std::function cmp = nullptr; - switch (compare_cb->currentIndex()) { - case 0: cmp = [v1](double v) { return v == v1;}; break; - case 1: cmp = [v1](double v) { return v > v1;}; break; - case 2: cmp = [v1](double v) { return v >= v1;}; break; - case 3: cmp = [v1](double v) { return v != v1;}; break; - case 4: cmp = [v1](double v) { return v < v1;}; break; - case 5: cmp = [v1](double v) { return v <= v1;}; break; - case 6: cmp = [v1, v2](double v) { return v >= v1 && v <= v2;}; break; - } - properties_group->setEnabled(false); - message_group->setEnabled(false); - search_btn->setEnabled(false); - stats_label->setVisible(false); - search_btn->setText("Finding ...."); - QTimer::singleShot(0, this, [=]() { model->search(cmp); }); -} - -void FindSignalDlg::setInitialSignals() { - std::set buses; - for (auto bus : bus_edit->text().trimmed().split(",")) { - bus = bus.trimmed(); - if (!bus.isEmpty()) buses.insert(bus.toUShort()); - } - - std::set addresses; - for (auto addr : address_edit->text().trimmed().split(",")) { - addr = addr.trimmed(); - if (!addr.isEmpty()) addresses.insert(addr.toULong(nullptr, 16)); - } - - cabana::Signal sig{}; - sig.is_little_endian = litter_endian->isChecked(); - sig.is_signed = is_signed->isChecked(); - sig.factor = factor_edit->text().toDouble(); - sig.offset = offset_edit->text().toDouble(); - - double first_time_val = first_time_edit->text().toDouble(); - double last_time_val = last_time_edit->text().toDouble(); - auto [first_sec, last_sec] = std::minmax(first_time_val, last_time_val); - uint64_t first_time = can->toMonoTime(first_sec); - model->last_time = std::numeric_limits::max(); - if (last_sec > 0) { - model->last_time = can->toMonoTime(last_sec); - } - model->initial_signals.clear(); - - for (const auto &[id, m] : can->lastMessages()) { - 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()) { - const int total_size = m.dat.size() * 8; - for (int size = min_size->value(); size <= max_size->value(); ++size) { - for (int start = 0; start <= total_size - size; ++start) { - FindSignalModel::SearchSignal s{.id = id, .mono_time = first_time, .sig = sig}; - s.sig.start_bit = start; - s.sig.size = size; - updateMsbLsb(s.sig); - s.value = get_raw_value((*e)->dat, (*e)->size, s.sig); - model->initial_signals.push_back(s); - } - } - } - } - } -} - -void FindSignalDlg::modelReset() { - properties_group->setEnabled(model->histories.empty()); - message_group->setEnabled(model->histories.empty()); - search_btn->setText(model->histories.empty() ? tr("Find") : tr("Find Next")); - reset_btn->setEnabled(!model->histories.empty()); - undo_btn->setEnabled(model->histories.size() > 1); - search_btn->setEnabled(model->rowCount() > 0 || model->histories.empty()); - stats_label->setVisible(true); - stats_label->setText(tr("%1 matches. right click on an item to create signal. double click to open message").arg(model->filtered_signals.size())); -} - -void FindSignalDlg::customMenuRequested(const QPoint &pos) { - if (auto index = view->indexAt(pos); index.isValid()) { - QMenu menu(this); - menu.addAction(tr("Create Signal")); - if (menu.exec(view->mapToGlobal(pos))) { - auto &s = model->filtered_signals[index.row()]; - UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); - emit openMessage(s.id); - } - } -} diff --git a/openpilot/tools/cabana/tools/findsignal.h b/openpilot/tools/cabana/tools/findsignal.h deleted file mode 100644 index b7cae73b49..0000000000 --- a/openpilot/tools/cabana/tools/findsignal.h +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/settings.h" - -class FindSignalModel : public QAbstractTableModel { -public: - struct SearchSignal { - MessageId id = {}; - uint64_t mono_time = 0; - cabana::Signal sig = {}; - double value = 0.; - QStringList values; - }; - - FindSignalModel(QObject *parent) : QAbstractTableModel(parent) {} - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 3; } - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return std::min((int)filtered_signals.size(), 300); } - void search(std::function cmp); - void reset(); - void undo(); - - std::vector filtered_signals; - std::vector initial_signals; - std::vector> histories; - uint64_t last_time = std::numeric_limits::max(); -}; - -class FindSignalDlg : public QDialog { - Q_OBJECT -public: - FindSignalDlg(QWidget *parent); - -signals: - void openMessage(const MessageId &id); - -private: - void search(); - void modelReset(); - void setInitialSignals(); - void customMenuRequested(const QPoint &pos); - - QLineEdit *value1, *value2, *factor_edit, *offset_edit; - QLineEdit *bus_edit, *address_edit, *first_time_edit, *last_time_edit; - QComboBox *compare_cb; - QSpinBox *min_size, *max_size; - QCheckBox *litter_endian, *is_signed; - QPushButton *search_btn, *reset_btn, *undo_btn; - QGroupBox *properties_group, *message_group; - QTableView *view; - QLabel *to_label, *stats_label; - FindSignalModel *model; -}; diff --git a/openpilot/tools/cabana/tools/findsimilarbits.cc b/openpilot/tools/cabana/tools/findsimilarbits.cc deleted file mode 100644 index 8062b61199..0000000000 --- a/openpilot/tools/cabana/tools/findsimilarbits.cc +++ /dev/null @@ -1,161 +0,0 @@ -#include "tools/cabana/tools/findsimilarbits.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" - -FindSimilarBitsDlg::FindSimilarBitsDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags() | Qt::Window) { - setWindowTitle(tr("Find similar bits")); - setAttribute(Qt::WA_DeleteOnClose); - - QVBoxLayout *main_layout = new QVBoxLayout(this); - - QHBoxLayout *src_layout = new QHBoxLayout(); - src_bus_combo = new QComboBox(this); - find_bus_combo = new QComboBox(this); - for (auto cb : {src_bus_combo, find_bus_combo}) { - for (uint8_t bus : can->sources) { - cb->addItem(QString::number(bus), bus); - } - } - - msg_cb = new QComboBox(this); - // TODO: update when src_bus_combo changes - for (auto &[address, msg] : dbc()->getMessages(-1)) { - msg_cb->addItem(QString::fromStdString(msg.name), address); - } - msg_cb->model()->sort(0); - msg_cb->setCurrentIndex(0); - - byte_idx_sb = new QSpinBox(this); - byte_idx_sb->setFixedWidth(50); - byte_idx_sb->setRange(0, 63); - - bit_idx_sb = new QSpinBox(this); - bit_idx_sb->setFixedWidth(50); - bit_idx_sb->setRange(0, 7); - - src_layout->addWidget(new QLabel(tr("Bus"))); - src_layout->addWidget(src_bus_combo); - src_layout->addWidget(msg_cb); - src_layout->addWidget(new QLabel(tr("Byte Index"))); - src_layout->addWidget(byte_idx_sb); - src_layout->addWidget(new QLabel(tr("Bit Index"))); - src_layout->addWidget(bit_idx_sb); - src_layout->addStretch(0); - - QHBoxLayout *find_layout = new QHBoxLayout(); - find_layout->addWidget(new QLabel(tr("Bus"))); - find_layout->addWidget(find_bus_combo); - find_layout->addWidget(new QLabel(tr("Equal"))); - equal_combo = new QComboBox(this); - equal_combo->addItems({"Yes", "No"}); - find_layout->addWidget(equal_combo); - min_msgs = new QLineEdit(this); - min_msgs->setValidator(new QIntValidator(this)); - min_msgs->setText("100"); - find_layout->addWidget(new QLabel(tr("Min msg count"))); - find_layout->addWidget(min_msgs); - search_btn = new QPushButton(tr("&Find"), this); - find_layout->addWidget(search_btn); - find_layout->addStretch(0); - - QGridLayout *grid_layout = new QGridLayout(); - grid_layout->addWidget(new QLabel("Find From:"), 0, 0); - grid_layout->addLayout(src_layout, 0, 1); - grid_layout->addWidget(new QLabel("Find In:"), 1, 0); - grid_layout->addLayout(find_layout, 1, 1); - main_layout->addLayout(grid_layout); - - table = new QTableWidget(this); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setEditTriggers(QAbstractItemView::NoEditTriggers); - table->horizontalHeader()->setStretchLastSection(true); - main_layout->addWidget(table); - - setMinimumSize({700, 500}); - QObject::connect(search_btn, &QPushButton::clicked, this, &FindSimilarBitsDlg::find); - QObject::connect(table, &QTableWidget::doubleClicked, [this](const QModelIndex &index) { - if (index.isValid()) { - MessageId msg_id = {.source = (uint8_t)find_bus_combo->currentData().toUInt(), .address = table->item(index.row(), 0)->text().toUInt(0, 16)}; - emit openMessage(msg_id); - } - }); -} - -void FindSimilarBitsDlg::find() { - search_btn->setEnabled(false); - table->clear(); - uint32_t selected_address = msg_cb->currentData().toUInt(); - auto msg_mismatched = calcBits(src_bus_combo->currentText().toUInt(), selected_address, byte_idx_sb->value(), bit_idx_sb->value(), - find_bus_combo->currentText().toUInt(), equal_combo->currentIndex() == 0, min_msgs->text().toInt()); - table->setRowCount(msg_mismatched.size()); - table->setColumnCount(6); - table->setHorizontalHeaderLabels({"address", "byte idx", "bit idx", "mismatches", "total msgs", "% mismatched"}); - for (int i = 0; i < msg_mismatched.size(); ++i) { - auto &m = msg_mismatched[i]; - table->setItem(i, 0, new QTableWidgetItem(QString("%1").arg(m.address, 1, 16))); - table->setItem(i, 1, new QTableWidgetItem(QString::number(m.byte_idx))); - table->setItem(i, 2, new QTableWidgetItem(QString::number(m.bit_idx))); - table->setItem(i, 3, new QTableWidgetItem(QString::number(m.mismatches))); - table->setItem(i, 4, new QTableWidgetItem(QString::number(m.total))); - table->setItem(i, 5, new QTableWidgetItem(QString::number(m.perc, 'f', 2))); - } - search_btn->setEnabled(true); -} - -std::vector FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, - int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) { - std::unordered_map> mismatches; - std::unordered_map msg_count; - const auto &events = can->allEvents(); - int bit_to_find = -1; - for (const CanEvent *e : events) { - if (e->src == bus) { - if (e->address == selected_address && e->size > byte_idx) { - bit_to_find = ((e->dat[byte_idx] >> (7 - bit_idx)) & 1) != 0; - } - } - if (e->src == find_bus) { - ++msg_count[e->address]; - if (bit_to_find == -1) continue; - - auto &mismatched = mismatches[e->address]; - if (mismatched.size() < e->size * 8) { - mismatched.resize(e->size * 8); - } - for (int i = 0; i < e->size; ++i) { - for (int j = 0; j < 8; ++j) { - int bit = ((e->dat[i] >> (7 - j)) & 1) != 0; - mismatched[i * 8 + j] += equal ? (bit != bit_to_find) : (bit == bit_to_find); - } - } - } - } - - std::vector result; - result.reserve(mismatches.size()); - for (auto it = mismatches.begin(); it != mismatches.end(); ++it) { - if (auto cnt = msg_count[it->first]; cnt > (uint32_t)min_msgs_cnt) { - auto &mismatched = it->second; - for (int i = 0; i < (int)mismatched.size(); ++i) { - if (float perc = (mismatched[i] / (double)cnt) * 100; perc < 50) { - result.push_back({it->first, (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); - } - } - } - } - std::sort(result.begin(), result.end(), [](auto &l, auto &r) { return l.perc < r.perc; }); - return result; -} diff --git a/openpilot/tools/cabana/tools/findsimilarbits.h b/openpilot/tools/cabana/tools/findsimilarbits.h deleted file mode 100644 index 3451360654..0000000000 --- a/openpilot/tools/cabana/tools/findsimilarbits.h +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" - -class FindSimilarBitsDlg : public QDialog { - Q_OBJECT - -public: - FindSimilarBitsDlg(QWidget *parent); - -signals: - void openMessage(const MessageId &msg_id); - -private: - struct mismatched_struct { - uint32_t address, byte_idx, bit_idx, mismatches, total; - float perc; - }; - std::vector calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus, - bool equal, int min_msgs_cnt); - void find(); - - QTableWidget *table; - QComboBox *src_bus_combo, *find_bus_combo, *msg_cb, *equal_combo; - QSpinBox *byte_idx_sb, *bit_idx_sb; - QPushButton *search_btn; - QLineEdit *min_msgs; -}; diff --git a/openpilot/tools/cabana/tools/routeinfo.cc b/openpilot/tools/cabana/tools/routeinfo.cc deleted file mode 100644 index 1037d4a206..0000000000 --- a/openpilot/tools/cabana/tools/routeinfo.cc +++ /dev/null @@ -1,40 +0,0 @@ -#include "tools/cabana/tools/routeinfo.h" -#include -#include -#include -#include -#include "tools/cabana/streams/replaystream.h" - -RouteInfoDlg::RouteInfoDlg(QWidget *parent) : QDialog(parent) { - auto *replay = dynamic_cast(can)->getReplay(); - setWindowTitle(tr("Route: %1").arg(QString::fromStdString(replay->route().name()))); - - auto *table = new QTableWidget(replay->route().segments().size(), 7, this); - table->setToolTip(tr("Click on a row to seek to the corresponding segment.")); - table->setEditTriggers(QAbstractItemView::NoEditTriggers); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setHorizontalHeaderLabels({"", "rlog", "narrow road", "wide road", "driver", "qlog", "qcam"}); - table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); - table->verticalHeader()->setVisible(false); - table->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - int row = 0; - 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.narrow_road_cam.empty() ? "--" : "Yes")); - table->setItem(row, 3, new QTableWidgetItem(seg.wide_road_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; - } - table->setMinimumWidth(table->horizontalHeader()->length() + table->verticalScrollBar()->sizeHint().width()); - table->setMinimumHeight(table->rowHeight(0) * std::min(table->rowCount(), 13) + table->horizontalHeader()->height() + table->frameWidth() * 2); - - connect(table, &QTableWidget::itemClicked, [](QTableWidgetItem *item) { can->seekTo(item->row() * 60.0); }); - - QVBoxLayout *layout = new QVBoxLayout(this); - layout->addWidget(table); -} diff --git a/openpilot/tools/cabana/tools/routeinfo.h b/openpilot/tools/cabana/tools/routeinfo.h deleted file mode 100644 index 36f32b4bf4..0000000000 --- a/openpilot/tools/cabana/tools/routeinfo.h +++ /dev/null @@ -1,8 +0,0 @@ -#pragma once -#include - -class RouteInfoDlg : public QDialog { - Q_OBJECT -public: - RouteInfoDlg(QWidget *parent = nullptr); -}; diff --git a/openpilot/tools/cabana/ui/app.cc b/openpilot/tools/cabana/ui/app.cc new file mode 100644 index 0000000000..beb693b1e3 --- /dev/null +++ b/openpilot/tools/cabana/ui/app.cc @@ -0,0 +1,203 @@ +#include "tools/cabana/ui/app.h" + +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_impl_glfw.h" +#include "imgui_impl_opengl3.h" +#include "imgui_impl_opengl3_loader.h" +#include "implot.h" +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/inistate.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/mainwin.h" +#include "tools/cabana/utils/util.h" + +namespace { + +std::atomic g_signal_exit{false}; +std::vector g_key_events; +void keyCallback(GLFWwindow *window, int key, int scancode, int action, int mods) { + ImGui_ImplGlfw_KeyCallback(window, key, scancode, action, mods); + if (action == GLFW_PRESS) g_key_events.push_back({key, mods}); +} + +void hookViewportCallbacks() { + for (ImGuiViewport *viewport : ImGui::GetPlatformIO().Viewports) { + if (viewport->PlatformHandle == nullptr || viewport == ImGui::GetMainViewport()) continue; + glfwSetKeyCallback((GLFWwindow *)viewport->PlatformHandle, keyCallback); + } +} + +void glfwErrorCallback(int error, const char *description) { + fprintf(stderr, "GLFW error %d: %s\n", error, description); +} + +void paceFrame() { + using clock = std::chrono::steady_clock; + static clock::duration period = [] { + const GLFWvidmode *mode = glfwGetVideoMode(glfwGetPrimaryMonitor()); + int hz = (mode != nullptr && mode->refreshRate > 0) ? mode->refreshRate : 60; + return std::chrono::duration_cast(std::chrono::duration(1.0 / hz)); + }(); + static clock::time_point next = clock::now(); + next += period; + auto now = clock::now(); + if (next < now) { + next = now; // fell behind (slow frame or hidden window): don't try to catch up + return; + } + std::this_thread::sleep_until(next); +} + +void renderFrame(GLFWwindow *window, MainWindow *win) { + glfwPollEvents(); + utils::drainMainThreadQueue(); + + int fb_w = 0, fb_h = 0; + glfwGetFramebufferSize(window, &fb_w, &fb_h); + + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplGlfw_NewFrame(); + ImGui::NewFrame(); + win->draw(); + ImGui::Render(); + + const ImVec4 &bg = ImGui::GetStyle().Colors[ImGuiCol_WindowBg]; + glViewport(0, 0, fb_w, fb_h); + glClearColor(bg.x, bg.y, bg.z, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + + if (ImGui::GetIO().ConfigFlags & ImGuiConfigFlags_ViewportsEnable) { + GLFWwindow *backup_context = glfwGetCurrentContext(); + ImGui::UpdatePlatformWindows(); + hookViewportCallbacks(); + ImGui::RenderPlatformWindowsDefault(); + glfwMakeContextCurrent(backup_context); + } + glfwSwapBuffers(window); + paceFrame(); +} + +class GlfwRuntime { +public: + GlfwRuntime() { + glfwSetErrorCallback(glfwErrorCallback); +#ifdef __APPLE__ + setMacAppName("Cabana"); +#endif + if (!glfwInit()) throw std::runtime_error("glfwInit failed"); + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); +#ifdef __APPLE__ + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); +#endif + // Restore geometry and render the initial layout before mapping the window. + glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); + window_ = glfwCreateWindow(1600, 900, "Cabana", nullptr, nullptr); + if (window_ == nullptr) { + glfwTerminate(); + throw std::runtime_error("glfwCreateWindow failed"); + } + glfwMakeContextCurrent(window_); + glfwSwapInterval(0); + } + + ~GlfwRuntime() { + if (window_ != nullptr) glfwDestroyWindow(window_); + glfwTerminate(); + } + + GlfwRuntime(const GlfwRuntime &) = delete; + GlfwRuntime &operator=(const GlfwRuntime &) = delete; + GLFWwindow *window() const { return window_; } + +private: + GLFWwindow *window_ = nullptr; +}; + +class ImGuiRuntime { +public: + explicit ImGuiRuntime(GLFWwindow *window) { + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImPlot::CreateContext(); + ImGuiIO &io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; + io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable; + io.ConfigViewportsNoDecoration = true; + io.ConfigDockingTransparentPayload = true; + io.IniFilename = nullptr; + io.LogFilename = nullptr; + if (!ImGui_ImplGlfw_InitForOpenGL(window, true)) { + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + throw std::runtime_error("ImGui_ImplGlfw_InitForOpenGL failed"); + } + glfwSetKeyCallback(window, keyCallback); + if (!ImGui_ImplOpenGL3_Init("#version 330")) { + ImGui_ImplGlfw_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + throw std::runtime_error("ImGui_ImplOpenGL3_Init failed"); + } + } + + ~ImGuiRuntime() { + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplGlfw_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + } + + ImGuiRuntime(const ImGuiRuntime &) = delete; + ImGuiRuntime &operator=(const ImGuiRuntime &) = delete; +}; + +} // namespace + +std::vector takeKeyEvents() { + return std::exchange(g_key_events, {}); +} + +int run(std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file) { + try { + // SIGINT/SIGTERM close all windows (which may ask about unsaved changes), then exit + UnixSignalHandler signal_handler([]() { g_signal_exit = true; }); + + GlfwRuntime glfw; + ImGuiRuntime imgui(glfw.window()); + loadFonts(); + applyTheme(settings.theme); + inistate::addSettingsHandler(); + inistate::load(); + inistate::applyWindowGeometry(glfw.window()); + + MainWindow win(glfw.window(), std::move(stream), std::move(stream_loader), dbc_file); + renderFrame(glfw.window(), &win); + glfwShowWindow(glfw.window()); + while (!win.exited()) { + if (g_signal_exit.exchange(false)) { + printf("\nexiting...\n"); + win.close(); + } else if (glfwWindowShouldClose(glfw.window())) { + glfwSetWindowShouldClose(glfw.window(), GLFW_FALSE); + win.close(); + } + renderFrame(glfw.window(), &win); + } + return 0; + } catch (const std::exception &e) { + fprintf(stderr, "%s\n", e.what()); + return 1; + } +} diff --git a/openpilot/tools/cabana/ui/app.h b/openpilot/tools/cabana/ui/app.h new file mode 100644 index 0000000000..a981e57e54 --- /dev/null +++ b/openpilot/tools/cabana/ui/app.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/streams/abstractstream.h" + +// builds a stream off the main thread; nullptr means the load failed +using StreamLoader = std::function()>; + +// takes ownership of stream; a loader runs behind the window instead of before it; with neither, the +// stream selector opens +int run(std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file); + +// key presses with the modifier state at event time: imgui may apply a modifier release in the same frame as +// the key press it belongs to, which loses fast shortcut sequences. Consumed once per frame by MainWindow. +struct KeyEvent { + int key; // GLFW_KEY_* + int mods; // GLFW_MOD_* +}; +std::vector takeKeyEvents(); diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc new file mode 100644 index 0000000000..57d00a2ee2 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -0,0 +1,797 @@ +#define IMGUI_DEFINE_MATH_OPERATORS // ImVec2 arithmetic, must precede imgui.h +#include "tools/cabana/ui/chart/chart.h" + +#include +#include +#include +#include +#include + +#include "tools/cabana/core/settings.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/chart/downsample.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +const int X_TICK_COUNT = 5; +const double MIN_ZOOM_SECONDS = 0.01; // 10ms +const double EPSILON = 1e-6; +static inline bool xLessThan(const ImPlotPoint &p, double x) { return p.x < (x - EPSILON); } +static inline bool isNull(const ImPlotPoint &p) { return p.x == 0 && p.y == 0; } + +static std::string formatNumber(double value, int precision) { + char buf[64]; + snprintf(buf, sizeof(buf), "%.*f", precision, value); + return buf; +} + +// the decimals needed to tell tick_count ticks over range apart +static int axisPrecision(double range, int tick_count, int min_precision) { + return std::max(int(-std::floor(std::log10(range / (tick_count - 1)))), min_precision); +} + +static void addTextEllipsis(ImDrawList *dl, ImFont *font, ImU32 col, const ImVec2 &pos, float max_x, const std::string &text) { + const float size = ImGui::GetFontSize(); + ImGui::PushFont(font, 0.0f); + ImGui::RenderTextEllipsis(dl, pos, ImVec2(max_x, pos.y + size), max_x, text.c_str(), nullptr, nullptr); + ImGui::PopFont(); +} + +ChartView::ChartView(const std::pair &x_range, ChartsWidget *parent) + : x_min_(x_range.first), x_max_(x_range.second), charts_widget_(parent) { + series_type_ = (SeriesType)settings.chart_series_type; + + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { signalRemoved(sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { signalUpdated(sig); })); + connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { msgRemoved(id); })); +} + +void ChartView::drawMenuActions() { + for (int i = 0; i < (int)std::size(SERIES_TYPE_NAMES); ++i) { + if (dropdown::Item(SERIES_TYPE_NAMES[i], nullptr, i == (int)series_type_)) { + setSeriesType((SeriesType)i); + } + } + ImGui::Separator(); + if (dropdown::Item("Manage Signals")) manageSignals(); + if (dropdown::Item("Split Chart", nullptr, false, sigs_.size() > 1)) charts_widget_->splitChart(this); +} + +// the buttons and their menus are drawn every frame, at the rects updateLayout() placed them at +void ChartView::createToolButtons() { + ImGui::SetCursorScreenPos(layout_.close_btn_rect.Min); + bool close_clicked = iconButton("close_btn", icon::X_LG, "Remove Chart"); + + ImGui::SetCursorScreenPos(layout_.manage_btn_rect.Min); + if (iconButton("manage_btn", icon::THREE_DOTS_VERTICAL, "")) ImGui::OpenPopup("manage_menu"); + if (dropdown::BeginPopup("manage_menu")) { + drawMenuActions(); + dropdown::EndPopup(); + } + + if (close_clicked) charts_widget_->removeChart(this); +} + +void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) { + if (hasSignal(msg_id, sig)) return; + + sigs_.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(sig->color)}); + updateSeries(sig); + charts_widget_->seriesChanged(); +} + +bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const { + return std::any_of(sigs_.cbegin(), sigs_.cend(), [&](auto &s) { return s.msg_id == msg_id && s.sig == sig; }); +} + +void ChartView::removeIf(std::function predicate) { + int prev_size = sigs_.size(); + 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) { + charts_widget_->seriesChanged(); + updateAxisY(); + } +} + +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->color == sig->color)) { + it->color = uniqueColor(sig->color, sig); + } + updateSeries(sig); + } +} + +void ChartView::manageSignals() { + auto dlg = std::make_unique("Manage Chart"); + for (auto &s : sigs_) { + dlg->addSelected(s.msg_id, s.sig); + } + // runs once the dialog is accepted, dropped if the chart is removed first + charts_widget_->execSignalSelector(std::move(dlg), this, [this](SignalSelector &selector) { + const auto &items = selector.selectedItems(); + for (const auto &s : items) { + addSignal(s.msg_id, s.sig); + } + removeIf([&](auto &s) { + return std::none_of(items.cbegin(), items.cend(), [&](auto &it) { return s.msg_id == it.msg_id && s.sig == it.sig; }); + }); + }); +} + +void ChartView::updateLayout() { + const ImVec2 grip = ImGui::CalcTextSize(icon::GRIP_HORIZONTAL); + const ImGuiStyle &style = ImGui::GetStyle(); + // WindowPadding can be zero in a borderless pane or drag preview. Chart + // content always uses the shared control gap, independently of its parent. + layout_.content_rect = layout_.rect; + layout_.content_rect.Expand(-style.ItemSpacing.x); + const ImVec2 top_left = layout_.content_rect.Min; + layout_.move_icon_rect = ImRect(top_left, top_left + grip); + const ImVec2 btn_size(iconButtonWidth(), iconButtonWidth()); + const ImVec2 close_min(layout_.content_rect.Max.x - btn_size.x, top_left.y); + layout_.close_btn_rect = ImRect(close_min, close_min + btn_size); + const ImVec2 manage_min(close_min.x - btn_size.x - ImGui::GetStyle().ItemSpacing.x, top_left.y); + layout_.manage_btn_rect = ImRect(manage_min, manage_min + btn_size); + + ImFont *bold = boldFont(); + const float font_size = ImGui::GetFontSize(); + const float fm_height = ImGui::GetTextLineHeight(); + const int marker_size = markerSize(); + const int row_height = std::max(marker_size, fm_height) + fm_height + style.ItemInnerSpacing.y; // + the signal value line + const int legend_left = layout_.move_icon_rect.Max.x + style.ItemSpacing.x; + const int legend_right = std::max(layout_.manage_btn_rect.Min.x - ImGui::GetStyle().ItemSpacing.x, legend_left + 10); + + // layout legend entries left-to-right, wrapping between the move icon and the buttons + layout_.legend_rects.clear(); + int x = legend_left, y = top_left.y; + for (auto &s : sigs_) { + int w = marker_size + style.ItemInnerSpacing.x + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + + ImGui::CalcTextSize(msgLabel(s.msg_id).c_str()).x; + pushMonoFont(font_size); + w = std::max(w, (int)std::ceil(ImGui::CalcTextSize("-0.00000e+000").x)); + popMonoFont(); + 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; + } + layout_.legend_rects.emplace_back(ImVec2(x, y), ImVec2(x + w, y + std::max(marker_size, fm_height))); + x += w + style.ItemSpacing.x; + } + + // add top space for the legend and signal values + layout_.header_bottom = std::max(y + row_height, layout_.manage_btn_rect.Max.y) + ImGui::GetStyle().ItemSpacing.y; +} + +void ChartView::updatePlot(double cur, double min, double max) { + cur_sec_ = cur; + if (min != x_min_ || max != x_max_) { + x_min_ = min; + x_max_ = max; + updateAxisY(); + if (tooltip_x_ >= 0) { + showTip(secondsAtPoint({(float)tooltip_x_, 0})); + } + } +} + +void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector &events, + std::vector &vals, std::vector &step_vals) { + vals.reserve(vals.size() + events.size()); + step_vals.reserve(step_vals.size() + events.size() * 2); + + double value = 0; + for (const CanEvent *e : events) { + if (sig->getValue(e->dat, e->size, &value)) { + const double ts = can->toSeconds(e->mono_time); + vals.emplace_back(ts, value); + if (!step_vals.empty()) + step_vals.emplace_back(ts, step_vals.back().y); + step_vals.emplace_back(ts, value); + } + } +} + +void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap *msg_new_events) { + for (auto &s : sigs_) { + if (!sig || s.sig == sig) { + if (!msg_new_events) { + s.vals.clear(); + s.step_vals.clear(); + } + auto events = msg_new_events ? msg_new_events : &can->eventsMap(); + auto it = events->find(s.msg_id); + if (it == events->end() || it->second.empty()) continue; + + if (s.vals.empty() || can->toSeconds(it->second.back()->mono_time) > s.vals.back().x) { + appendCanEvents(s.sig, it->second, s.vals, s.step_vals); + } else { + std::vector vals, step_vals; + appendCanEvents(s.sig, it->second, vals, step_vals); + if (vals.empty()) continue; + s.vals.insert(std::lower_bound(s.vals.begin(), s.vals.end(), vals.front().x, xLessThan), + vals.begin(), vals.end()); + s.step_vals.insert(std::lower_bound(s.step_vals.begin(), s.step_vals.end(), step_vals.front().x, xLessThan), + step_vals.begin(), step_vals.end()); + } + + if (!can->liveStreaming()) { + s.segment_tree.build(s.vals.size(), [&vals = s.vals](int i) { return vals[i].y; }); + } + } + } + updateAxisY(); +} + +std::pair ChartView::visibleRange(const std::vector &points) const { + auto first = std::lower_bound(points.cbegin(), points.cend(), x_min_, xLessThan); + auto last = std::lower_bound(first, points.cend(), x_max_, xLessThan); + return {first, last}; +} + +const ImPlotPoint *ChartView::lastPointBefore(const SigItem &s, double sec) const { + auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double x) { return p.x > x + EPSILON; }); + return it != s.vals.crend() && it->x >= x_min_ ? &*it : nullptr; +} + +void ChartView::updateAxisY() { + if (sigs_.empty()) return; + + double min = std::numeric_limits::max(); + double max = std::numeric_limits::lowest(); + std::string unit = sigs_[0].sig->unit; + + for (auto &s : sigs_) { + if (!s.visible) continue; + + // Only show unit when all signals have the same unit + if (unit != s.sig->unit) { + unit.clear(); + } + + auto [first, last] = visibleRange(s.vals); + s.min = std::numeric_limits::max(); + s.max = std::numeric_limits::lowest(); + if (first == last) continue; + if (can->liveStreaming()) { + for (auto it = first; it != last; ++it) { + if (it->y < s.min) s.min = it->y; + if (it->y > s.max) s.max = it->y; + } + } else { + std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last) - 1); + } + min = std::min(min, s.min); + max = std::max(max, s.max); + } + if (min == std::numeric_limits::max()) min = 0; + if (max == std::numeric_limits::lowest()) max = 0; + + y_unit_ = unit; + + const double magnitude = std::max(std::abs(min), std::abs(max)); + double delta = max - min <= magnitude * 1e-9 ? (magnitude > 0 ? magnitude * 0.05 : 1) : (max - min) * 0.05; + auto [min_y, max_y, tick_count] = getNiceAxisNumbers(min - delta, max + delta, 3); + if (min_y != y_min_ || max_y != y_max_) { + y_min_ = min_y; + y_max_ = max_y; + y_tick_count_ = tick_count; + y_precision_ = axisPrecision(max_y - min_y, tick_count, 0); + } +} + +std::tuple ChartView::getNiceAxisNumbers(double min, double max, int tick_count) { + double range = niceNumber((max - min), true); // range with ceiling + double step = niceNumber(range / (tick_count - 1), false); + min = std::floor(min / step); + max = std::ceil(max / step); + tick_count = int(max - min) + 1; + return {min * step, max * step, tick_count}; +} + +int ChartView::xAxisPrecision() const { + return axisPrecision(x_max_ - x_min_, X_TICK_COUNT, 2); +} + +// nice numbers can be expressed as form of 1*10^n, 2* 10^n or 5*10^n +double ChartView::niceNumber(double x, bool ceiling) { + double z = std::pow(10, std::floor(std::log10(x))); // the largest 10^n smaller than x + double q = x / z; // 1 <= q < 10 + if (ceiling) { + if (q <= 1.0) q = 1; + else if (q <= 2.0) q = 2; + else if (q <= 5.0) q = 5; + else q = 10; + } else { + if (q < 1.5) q = 1; + else if (q < 3.0) q = 2; + else if (q < 7.0) q = 5; + else q = 10; + } + return q * z; +} + +void ChartView::drawContextMenu() { + if (drawing_ghost_) return; + // the menu opens on right press; a right release with no menu open reaches handleMouseRelease + if (ImGui::IsMouseClicked(ImGuiMouseButton_Right) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows) && + !ImGui::IsAnyItemActive()) { + ImGui::OpenPopup("context_menu"); + } + context_menu_id_ = ImGui::GetID("context_menu"); + if (dropdown::BeginPopup("context_menu")) { + drawMenuActions(); + ImGui::Separator(); + // the zoom entries come from the toolbar, where they are only visible while zoomed + if (can->timeRange().has_value()) { + const std::string undo_text = std::string(icon::ARROW_COUNTERCLOCKWISE) + " Undo Zoom"; + const std::string redo_text = std::string(icon::ARROW_CLOCKWISE) + " Redo Zoom"; + if (dropdown::Item(undo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canUndo())) charts_widget_->zoom_undo_stack_.undo(); + if (dropdown::Item(redo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canRedo())) charts_widget_->zoom_undo_stack_.redo(); + ImGui::Separator(); + } + if (dropdown::Item("Close")) charts_widget_->removeChart(this); + dropdown::EndPopup(); + } +} + +void ChartView::handleMousePress() { + if (drawing_ghost_) return; + const ImVec2 pos = ImGui::GetMousePos(); + // a press on the close/manage buttons does not reach the widget + const bool widget_pressed = ImGui::IsMouseClicked(ImGuiMouseButton_Left) && layout_.rect.Contains(pos) && + ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && + !layout_.close_btn_rect.Contains(pos) && !layout_.manage_btn_rect.Contains(pos); + if (!widget_pressed) return; + press_pos_ = pos; + if (layout_.move_icon_rect.Contains(pos)) return; // the move icon press is handled by the grip item (startChartDrag) + + if (ImGui::GetIO().KeyShift) { + // Save current playback state when scrubbing + resume_after_scrub_ = !can->isPaused(); + if (resume_after_scrub_) { + can->pause(true); + } + mouse_mode_ = MouseMode::Scrub; + } else if (layout_.plot_area.Contains(pos)) { + mouse_mode_ = MouseMode::Rubber; + rubber_rect_ = ImRect(); + } +} + +void ChartView::handleMouseMove() { + if (drawing_ghost_) return; + const ImVec2 pos = ImGui::GetMousePos(); + const ImVec2 delta = ImGui::GetIO().MouseDelta; + // a click alone must not hide the tip + if (delta.x == 0 && delta.y == 0) return; + // only the widget under the mouse, or the one dragging, reacts to a move + if (mouse_mode_ == MouseMode::None && !layout_.rect.Contains(pos)) return; + + if (mouse_mode_ == MouseMode::Scrub && ImGui::GetIO().KeyShift) { + if (layout_.plot_area.Contains(pos)) { + can->seekTo(std::clamp(secondsAtPoint(pos), can->minSeconds(), can->maxSeconds())); + } + } + + if (mouse_mode_ == MouseMode::Rubber) { + // horizontal selection, clamped to the plot area + float left = std::clamp(std::min(press_pos_.x, pos.x), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + float right = std::clamp(std::max(press_pos_.x, pos.x), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + rubber_rect_ = ImRect(ImVec2(left, layout_.plot_area.Min.y), ImVec2(right, layout_.plot_area.Max.y)); + } + + clearTrackPoints(); + if (mouse_mode_ != MouseMode::Rubber && layout_.plot_area.Contains(pos) && (layout_.plot_hovered || mouse_mode_ != MouseMode::None) && + ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow)) { + charts_widget_->showValueTip(secondsAtPoint(pos)); + } else if (tip_label_.isVisible()) { + charts_widget_->showValueTip(-1); + } +} + +void ChartView::handleMouseRelease() { + if (drawing_ghost_) return; + const bool left_released = ImGui::IsMouseReleased(ImGuiMouseButton_Left); + const bool right_released = ImGui::IsMouseReleased(ImGuiMouseButton_Right) && layout_.rect.Contains(ImGui::GetMousePos()); + if (!left_released && !right_released) return; + if (left_released && mouse_mode_ == MouseMode::Rubber) { + mouse_mode_ = MouseMode::None; + // Prevent zooming/seeking past the end of the route + double min = std::clamp(secondsAtPoint(rubber_rect_.Min), can->minSeconds(), can->maxSeconds()); + double max = std::clamp(secondsAtPoint(rubber_rect_.Max), can->minSeconds(), can->maxSeconds()); + if (rubber_rect_.GetWidth() <= 10) { + // Small movements are still clicks; use the same threshold as drag-to-zoom. + can->seekTo(std::clamp(secondsAtPoint(press_pos_), can->minSeconds(), can->maxSeconds())); + } else if ((max - min) > MIN_ZOOM_SECONDS) { + charts_widget_->zoom_undo_stack_.push(new ZoomCommand({min, max})); + } + rubber_rect_ = ImRect(); + } else if (right_released && !ImGui::IsPopupOpen(context_menu_id_, ImGuiPopupFlags_None)) { + charts_widget_->zoom_undo_stack_.undo(); + } + + if (mouse_mode_ == MouseMode::Scrub) { + mouse_mode_ = MouseMode::None; + if (resume_after_scrub_) { + can->pause(false); + resume_after_scrub_ = false; + } + } +} + +void ChartView::takeSignalsFrom(ChartView *source) { + for (auto &s : source->sigs_) { + s.color = uniqueColor(s.color); + sigs_.push_back(std::move(s)); + } + source->sigs_.clear(); + updateAxisY(); + charts_widget_->removeChart(source); +} + +std::vector ChartView::takeExtraSignals() { + std::vector extra; + for (auto it = sigs_.begin() + 1; it != sigs_.end(); ++it) { + it->color = it->sig->color; + extra.push_back(std::move(*it)); + } + sigs_.resize(1); + updateAxisY(); + return extra; +} + +void ChartView::adoptSignal(SigItem s) { + sigs_.push_back(std::move(s)); + updateAxisY(); +} + +void ChartView::showTip(double sec) { + ImRect tip_area(ImVec2(layout_.rect.Min.x, layout_.plot_area.Min.y), ImVec2(layout_.rect.Max.x, layout_.plot_area.Max.y)); + ImRect visible_rect = charts_widget_->chartVisibleRect(this); + visible_rect.ClipWith(tip_area); + if (visible_rect.GetWidth() <= 0 || visible_rect.GetHeight() <= 0) { + tip_label_.hide(); + return; + } + + tooltip_x_ = xPos(sec); + float x = -1; + std::vector text_list; + for (auto &s : sigs_) { + if (s.visible) { + std::string value = "--"; + if (const ImPlotPoint *pt = lastPointBefore(s, sec)) { + value = s.sig->formatValue(pt->y, false); + s.track_pt = *pt; + x = std::max(x, xPos(pt->x)); + } + std::string name = s.sig->name; + std::string min = s.min == std::numeric_limits::max() ? "--" : utils::toString(s.min); + std::string max = s.max == std::numeric_limits::lowest() ? "--" : utils::toString(s.max); + text_list.push_back({.has_marker = true, .marker = toImU32(graphicColor(s.color)), .name = name, .value = value, .min = min, .max = max}); + } + } + if (x < 0) { + x = tooltip_x_; + } + ImVec2 pt(x, layout_.plot_area.Min.y); + text_list.insert(text_list.begin(), TipLine{.name = formatNumber(sec, 2) + " s"}); + tip_label_.showText(pt, text_list, visible_rect); +} + +void ChartView::hideTip() { + clearTrackPoints(); + tooltip_x_ = -1; + tip_label_.hide(); +} + +void ChartView::draw(float width) { + ImGui::PushID(this); + width = std::max(width, (float)CHART_MIN_WIDTH); + layout_.plot_hovered = false; + // the tile geometry is known before the child is entered, so it stays valid when imgui culls a scrolled out chart + const ImVec2 tile_pos = ImGui::GetCursorScreenPos(); + const ImVec2 tile_size(width, (float)settings.chart_height); + layout_.rect = ImRect(tile_pos, tile_pos + tile_size); + if (ImGui::BeginChild("chart", tile_size, ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + updateLayout(); + paint(); + drawContextMenu(); + // Keep the tip above the plot, but below popup menus and other windows. + ImRect visible_rect = charts_widget_->chartVisibleRect(this); + visible_rect.ClipWith(ImRect(ImVec2(layout_.rect.Min.x, layout_.plot_area.Min.y), + ImVec2(layout_.rect.Max.x, layout_.plot_area.Max.y))); + if (!drawing_ghost_ && visible_rect.GetWidth() > 0 && visible_rect.GetHeight() > 0) tip_label_.draw(visible_rect); + } + ImGui::EndChild(); + ImGui::PopID(); +} + +void ChartView::drawGhost(float width) { + // the ghost is drawn in its own window: keep the geometry of the live tile so hit testing stays correct + drawing_ghost_ = true; + const Layout saved = layout_; + draw(width); + layout_ = saved; + drawing_ghost_ = false; +} + +void ChartView::paint() { + drawStaticLayer(); + + if (can_drop_) { + ImGui::GetWindowDrawList()->AddRect(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_Header), ImGui::GetStyle().ChildRounding, 0, 4.0f); + } +} + +void ChartView::drawStaticLayer() { + ImDrawList *painter = ImGui::GetWindowDrawList(); + painter->AddRectFilled(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_ChildBg), ImGui::GetStyle().ChildRounding); + ImGui::SetCursorScreenPos(layout_.move_icon_rect.Min); + ImGui::InvisibleButton("grip", layout_.move_icon_rect.GetSize()); + if (ImGui::IsItemActivated()) charts_widget_->startChartDrag(this, ImGui::GetMousePos()); + if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + painter->AddText(layout_.move_icon_rect.Min, ImGui::GetColorU32(ImGuiCol_Text), icon::GRIP_HORIZONTAL); + createToolButtons(); + drawLegend(); + drawSignalValue(); // drawn here because implot clips the plot frame + drawAxes(); +} + +void ChartView::drawAxes() { + ImGui::SetCursorScreenPos(ImVec2(layout_.content_rect.Min.x, layout_.header_bottom)); + const float plot_h = std::max(layout_.content_rect.Max.y - layout_.header_bottom, 10.0f); + ImPlot::PushStyleVar(ImPlotStyleVar_PlotPadding, ImVec2(0.0f, ImGui::GetStyle().ItemInnerSpacing.y)); + ImPlot::PushStyleColor(ImPlotCol_PlotBg, ImVec4(0, 0, 0, 0)); + ImPlot::PushStyleColor(ImPlotCol_FrameBg, ImVec4(0, 0, 0, 0)); + ImPlot::PushStyleColor(ImPlotCol_PlotBorder, palette().grid); + ImPlot::PushStyleColor(ImPlotCol_AxisTick, ImVec4(0, 0, 0, 0)); + ImPlot::PushStyleColor(ImPlotCol_AxisText, ImGui::GetStyleColorVec4(ImGuiCol_Text)); + ImPlot::PushStyleVar(ImPlotStyleVar_MajorTickLen, ImVec2(0, 0)); + ImPlot::PushStyleVar(ImPlotStyleVar_MajorGridSize, ImVec2(1.0f, 1.0f)); + const ImPlotFlags flags = ImPlotFlags_NoTitle | ImPlotFlags_NoLegend | ImPlotFlags_NoMenus | ImPlotFlags_NoMouseText | + ImPlotFlags_NoBoxSelect | ImPlotFlags_NoInputs | ImPlotFlags_NoFrame; + const ImPlotAxisFlags axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | ImPlotAxisFlags_NoSideSwitch | ImPlotAxisFlags_Lock; + // reserve room for the right half of the last x tick label + const float x_label_width = ImGui::CalcTextSize(formatNumber(x_max_, xAxisPrecision()).c_str()).x + ImGui::GetStyle().ItemInnerSpacing.x; + if (ImPlot::BeginPlot("##plot", ImVec2(layout_.content_rect.GetWidth() - x_label_width / 2, plot_h), flags)) { + ImPlot::SetupAxis(ImAxis_X1, nullptr, axis_flags); + ImPlot::SetupAxis(ImAxis_Y1, y_unit_.empty() ? nullptr : y_unit_.c_str(), axis_flags); + ImPlot::SetupAxisLimits(ImAxis_X1, x_min_, x_max_, ImPlotCond_Always); + ImPlot::SetupAxisLimits(ImAxis_Y1, y_min_, y_max_, ImPlotCond_Always); + // the format must be set before the ticks are generated + ImPlot::SetupAxisFormat(ImAxis_Y1, ("%." + std::to_string(y_precision_) + "f").c_str()); + ImPlot::SetupAxisTicks(ImAxis_Y1, y_min_, y_max_, y_tick_count_); + ImPlot::SetupAxisFormat(ImAxis_X1, ("%." + std::to_string(xAxisPrecision()) + "f").c_str()); + ImPlot::SetupAxisTicks(ImAxis_X1, x_min_, x_max_, X_TICK_COUNT); + ImPlot::SetupFinish(); + + layout_.plot_area = ImRect(ImPlot::GetPlotPos(), ImPlot::GetPlotPos() + ImPlot::GetPlotSize()); + // ImPlotFlags_NoInputs disables implot's own hover tracking + // A popup is a descendant of the chart, but hovering its menu must not hover the plot underneath. + layout_.plot_hovered = layout_.plot_area.Contains(ImGui::GetMousePos()) && ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + drawSeries(); + if (!drawing_ghost_) { + // Own plot clicks so custom scrubbing/zooming cannot also move the floating window. + const ImGuiID input_id = ImGui::GetID("plot_input"); + if (ImGui::ItemAdd(layout_.plot_area, input_id)) { + bool hovered, held; + ImGui::ButtonBehavior(layout_.plot_area, input_id, &hovered, &held); + } + } + handleMousePress(); + handleMouseMove(); + handleMouseRelease(); + drawForeground(); + ImPlot::EndPlot(); + } + ImPlot::PopStyleColor(5); + ImPlot::PopStyleVar(3); +} + +void ChartView::drawLegend() { + ImDrawList *painter = ImGui::GetWindowDrawList(); + const ImU32 title_color = ImGui::GetColorU32(ImGuiCol_Text); + // Draw message details in similar color, but slightly fade it to the background + const ImU32 msg_color = withAlpha(title_color, 180); + ImFont *bold = boldFont(); + ImFont *normal = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + const float marker_size = markerSize(); + + for (int i = 0; i < sigs_.size() && i < layout_.legend_rects.size(); ++i) { + const auto &s = sigs_[i]; + const ImRect &r = layout_.legend_rects[i]; + // toggle series visibility by clicking its legend entry + ImGui::PushID(i); + ImGui::SetCursorScreenPos(r.Min); + if (ImGui::InvisibleButton("legend", ImVec2(std::max(r.GetWidth(), 1.0f), std::max(r.GetHeight(), 1.0f))) && + mouse_mode_ == MouseMode::None && sigs_.size() > 1) { + sigs_[i].visible = !sigs_[i].visible; + updateAxisY(); + } + ImGui::PopID(); + + if (series_type_ == SeriesType::Scatter) { + painter->AddCircleFilled(r.Min + ImVec2(marker_size / 2.0f, 2.0f + marker_size / 2.0f), marker_size / 2.0f, toImU32(graphicColor(s.color))); + } else { + drawColorMarker(painter, r.Min, toImU32(graphicColor(s.color))); + } + + float x = r.Min.x + marker_size + ImGui::GetStyle().ItemInnerSpacing.x; + const float text_y = r.GetCenter().y - font_size / 2.0f; + addTextEllipsis(painter, bold, title_color, ImVec2(x, text_y), r.Max.x, s.sig->name); + float name_w = std::min(bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x, r.Max.x - x); + x += name_w; + const std::string msg = msgLabel(s.msg_id); + addTextEllipsis(painter, normal, msg_color, ImVec2(x, text_y), r.Max.x, msg); + if (!s.visible) { // strike out + const float y = r.GetCenter().y; + painter->AddLine(ImVec2(r.Min.x + marker_size + ImGui::GetStyle().ItemInnerSpacing.x, y), ImVec2(std::min(x + ImGui::CalcTextSize(msg.c_str()).x, r.Max.x), y), title_color); + } + } +} + +void ChartView::drawSeries() { + for (int i = 0; i < sigs_.size(); ++i) { + auto &s = sigs_[i]; + if (!s.visible) continue; + + // visible points in vals to compute point density + auto [first, last] = visibleRange(s.vals); + int num_points = std::max(last - first, 1); + double pixels_per_point = 0; + if (first != last) { + const ImPlotPoint &right_pt = last == s.vals.cend() ? s.vals.back() : *last; + pixels_per_point = (xPos(right_pt.x) - xPos(first->x)) / num_points; + } + + const std::string label = "##sig" + std::to_string(i); + ImPlotSpec spec; + spec.LineColor = toImVec4(graphicColor(s.color)); + spec.Stride = sizeof(ImPlotPoint); + if (series_type_ == SeriesType::Scatter) { + float radius = std::clamp(pixels_per_point / 2.0, 2.0, 8.0) / 2.0; + spec.Marker = ImPlotMarker_Circle; + spec.MarkerSize = radius; + if (first != last) ImPlot::PlotScatter(label.c_str(), &first->x, &first->y, last - first, spec); + } else { + const auto &points = series_type_ == SeriesType::StepLine ? s.step_vals : s.vals; + // one sample beyond each edge so the line runs out of the plot + auto [begin, end] = visibleRange(points); + if (begin != points.cbegin()) --begin; + if (end != points.cend()) ++end; + if (begin == end) continue; + + spec.LineWeight = 2; + const int pixels = std::max(1, (int)layout_.plot_area.GetWidth()); + if (end - begin > pixels * 4) { + const auto envelope = chart::pixelEnvelope(begin, end, x_min_, x_max_, pixels); + ImPlot::PlotLine(label.c_str(), &envelope.front().x, &envelope.front().y, envelope.size(), spec); + } else { + ImPlot::PlotLine(label.c_str(), &begin->x, &begin->y, end - begin, spec); + } + + // show points when zoomed in enough + if ((num_points == 1 || pixels_per_point > 20) && first != last) { + ImPlotSpec dots; + dots.LineColor = toImVec4(graphicColor(s.color)); + dots.Stride = sizeof(ImPlotPoint); + dots.Marker = ImPlotMarker_Circle; + dots.MarkerSize = 4; + ImPlot::PlotScatter((label + "_pts").c_str(), &first->x, &first->y, last - first, dots); + } + } + } +} + +void ChartView::drawForeground() { + drawTimeline(); + ImDrawList *painter = ImPlot::GetPlotDrawList(); + ImPlot::PushPlotClipRect(); + float track_line_x = -1; + for (auto &s : sigs_) { + if (!isNull(s.track_pt) && s.visible) { + ImVec2 pos(xPos(s.track_pt.x), yPos(s.track_pt.y)); + painter->AddCircleFilled(pos, 5.5f, toImU32(graphicColor(s.color))); + track_line_x = std::max(track_line_x, pos.x); + } + } + if (track_line_x > 0) { + const ImU32 dark_gray = ImGui::GetColorU32(palette().text_disabled); + for (float y = layout_.plot_area.Min.y; y < layout_.plot_area.Max.y; y += 8) { + painter->AddLine(ImVec2(track_line_x, y), ImVec2(track_line_x, std::min(y + 4, layout_.plot_area.Max.y)), dark_gray, 1.0f); + } + } + ImPlot::PopPlotClipRect(); + + drawRubberBandTimeRange(); +} + +void ChartView::drawRubberBandTimeRange() { + if (rubber_rect_.GetWidth() <= 1) return; + + ImDrawList *painter = ImPlot::GetPlotDrawList(); + // ImGuiCol_Header is translucent, so the 1px selection outline is drawn at full alpha + const ImU32 highlight = withAlpha(ImGui::GetColorU32(ImGuiCol_Header), 255); + painter->AddRectFilled(rubber_rect_.Min, rubber_rect_.Max, withAlpha(highlight, 50), ImGui::GetStyle().FrameRounding); + painter->AddRect(rubber_rect_.Min, rubber_rect_.Max, highlight, ImGui::GetStyle().FrameRounding); + + // time labels at the bottom corners (below the plot, so clip to the widget instead of the plot) + const ImU32 white = IM_COL32_WHITE; + const ImU32 badge = ImGui::GetColorU32(palette().badge); + painter = ImGui::GetWindowDrawList(); + painter->PushClipRect(layout_.rect.Min, layout_.rect.Max); + for (const auto &pt : {rubber_rect_.GetBL(), rubber_rect_.GetBR()}) { + std::string sec = formatNumber(secondsAtPoint(pt), 2); + ImVec2 size = ImGui::CalcTextSize(sec.c_str()) + ImVec2(12, ImGui::GetStyle().ItemInnerSpacing.y * 2); + ImVec2 top_left = pt.x == rubber_rect_.Min.x ? ImVec2(pt.x - size.x, pt.y + 2) : ImVec2(pt.x, pt.y + 2); + painter->AddRectFilled(top_left, top_left + size, badge, ImGui::GetStyle().FrameRounding); + painter->AddText(top_left + ImVec2(6, ImGui::GetStyle().ItemInnerSpacing.y), white, sec.c_str()); + } + painter->PopClipRect(); +} + +void ChartView::drawTimeline() { + ImDrawList *painter = ImPlot::GetPlotDrawList(); + float x = std::clamp(xPos(cur_sec_), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + painter->AddLine(ImVec2(x, layout_.plot_area.Min.y - 1.0f), ImVec2(x, layout_.plot_area.Max.y + 1.0f), ImGui::GetColorU32(ImGuiCol_Text), 1.0f); + + std::string time_str = formatNumber(cur_sec_, 2); + ImVec2 time_str_size = ImGui::CalcTextSize(time_str.c_str()) + ImVec2(8, 2); + ImVec2 time_str_pos(x - time_str_size.x / 2.0f, layout_.plot_area.Max.y + ImGui::GetStyle().ItemInnerSpacing.y); + painter->AddRectFilled(time_str_pos, time_str_pos + time_str_size, ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); + painter->AddText(time_str_pos + ImVec2(4, 1), IM_COL32_WHITE, time_str.c_str()); +} + +void ChartView::drawSignalValue() { + pushMonoFont(ImGui::GetFontSize()); + ImDrawList *painter = ImGui::GetWindowDrawList(); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); + for (int i = 0; i < sigs_.size() && i < layout_.legend_rects.size(); ++i) { + const auto &s = sigs_[i]; + const ImPlotPoint *pt = lastPointBefore(s, cur_sec_); + std::string value = pt ? s.sig->formatValue(pt->y) : "--"; + const ImVec2 value_min = layout_.legend_rects[i].GetBL() - ImVec2(0, 1); + ImRect value_rect(value_min, value_min + layout_.legend_rects[i].GetSize()); + float w = ImGui::CalcTextSize(value.c_str()).x; + if (w <= value_rect.GetWidth()) { + painter->AddText(value_rect.Min, color, value.c_str()); + } else { + addTextEllipsis(painter, ImGui::GetFont(), color, value_rect.Min, value_rect.Max.x, value); + } + } + popMonoFont(); +} + +CabanaColor ChartView::uniqueColor(CabanaColor color, const cabana::Signal *exclude) const { + auto separation = [&](float hue) { + float distance = 1.0f; + for (const auto &s : sigs_) { + if (exclude && s.sig == exclude) continue; + const float delta = std::abs(hue - s.color.hsv().hue); + distance = std::min(distance, std::min(delta, 1.0f - delta)); + } + return distance; + }; + const float original_hue = color.hsv().hue; + if (separation(original_hue) >= 0.1f) return color; + + float best_hue = original_hue, best_distance = -1; + for (int i = 0; i < 36; ++i) { + const float hue = std::fmod(original_hue + i / 36.0f, 1.0f); + const float distance = separation(hue); + if (distance > best_distance) { + best_hue = hue; + best_distance = distance; + } + } + return CabanaColor::fromHsv(best_hue, 0.8f, 0.9f, color.a / 255.0f); +} diff --git a/openpilot/tools/cabana/ui/chart/chart.h b/openpilot/tools/cabana/ui/chart/chart.h new file mode 100644 index 0000000000..7e7152f6df --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chart.h @@ -0,0 +1,143 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "implot.h" + +#include "tools/cabana/ui/chart/tiplabel.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/util.h" + +enum class SeriesType { + Line = 0, + StepLine, + Scatter +}; +inline constexpr const char *SERIES_TYPE_NAMES[] = {"Line", "Step Line", "Scatter"}; + +// the message part of a legend entry, drawn after the signal name +inline std::string msgLabel(const MessageId &id) { return " " + msgName(id) + " " + id.toString(); } + +class ChartsWidget; +class ChartView { +public: + struct SigItem { + MessageId msg_id; + const cabana::Signal *sig = nullptr; + CabanaColor color; + bool visible = true; + std::vector vals; + std::vector step_vals; + ImPlotPoint track_pt{}; + SegmentTree segment_tree; + double min = 0; + double max = 0; + }; + + ChartView(const std::pair &x_range, ChartsWidget *parent); + void addSignal(const MessageId &msg_id, const cabana::Signal *sig); + bool hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const; + void updateSeries(const cabana::Signal *sig = nullptr, const MessageEventsMap *msg_new_events = nullptr); + void updatePlot(double cur, double min, double max); + void setSeriesType(SeriesType type) { series_type_ = type; } + void showTip(double sec); + void hideTip(); + void draw(float width); // one chart of settings.chart_height + void drawGhost(float width); // the same tile rendered again, without handling any input + void removeIf(std::function predicate); + void takeSignalsFrom(ChartView *source); + // every signal but the first, with its original color, for a split into one chart per signal + std::vector takeExtraSignals(); + void adoptSignal(SigItem s); + void setDropHighlight(bool highlight) { can_drop_ = highlight; } + const std::vector &signals() const { return sigs_; } + const ImRect &rect() const { return layout_.rect; } // the whole chart widget, screen coordinates + bool plotHovered() const { return layout_.plot_hovered; } + double secondsAtPoint(const ImVec2 &pt) const { + return x_min_ + (pt.x - layout_.plot_area.Min.x) * (x_max_ - x_min_) / std::max(layout_.plot_area.GetWidth(), 1.0f); + } + +private: + using PointIter = std::vector::const_iterator; + + void signalUpdated(const cabana::Signal *sig); + void manageSignals(); + 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; }); } + + void appendCanEvents(const cabana::Signal *sig, const std::vector &events, + std::vector &vals, std::vector &step_vals); + void createToolButtons(); + void drawContextMenu(); + void handleMousePress(); + void handleMouseMove(); + void handleMouseRelease(); + void updateLayout(); + void updateAxisY(); + void paint(); + void drawStaticLayer(); + void drawAxes(); + void drawLegend(); + void drawSeries(); + void drawForeground(); + void drawSignalValue(); + void drawTimeline(); + void drawRubberBandTimeRange(); + void drawMenuActions(); // the series type / manage / split entries shared by the menu button and the context menu + int xAxisPrecision() const; + std::tuple getNiceAxisNumbers(double min, double max, int tick_count); + double niceNumber(double x, bool ceiling); + CabanaColor uniqueColor(CabanaColor color, const cabana::Signal *exclude = nullptr) const; + // the last sample at or before sec, nullptr when there is none inside the visible range + const ImPlotPoint *lastPointBefore(const SigItem &s, double sec) const; + // the samples inside [x_min_, x_max_) + std::pair visibleRange(const std::vector &points) const; + inline void clearTrackPoints() { for (auto &s : sigs_) s.track_pt = {}; } + inline float xPos(double sec) const { return layout_.plot_area.Min.x + (sec - x_min_) / (x_max_ - x_min_) * layout_.plot_area.GetWidth(); } + inline float yPos(double val) const { return layout_.plot_area.Max.y - (val - y_min_) / (y_max_ - y_min_) * layout_.plot_area.GetHeight(); } + + // layout + struct Layout { + ImRect rect; // the whole chart widget, screen coordinates + ImRect content_rect; // the same inset on all four sides, including during a drag + ImRect plot_area; + ImRect move_icon_rect; + ImRect close_btn_rect; + ImRect manage_btn_rect; + std::vector legend_rects; + float header_bottom = 0; + bool plot_hovered = false; + } layout_; + // axes + double x_min_; + double x_max_; + double y_min_ = 0; + double y_max_ = 1; + int y_tick_count_ = 3; + int y_precision_ = 1; + std::string y_unit_; + // interaction + enum class MouseMode { None, Rubber, Scrub }; + MouseMode mouse_mode_ = MouseMode::None; + ImVec2 press_pos_; + ImRect rubber_rect_; + bool resume_after_scrub_ = false; + bool drawing_ghost_ = false; // drawing the drag preview: no mouse handling, no tip + ImGuiID context_menu_id_ = 0; + + TipLabel tip_label_; + std::vector sigs_; + double cur_sec_ = 0; + SeriesType series_type_ = SeriesType::Line; + bool can_drop_ = false; + double tooltip_x_ = -1; + ChartsWidget *charts_widget_; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc new file mode 100644 index 0000000000..c89dc702db --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -0,0 +1,650 @@ +#define IMGUI_DEFINE_MATH_OPERATORS // ImVec2 arithmetic, must precede imgui.h +#include "tools/cabana/ui/chart/chartswidget.h" + +#include "tools/cabana/ui/threadpool.h" + +#include +#include +#include +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/chart/chart.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +const int MAX_COLUMN_COUNT = 4; +const int START_DRAG_DISTANCE = 10; +const float MIN_RANGE_SLIDER_WIDTH = 40.0f; + +bool LogSlider::draw(const char *label, float width) { + return fusionSliderInt(label, &pos_, min_, max_, width); +} + +ChartsWidget::ChartsWidget() { + range_slider_.setRange(1, settings.max_cached_minutes * 60); + + tabbar_.setAutoHide(true); + tabbar_.setUsesScrollButtons(true); + tabbar_.setTabsClosable(true); + + column_count_ = std::clamp(settings.chart_column_count, 1, MAX_COLUMN_COUNT); + max_chart_range_ = std::clamp(settings.chart_range, 1, settings.max_cached_minutes * 60); + display_range_ = std::make_pair(can->minSeconds(), can->minSeconds() + max_chart_range_); + range_slider_.setValue(max_chart_range_); + + connections_.push_back(dbc()->fileChanged.connect([this]() { removeAll(); })); + connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &events) { eventsMerged(events); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); + connections_.push_back(can->seeking.connect([this](double) { updateState(); })); + connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { updateState(); })); + connections_.push_back(settings.changed.connect([this]() { settingChanged(); })); + connections_.push_back(seriesChanged.connect([this]() { updateTabBar(); })); + connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { removeTab(index); })); + connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { + if (dropdown::BeginPopupContextItem()) { + if (dropdown::Item("Close Other Tabs")) { + tabbar_.moveTab(index, 0); + tabbar_.setCurrentIndex(0); + while (tabbar_.count() > 1) removeTab(1); + } + dropdown::EndPopup(); + } + })); + connections_.push_back(tabbar_.currentChanged.connect([this](int index) { + if (index != -1) updateLayout(); + })); + + newTab(); +} + +ChartsWidget::~ChartsWidget() = default; + +std::string ChartsWidget::whatsThis() const { + return R"( + Chart View
+ Click: Click to seek to a corresponding time.
+ Drag: Zoom into the chart.
+ Shift + Drag: Scrub through the chart to view values.
+ Right-click: Open the context menu.
+ )"; +} + +void ChartsWidget::newTab() { + static int tab_unique_id = 0; + int idx = tabbar_.addTab(""); + tabbar_.setTabData(idx, tab_unique_id++); + tabbar_.setCurrentIndex(idx); + updateTabBar(); +} + +void ChartsWidget::removeTab(int index) { + int id = tabbar_.tabData(index); + for (auto &c : std::vector(tab_charts_[id])) { + removeChart(c); + } + tab_charts_.erase(id); + tabbar_.removeTab(index); + updateTabBar(); +} + +void ChartsWidget::updateTabBar() { + for (int i = 0; i < tabbar_.count(); ++i) { + const auto &charts_in_tab = tab_charts_[tabbar_.tabData(i)]; + tabbar_.setTabText(i, "Tab " + std::to_string(i + 1) + " (" + std::to_string((int)charts_in_tab.size()) + ")"); + } +} + +void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) { + std::vector> futures; + for (auto &c : charts_) { + futures.push_back(ThreadPool::instance().run([c = c.get(), &new_events]() { c->updateSeries(nullptr, &new_events); })); + } + for (auto &f : futures) f.get(); +} + +void ChartsWidget::zoomReset() { + can->setTimeRange(std::nullopt); + zoom_undo_stack_.clear(); +} + +ImRect ChartsWidget::chartVisibleRect(ChartView *chart) { + ImRect r = chart->rect(); + r.ClipWith(charts_scroll_viewport_); + return r; +} + +void ChartsWidget::showValueTip(double sec) { + if (chartDragActive()) sec = -1; // no value tip while a drag is in progress + showTip(sec); + if (sec < 0 && !value_tip_visible_) return; + + value_tip_visible_ = sec >= 0; + for (auto c : currentCharts()) { + value_tip_visible_ ? c->showTip(sec) : c->hideTip(); + } +} + +void ChartsWidget::updateState() { + if (charts_.empty()) return; + + const auto &time_range = can->timeRange(); + const double cur_sec = can->currentSec(); + if (!time_range.has_value()) { + double pos = (cur_sec - display_range_.first) / std::max(1.0, max_chart_range_); + if (pos < 0 || pos > 0.8) { + display_range_.first = std::max(can->minSeconds(), cur_sec - max_chart_range_ * 0.1); + } + double max_sec = std::min(display_range_.first + max_chart_range_, can->maxSeconds()); + display_range_.first = std::max(can->minSeconds(), max_sec - max_chart_range_); + display_range_.second = display_range_.first + max_chart_range_; + } + + const auto &range = time_range ? *time_range : display_range_; + for (auto &c : charts_) { + c->updatePlot(cur_sec, range.first, range.second); + } +} + +void ChartsWidget::setMaxChartRange(int value) { + max_chart_range_ = settings.chart_range = value; + updateState(); +} + +void ChartsWidget::drawToolBar() { + float slider_width = 150.0f; + const bool is_zoomed = can->timeRange().has_value(); + + // the labels are captured by reference, they outlive the draw calls below + std::vector items; + items.push_back({iconButtonWidth(), [this]() { + if (stepButton("new_plot_btn", true, "New Chart")) newChart(); + }}); + items.push_back({iconButtonWidth(), [this]() { + if (iconButton("new_tab_btn", icon::WINDOW_PLUS, "New Tab")) newTab(); + }}); + + const int type_count = (int)std::size(SERIES_TYPE_NAMES); + const std::string chart_type_text = std::string("Type: ") + SERIES_TYPE_NAMES[std::clamp(settings.chart_series_type, 0, type_count - 1)]; + auto chart_type_items = [this]() { + for (int i = 0; i < type_count; ++i) { + if (dropdown::Item(SERIES_TYPE_NAMES[i], nullptr, settings.chart_series_type == i)) { + settings.chart_series_type = i; + settingChanged(); + } + } + }; + items.push_back(toolbarMenu("chart_type", chart_type_text, "Type", chart_type_items)); + + const std::string columns_action_text = "Columns: " + std::to_string(column_count_); + if (columns_action_visible_) { + auto column_items = [this]() { + for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { + if (dropdown::Item(std::to_string(i + 1).c_str(), nullptr, column_count_ == i + 1)) setColumnCount(i + 1); + } + }; + items.push_back(toolbarMenu("columns", columns_action_text, "Columns", column_items)); + } + + // the spacer right aligns the rest + const size_t spacer_index = items.size(); + size_t slider_index = (size_t)-1; + const std::string range_lb = is_zoomed ? std::string() : utils::formatSeconds(max_chart_range_); + std::string reset_zoom_text; + if (!is_zoomed) { + // the range label and the slider are one unit: drawn inline and moved to the overflow menu together + slider_index = items.size(); + const float label_width = ImGui::CalcTextSize(range_lb.c_str()).x + ImGui::GetStyle().ItemInnerSpacing.x; + items.push_back({label_width + slider_width, [this, &range_lb, &slider_width]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(range_lb.c_str()); + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + // Restore the slider width in overflow; the toolbar may have shrunk it. + const bool in_menu = ImGui::GetCurrentWindow()->Flags & ImGuiWindowFlags_Popup; + const float width = in_menu ? std::max(ImGui::GetContentRegionAvail().x, 150.0f) : slider_width; + if (range_slider_.draw("##range_slider", width)) setMaxChartRange(range_slider_.value()); + ImGui::SetItemTooltip("Set the chart range"); + }}); + } else { + const auto &range = *can->timeRange(); + char buf[64]; + snprintf(buf, sizeof(buf), "%.2f-%.2f", range.first, range.second); + reset_zoom_text = buf; + // The undo/redo/reset buttons form one group. The reset button has a fixed width in the mono font, + // sized for the longest range the stream can show, so its neighbors do not shift as the range changes. + const int digits = std::max({1, (int)std::to_string((long long)can->maxSeconds()).size(), (int)std::to_string((long long)range.second).size()}); + const std::string widest = std::string(digits, '0') + ".00"; + pushMonoFont(ImGui::GetFontSize()); + const float reset_zoom_width = iconTextButtonWidth(icon::ZOOM_OUT, widest + "-" + widest); + popMonoFont(); + items.push_back({iconButtonWidth() * 2 + ImGui::GetStyle().ItemSpacing.x, [this]() { + ImGui::BeginDisabled(!zoom_undo_stack_.canUndo()); + if (iconButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(!zoom_undo_stack_.canRedo()); + if (iconButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); + ImGui::EndDisabled(); + }}); + items.push_back({reset_zoom_width, [this, &reset_zoom_text, reset_zoom_width]() { + pushMonoFont(ImGui::GetFontSize()); + const bool clicked = iconTextButton("reset_zoom_btn", icon::ZOOM_OUT, reset_zoom_text, reset_zoom_width); + popMonoFont(); + if (clicked) zoomReset(); + ImGui::SetItemTooltip("Reset Zoom"); + }}); + } + items.push_back(toolbarAction("remove_all_btn", icon::TRASH, "Remove all charts", [this]() { removeAll(); }, !charts_.empty())); + + // the slider shrinks first, the buttons stay pinned to the right edge + if (slider_index != (size_t)-1) { + const float shrink = std::min(slider_width - MIN_RANGE_SLIDER_WIDTH, toolbarWidth(items, spacer_index) - ImGui::GetContentRegionAvail().x); + if (shrink > 0.0f) { + slider_width -= shrink; + items[slider_index].width -= shrink; + } + } + drawToolbar(items, spacer_index); +} + +void ChartsWidget::settingChanged() { + if (range_slider_.maximum() != settings.max_cached_minutes * 60) { + range_slider_.setRange(1, settings.max_cached_minutes * 60); + } + for (auto &c : charts_) { + c->setSeriesType((SeriesType)settings.chart_series_type); + } +} + +ChartView *ChartsWidget::findChart(const MessageId &id, const cabana::Signal *sig) { + for (auto &c : charts_) + if (c->hasSignal(id, sig)) return c.get(); + return nullptr; +} + +ChartView *ChartsWidget::createChart(int pos) { + auto chart = std::make_unique(can->timeRange().value_or(display_range_), this); + ChartView *ptr = chart.get(); + pos = std::clamp(pos, 0, (int)charts_.size()); + charts_.insert(charts_.begin() + pos, std::move(chart)); + auto ¤t = currentCharts(); + current.insert(current.begin() + std::min(pos, (int)current.size()), ptr); + updateLayout(); + return ptr; +} + +void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { + ChartView *chart = findChart(id, sig); + if (show && !chart) { + chart = merge && currentCharts().size() > 0 ? currentCharts().front() : createChart(); + chart->addSignal(id, sig); + updateState(); + } else if (!show && chart) { + chart->removeIf([&](auto &s) { return s.msg_id == id && s.sig == sig; }); + } +} + +void ChartsWidget::splitChart(ChartView *src_chart) { + if (src_chart->signals().size() > 1) { + auto ¤t = currentCharts(); + const int pos = std::find(current.begin(), current.end(), src_chart) - current.begin() + 1; + for (auto &s : src_chart->takeExtraSignals()) { + createChart(pos)->adoptSignal(std::move(s)); + } + updateState(); + } +} + +std::vector ChartsWidget::serializeChartIds() const { + std::vector chart_ids; + for (auto &c : charts_) { + std::string ids; + for (const auto &s : c->signals()) { + if (!ids.empty()) ids += ','; + ids += s.msg_id.toString() + "|" + s.sig->name; + } + chart_ids.push_back(ids); + } + std::reverse(chart_ids.begin(), chart_ids.end()); + return chart_ids; +} + +void ChartsWidget::restoreChartsFromIds(const std::vector &chart_ids) { + for (const auto &chart_id : chart_ids) { + int index = 0; + for (const auto &part : utils::split(chart_id, ',')) { + const size_t sep = part.find('|'); + if (sep == std::string::npos) continue; + MessageId msg_id = MessageId::fromString(part.substr(0, sep)); + if (auto *msg = dbc()->msg(msg_id)) + if (auto *sig = msg->sig(part.substr(sep + 1))) + showChart(msg_id, sig, true, index++ > 0); + } + } +} + +void ChartsWidget::setColumnCount(int n) { + n = std::clamp(n, 1, MAX_COLUMN_COUNT); + if (column_count_ != n) { + column_count_ = settings.chart_column_count = n; + updateLayout(); + } +} + +void ChartsWidget::updateLayout() { + // the container has not been drawn yet (docked/floated this frame): keep the last known layout + const float container_width = charts_container_.geometry().GetWidth(); + if (container_width <= 0) return; + + int n = MAX_COLUMN_COUNT; + for (; n > 1; --n) { + if ((n * CHART_MIN_WIDTH + (n - 1) * ImGui::GetStyle().ItemSpacing.x) < container_width) break; + } + + columns_action_visible_ = n > 1; + current_column_count_ = std::min(column_count_, n); +} + +void ChartsWidget::startChartDrag(ChartView *chart, const ImVec2 &global_pos) { + stopAutoScroll(); + drag_ = {.source = chart, .press_pos = global_pos}; + showValueTip(-1); // no value tip while a drag is in progress + // the drag preview re-renders the tile at CHART_MIN_WIDTH + drag_preview_size_ = ImVec2(CHART_MIN_WIDTH, (float)settings.chart_height); +} + +void ChartsWidget::dragChartMove(const ImVec2 &global_pos) { + if (!drag_.active) { + ImVec2 d = global_pos - drag_.press_pos; + if (std::abs(d.x) + std::abs(d.y) < START_DRAG_DISTANCE) return; + drag_.active = true; + drag_preview_visible_ = true; + } + drag_preview_pos_ = global_pos + ImVec2(5, 5); + + // hovering a tab switches to it so the chart can be dropped into another tab + int tab = tabbar_.tabAt(global_pos); + if (tab >= 0 && tab != tabbar_.currentIndex()) { + tabbar_.setCurrentIndex(tab); + } + + ChartView *target = nullptr; + for (auto c : currentCharts()) { + if (c != drag_.source && c->rect().Contains(global_pos)) { + target = c; + break; + } + } + if (std::exchange(drop_target_, target) != target) { + for (auto &c : charts_) c->setDropHighlight(c.get() == target); + } + bool in_viewport = charts_scroll_viewport_.Contains(global_pos); + bool on_background = !target && in_viewport && !charts_container_.childAt(global_pos); + charts_container_.setDropIndicator(on_background ? global_pos : ImVec2()); + + if (in_viewport) { + startAutoScroll(global_pos); + } +} + +void ChartsWidget::cancelChartDrag() { + drag_ = {}; + stopAutoScroll(); + drag_preview_visible_ = false; + charts_container_.setDropIndicator({}); + if (auto target = std::exchange(drop_target_, nullptr)) target->setDropHighlight(false); +} + +void ChartsWidget::dragChartRelease(const ImVec2 &global_pos) { + ChartView *source = drag_.source; + bool active = drag_.active; + ChartView *target = drop_target_; + cancelChartDrag(); + if (!active) return; + + bool in_viewport = charts_scroll_viewport_.Contains(global_pos); + if (target) { + // merge source into target + target->takeSignalsFrom(source); + } else if (in_viewport && !charts_container_.childAt(global_pos)) { + // reorder within the current tab + auto w = charts_container_.getDropAfter(global_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(); + updateTabBar(); + } + } +} + +void ChartsWidget::drawDragPreview() { + if (!drag_preview_visible_ || !drag_.source) return; + // the drag preview is the whole tile (header + axes + plot) at 50% alpha, re-rendered into a window that + // takes no input, so the live chart keeps handling the mouse. + ImGui::SetNextWindowPos(drag_preview_pos_); + ImGui::SetNextWindowSize(drag_preview_size_); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.5f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking; + if (ImGui::Begin("##chart_drag_ghost", nullptr, flags)) { + drag_.source->drawGhost(drag_preview_size_.x); + } + ImGui::End(); + ImGui::PopStyleVar(3); +} + +void ChartsWidget::startAutoScroll(const ImVec2 &global_pos) { + auto_scroll_pos_ = global_pos; + if (!auto_scroll_timer_active_) auto_scroll_timer_next_ = ImGui::GetTime() + 0.05; + auto_scroll_timer_active_ = true; +} + +void ChartsWidget::stopAutoScroll() { + auto_scroll_timer_active_ = false; + auto_scroll_count_ = 0; +} + +void ChartsWidget::doAutoScroll() { + if (!charts_scroll_) return; + const int page_step = charts_scroll_viewport_.GetHeight(); + if (auto_scroll_count_ < page_step) { + ++auto_scroll_count_; + } + + int value = charts_scroll_->Scroll.y; + ImVec2 pos = auto_scroll_pos_; + ImRect area = charts_scroll_viewport_; + + int new_value = value; + if (pos.y - area.Min.y < settings.chart_height / 2) { + new_value = value - auto_scroll_count_; + } else if (area.Max.y - pos.y < settings.chart_height / 2) { + new_value = value + auto_scroll_count_; + } + new_value = std::clamp(new_value, 0, charts_scroll_->ScrollMax.y); + if (new_value != value) ImGui::SetScrollY(charts_scroll_, new_value); + if (value == new_value) { + stopAutoScroll(); + } else if (chartDragActive()) { + // refresh the drop indicator/target at the new scroll position + dragChartMove(auto_scroll_pos_); + } +} + +void ChartsWidget::newChart() { + execSignalSelector(std::make_unique("New Chart"), nullptr, [this](SignalSelector &dlg) { + const auto &items = dlg.selectedItems(); + if (!items.empty()) { + auto c = createChart(); + for (const auto &it : items) { + c->addSignal(it.msg_id, it.sig); + } + updateState(); + } + }); +} + +void ChartsWidget::execSignalSelector(std::unique_ptr dlg, ChartView *owner, std::function accepted) { + signal_selector_ = std::move(dlg); + signal_selector_owner_ = owner; + signal_selector_accepted_ = std::move(accepted); + signal_selector_->open(); +} + +void ChartsWidget::removeChart(ChartView *chart) { + if (drag_.source == chart) cancelChartDrag(); + if (drop_target_ == chart) drop_target_ = nullptr; + if (signal_selector_owner_ == chart) { + signal_selector_owner_ = nullptr; + signal_selector_accepted_ = nullptr; + } + auto it = std::find_if(charts_.begin(), charts_.end(), [chart](auto &c) { return c.get() == chart; }); + if (it != charts_.end()) { + deleted_charts_.push_back(std::move(*it)); // may be called from the chart's draw; freed next frame + charts_.erase(it); + } + for (auto &[_, list] : tab_charts_) { + list.erase(std::remove(list.begin(), list.end(), chart), list.end()); + } + updateLayout(); + seriesChanged(); +} + +void ChartsWidget::removeAll() { + while (tabbar_.count() > 1) { + tabbar_.removeTab(1); + } + std::vector all; + for (auto &c : charts_) all.push_back(c.get()); + for (auto c : all) removeChart(c); + tab_charts_.clear(); + zoomReset(); +} + +void ChartsWidget::handleEvents() { + // the mouse back button undoes a zoom; there is no swipe-back gesture + if (ImGui::IsMouseClicked(3) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows)) { + zoom_undo_stack_.undo(); + } + if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow)) { + if (chartDragActive()) cancelChartDrag(); + showValueTip(-1); + } + + // route all mouse events to the chart drag, even when the source chart is hidden by a tab switch + if (chartDragActive()) { + if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + dragChartMove(ImGui::GetMousePos()); + } else { + dragChartRelease(ImGui::GetMousePos()); + } + } + + if (!value_tip_visible_) return; + + // The tip is drawn without an input item, so the mouse is never "on the tip". + const ImVec2 delta = ImGui::GetIO().MouseDelta; + if (!any_plot_hovered_ && + (delta.x != 0 || delta.y != 0 || !ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows))) { + showValueTip(-1); // the mouse moved off the plot or out of the charts window + } +} + +void ChartsWidget::draw() { + deleted_charts_.clear(); + ImGui::PushID(this); + if (auto_scroll_timer_active_ && ImGui::GetTime() >= auto_scroll_timer_next_) { + auto_scroll_timer_next_ = ImGui::GetTime() + 0.05; + doAutoScroll(); + } + // the drop target and indicator must be resolved before the charts are painted, otherwise the highlight + // lags a frame behind the target used on release and the drop lands on the wrong chart + handleEvents(); + + drawToolBar(); + tabbar_.draw(); + + any_plot_hovered_ = false; + if (ImGui::BeginChild("charts_scroll", ImVec2(0, 0), ImGuiChildFlags_None, 0)) { + charts_scroll_ = ImGui::GetCurrentWindow(); + charts_scroll_viewport_ = charts_scroll_->InnerRect; + charts_container_.draw(); + } + ImGui::EndChild(); + + drawDragPreview(); + + if (signal_selector_ && !signal_selector_->draw()) { + auto dlg = std::move(signal_selector_); + auto accepted = std::move(signal_selector_accepted_); + signal_selector_owner_ = nullptr; + if (dlg->accepted() && accepted) accepted(*dlg); + } + ImGui::PopID(); +} + +void ChartsContainer::draw() { + const ImVec2 start = ImGui::GetCursorScreenPos(); + const float width_avail = ImGui::GetContentRegionAvail().x; + geometry_ = ImRect(start, start + ImVec2(width_avail, 0)); + charts_widget_->updateLayout(); + + const int n = std::max(charts_widget_->current_column_count_, 1); + const float spacing = ImGui::GetStyle().ItemSpacing.x; + const float width = (geometry_.GetWidth() - (n - 1) * spacing) / n; + const ImVec2 origin = ImGui::GetCursorScreenPos(); + auto current_charts = charts_widget_->currentCharts(); // copy: drawing may remove charts + float bottom = origin.y; + const bool aligned = ImPlot::BeginAlignedPlots("charts_align", true); + for (int i = 0; i < current_charts.size(); ++i) { + ImVec2 pos = origin + ImVec2((i % n) * (width + spacing), (i / n) * (settings.chart_height + ImGui::GetStyle().ItemSpacing.y)); + ImGui::SetCursorScreenPos(pos); + current_charts[i]->draw(width); + bottom = std::max(bottom, pos.y + settings.chart_height); + if (current_charts[i]->plotHovered()) charts_widget_->any_plot_hovered_ = true; // the window must be hovered too + } + if (aligned) ImPlot::EndAlignedPlots(); + ImGui::SetCursorScreenPos(ImVec2(origin.x, bottom)); + ImGui::Dummy(ImVec2(geometry_.GetWidth(), ImGui::GetStyle().ItemSpacing.y)); + geometry_.Max.y = bottom + ImGui::GetStyle().ItemSpacing.y; + drawDropIndicator(); +} + +void ChartsContainer::drawDropIndicator() { + if (!(drop_indicator_pos_.x == 0 && drop_indicator_pos_.y == 0) && !childAt(drop_indicator_pos_)) { + ImRect r = geometry_; + r.Max.y = r.Min.y + ImGui::GetStyle().ItemSpacing.y; + if (auto insert_after = getDropAfter(drop_indicator_pos_)) { + float h = r.GetHeight(); + r.Min.y = insert_after->rect().Max.y; + r.Max.y = r.Min.y + h; + } + + ImGui::GetWindowDrawList()->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_Header), ImGui::GetStyle().FrameRounding); + } +} + +ChartView *ChartsContainer::getDropAfter(const ImVec2 &pos) const { + const auto &charts = charts_widget_->currentCharts(); + auto it = std::find_if(charts.crbegin(), charts.crend(), [&pos](auto c) { + const ImRect &area = c->rect(); + return pos.x >= area.Min.x && pos.x <= area.Max.x && pos.y >= area.Max.y; + }); + return it == charts.crend() ? nullptr : *it; +} + +ChartView *ChartsContainer::childAt(const ImVec2 &pos) const { + for (auto c : charts_widget_->currentCharts()) { + if (c->rect().Contains(pos)) return c; + } + return nullptr; +} diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.h b/openpilot/tools/cabana/ui/chart/chartswidget.h new file mode 100644 index 0000000000..16e1700631 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chartswidget.h @@ -0,0 +1,165 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/ui/chart/signalselector.h" +#include "tools/cabana/ui/widgets/tabbar.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/util.h" + +const int CHART_MIN_WIDTH = 300; + +// a slider whose value is mapped onto a log10 scale +class LogSlider { +public: + LogSlider(double factor) : scale_(factor) {} + + void setRange(double min, double max) { + scale_.setRange(min, max); + min_ = min; + max_ = max; + setValue(pos_); // the raw position is re-mapped as a value + } + int value() const { return scale_.value(pos_, minimum(), maximum()); } + void setValue(int v) { pos_ = scale_.position(v, minimum(), maximum()); } + int minimum() const { return min_; } + int maximum() const { return max_; } + bool draw(const char *label, float width); + +private: + LogScale scale_; + int min_ = 0; + int max_ = 1; + int pos_ = 0; +}; + +class ChartView; +class ChartsWidget; + +class ChartsContainer { +public: + ChartsContainer(ChartsWidget *parent) : charts_widget_(parent) {} + void setDropIndicator(const ImVec2 &pt) { drop_indicator_pos_ = pt; } + void draw(); // grid layout of the current tab's charts + ChartView *getDropAfter(const ImVec2 &pos) const; + ChartView *childAt(const ImVec2 &pos) const; + const ImRect &geometry() const { return geometry_; } // screen coordinates + +private: + void drawDropIndicator(); + + ImRect geometry_; + ChartsWidget *charts_widget_; + ImVec2 drop_indicator_pos_; +}; + +class ChartsWidget { +public: + ChartsWidget(); + ~ChartsWidget(); // out of line: the header users only see a forward declared ChartView + void draw(); // content only; MainWindow owns the dockable panel + size_t chartCount() const { return charts_.size(); } + void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); + inline bool hasSignal(const MessageId &id, const cabana::Signal *sig) { return findChart(id, sig) != nullptr; } + std::vector serializeChartIds() const; + void restoreChartsFromIds(const std::vector &chart_ids); + std::string whatsThis() const; + + void setColumnCount(int n); + void removeAll(); + + Observable<> seriesChanged; + Observable showTip; + +private: + void handleEvents(); // the back button, focus loss, the chart drag and the value tip leave + void newChart(); + ChartView *createChart(int pos = 0); + void removeChart(ChartView *chart); + void splitChart(ChartView *chart); + ImRect chartVisibleRect(ChartView *chart); + void eventsMerged(const MessageEventsMap &new_events); + void updateState(); + void zoomReset(); + void startChartDrag(ChartView *chart, const ImVec2 &global_pos); + void dragChartMove(const ImVec2 &global_pos); + void dragChartRelease(const ImVec2 &global_pos); + void cancelChartDrag(); + bool chartDragActive() const { return drag_.source != nullptr; } + void startAutoScroll(const ImVec2 &global_pos); + void stopAutoScroll(); + void doAutoScroll(); + void drawToolBar(); + void updateTabBar(); + void setMaxChartRange(int value); + void updateLayout(); + void settingChanged(); + void showValueTip(double sec); + void newTab(); + void removeTab(int index); + inline std::vector ¤tCharts() { return tab_charts_[tabbar_.tabData(tabbar_.currentIndex())]; } + ChartView *findChart(const MessageId &id, const cabana::Signal *sig); + // draws the selector until closed, then runs `accepted` (unless `owner` was removed) + void execSignalSelector(std::unique_ptr dlg, ChartView *owner, std::function accepted); + void drawDragPreview(); + + LogSlider range_slider_{1000}; + + UndoStack zoom_undo_stack_; + + std::vector> charts_; + std::unordered_map> tab_charts_; + TabBar tabbar_; + ChartsContainer charts_container_{this}; + ImGuiWindow *charts_scroll_ = nullptr; // the scroll area child window + ImRect charts_scroll_viewport_; + int max_chart_range_ = 0; + std::pair display_range_; + bool columns_action_visible_ = false; + int column_count_ = 1; + int current_column_count_ = 0; + struct ChartDrag { + ChartView *source = nullptr; + ImVec2 press_pos; // global + bool active = false; + } drag_; + // the drag preview is a 50% alpha copy of the whole chart tile, drawn in a window that takes no input + ImVec2 drag_preview_pos_; + ImVec2 drag_preview_size_; + bool drag_preview_visible_ = false; + ChartView *drop_target_ = nullptr; + int auto_scroll_count_ = 0; + ImVec2 auto_scroll_pos_; + bool auto_scroll_timer_active_ = false; + double auto_scroll_timer_next_ = 0; + bool value_tip_visible_ = false; + bool any_plot_hovered_ = false; + std::vector> deleted_charts_; // freed at the start of the next draw() + std::unique_ptr signal_selector_; + ChartView *signal_selector_owner_ = nullptr; + std::function signal_selector_accepted_; + Connections connections_; + friend class ChartView; + friend class ChartsContainer; +}; + +class ZoomCommand : public UndoCommand { +public: + ZoomCommand(std::pair range) : range(range) { + prev_range = can->timeRange(); + } + void undo() override { can->setTimeRange(prev_range); } + void redo() override { can->setTimeRange(range); } + std::optional> prev_range, range; +}; diff --git a/openpilot/tools/cabana/ui/chart/downsample.h b/openpilot/tools/cabana/ui/chart/downsample.h new file mode 100644 index 0000000000..e523125b62 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/downsample.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace chart { + +// Keep endpoints and both extrema in time order in each pixel column. Only rendering +// uses this envelope; calculations, cursor values and exports retain every sample. +template +auto pixelEnvelope(Iterator begin, Iterator end, double min, double max, int pixels) { + using Point = typename std::iterator_traits::value_type; + std::vector result; + if (begin == end || pixels <= 0 || max <= min) return result; + result.reserve(std::min(end - begin, (size_t)pixels * 4)); + auto column = [&](const auto &p) { + return (int)std::clamp((p.x - min) / (max - min) * pixels, 0.0, double(pixels - 1)); + }; + while (begin != end) { + auto last = begin, low = begin, high = begin, next = begin + 1; + const int bucket = column(*begin); + while (next != end && column(*next) == bucket) { + if (next->y < low->y) low = next; + if (next->y > high->y) high = next; + last = next++; + } + std::array selected{begin, low, high, last}; + std::sort(selected.begin(), selected.end()); + auto unique_end = std::unique(selected.begin(), selected.end()); + for (auto it = selected.begin(); it != unique_end; ++it) result.push_back(**it); + begin = next; + } + return result; +} + +} // namespace chart diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc new file mode 100644 index 0000000000..2ea6d70121 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -0,0 +1,160 @@ +#include "tools/cabana/ui/chart/signalselector.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/chart/chart.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +SignalSelector::SignalSelector(std::string title) : title_(std::move(title)) { + std::set ids; + for (const auto &[id, _] : can->eventsMap()) ids.insert(id); + for (const auto &[id, _] : can->lastMessages()) ids.insert(id); + for (const auto &id : ids) { + if (auto m = dbc()->msg(id)) { + msgs_combo_.push_back({m->name + " (" + id.toString() + ")", id}); + } + } + std::sort(msgs_combo_.begin(), msgs_combo_.end(), [](auto &a, auto &b) { return a.text < b.text; }); +} + +bool SignalSelector::draw() { + if (!open_) return false; + const std::string popup_id = title_ + "###SignalSelector"; + if (!show_) { + ImGui::OpenPopup(popup_id.c_str()); + show_ = true; + } + setNextDialogWindow(ImVec2(700.0f, 450.0f)); + if (!ImGui::BeginPopupModal(popup_id.c_str(), nullptr, ImGuiWindowFlags_NoSavedSettings)) { + open_ = false; + return false; + } + + const float btn_w = iconButtonWidth(); + const float column_w = (ImGui::GetContentRegionAvail().x - btn_w - ImGui::GetStyle().ItemSpacing.x * 2) / 2; + // the selected list spans the combo row too; both lists end above the Ok/Cancel row + const float lists_h = ImGui::GetContentRegionAvail().y - ImGui::GetFrameHeightWithSpacing() * 3; + + ImGui::BeginGroup(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Available Signals"); + // a combo popup with a filter box + const char *preview = msgs_combo_index_ >= 0 ? msgs_combo_[msgs_combo_index_].text.c_str() : "Select a message..."; + ImGui::SetNextItemWidth(column_w); + if (dropdown::BeginCombo("##msgs_combo", preview)) { + if (ImGui::IsWindowAppearing()) { + msgs_combo_filter_.clear(); // reopen showing the full list + ImGui::SetKeyboardFocusHere(); + } + ImGui::SetNextItemWidth(-FLT_MIN); + inputText("##msgs_filter", &msgs_combo_filter_, "Select a message..."); + for (int i = 0; i < (int)msgs_combo_.size(); ++i) { + if (!msgs_combo_filter_.empty() && !utils::containsCI(msgs_combo_[i].text, msgs_combo_filter_)) continue; + if (dropdown::Item(msgs_combo_[i].text.c_str(), nullptr, i == msgs_combo_index_)) { + msgs_combo_index_ = i; + updateAvailableList(i); + ImGui::CloseCurrentPopup(); + } + } + dropdown::EndCombo(); + } + bool add_dbl = false; + drawList("##available_list", available_list_, &available_row_, false, &add_dbl, ImVec2(column_w, lists_h)); + ImGui::EndGroup(); + + ImGui::SameLine(); + ImGui::BeginGroup(); + ImGui::Dummy(ImVec2(btn_w, (lists_h + ImGui::GetFrameHeightWithSpacing() * 2) / 2 - ImGui::GetFrameHeight())); + ImGui::BeginDisabled(available_row_ == -1); + bool add_clicked = iconButton("add", icon::CHEVRON_RIGHT, "Add"); + ImGui::EndDisabled(); + ImGui::BeginDisabled(selected_row_ == -1); + bool remove_clicked = iconButton("remove", icon::CHEVRON_LEFT, "Remove"); + ImGui::EndDisabled(); + ImGui::EndGroup(); + + ImGui::SameLine(); + ImGui::BeginGroup(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Selected Signals"); + bool remove_dbl = false; + drawList("##selected_list", selected_list_, &selected_row_, true, &remove_dbl, ImVec2(column_w, lists_h + ImGui::GetFrameHeightWithSpacing())); + bool rejected = false; + dialogButtons("OK", &accepted_, &rejected); + const bool done = accepted_ || rejected; + ImGui::EndGroup(); + + if ((add_dbl || add_clicked) && available_row_ >= 0 && available_row_ < (int)available_list_.size()) { + add(available_row_); + } else if ((remove_dbl || remove_clicked) && selected_row_ >= 0 && selected_row_ < (int)selected_list_.size()) { + remove(selected_row_); + } + + if (done) { + open_ = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + return open_; +} + +void SignalSelector::drawList(const char *id, std::vector &list, int *current_row, bool show_msg_name, bool *double_clicked, const ImVec2 &size) { + if (!ImGui::BeginListBox(id, size)) return; + for (int i = 0; i < (int)list.size(); ++i) { + const auto &item = list[i]; + ImGui::PushID(i); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + if (selectable("##item", i == *current_row)) *current_row = i; + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + *current_row = i; + *double_clicked = true; + } + // label: colored square, signal name, then the message name/id in gray + ImDrawList *dl = ImGui::GetWindowDrawList(); + float x = pos.x + 5; + drawColorMarker(dl, ImVec2(x, pos.y), toImU32(item.sig->color)); + x += markerSize() + 4; + dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(i == *current_row ? palette().text_selected : palette().text), item.sig->name.c_str()); + if (show_msg_name) { + x += ImGui::CalcTextSize(item.sig->name.c_str()).x; + dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(i == *current_row ? palette().text_selected : palette().text_disabled), msgLabel(item.msg_id).c_str()); + } + ImGui::PopID(); + } + ImGui::EndListBox(); +} + +void SignalSelector::add(int row) { + const auto &item = available_list_[row]; + selected_list_.emplace_back(item.msg_id, item.sig); + available_list_.erase(available_list_.begin() + row); + available_row_ = -1; +} + +void SignalSelector::remove(int row) { + const auto &item = selected_list_[row]; + if (msgs_combo_index_ >= 0 && item.msg_id == msgs_combo_[msgs_combo_index_].id) { + available_list_.emplace_back(item.msg_id, item.sig); + } + selected_list_.erase(selected_list_.begin() + row); + selected_row_ = -1; +} + +void SignalSelector::updateAvailableList(int index) { + if (index == -1) return; + available_list_.clear(); + available_row_ = -1; + MessageId msg_id = msgs_combo_[index].id; + for (auto s : dbc()->msg(msg_id)->getSignals()) { + bool is_selected = std::any_of(selected_list_.begin(), selected_list_.end(), + [sig = s, &msg_id](auto &it) { return it.msg_id == msg_id && it.sig == sig; }); + if (!is_selected) { + available_list_.emplace_back(msg_id, s); + } + } +} diff --git a/openpilot/tools/cabana/ui/chart/signalselector.h b/openpilot/tools/cabana/ui/chart/signalselector.h new file mode 100644 index 0000000000..675b6f0039 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/signalselector.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" + +// non-blocking: open(), draw() every frame until it returns false, then check accepted() +class SignalSelector { +public: + struct ListItem { + ListItem(const MessageId &msg_id, const cabana::Signal *sig) : msg_id(msg_id), sig(sig) {} + MessageId msg_id; + const cabana::Signal *sig; + }; + + SignalSelector(std::string title); + const std::vector &selectedItems() const { return selected_list_; } + inline void addSelected(const MessageId &id, const cabana::Signal *sig) { selected_list_.emplace_back(id, sig); } + void open() { open_ = true; show_ = false; accepted_ = false; } + bool draw(); // false once the dialog is closed + bool accepted() const { return accepted_; } + +private: + void updateAvailableList(int index); + void add(int row); + void remove(int row); + void drawList(const char *id, std::vector &list, int *current_row, bool show_msg_name, bool *double_clicked, const ImVec2 &size); + + struct ComboItem { + std::string text; + MessageId id; + }; + std::string title_; + std::vector msgs_combo_; + int msgs_combo_index_ = -1; + std::string msgs_combo_filter_; + std::vector available_list_; + std::vector selected_list_; + int available_row_ = -1; + int selected_row_ = -1; + bool accepted_ = false; + bool open_ = false; + bool show_ = false; +}; diff --git a/openpilot/tools/cabana/ui/chart/sparkline.cc b/openpilot/tools/cabana/ui/chart/sparkline.cc new file mode 100644 index 0000000000..d9e5702fb5 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/sparkline.cc @@ -0,0 +1,163 @@ +#include "tools/cabana/ui/chart/sparkline.h" + +#include +#include +#include + +#include "tools/cabana/ui/util.h" + +void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, ImVec2 sz, + double window_end) { + if (first == last || sz.x <= 0 || sz.y <= 0) { + render_points_.clear(); + size = {}; + return; + } + + points_.clear(); + min_val = std::numeric_limits::max(); + max_val = std::numeric_limits::lowest(); + points_.reserve(std::distance(first, last)); + + // x runs from the start of the time window, not from the first sample in it: the oldest sample drops + // out at its own rate, so anchoring to it slid the whole curve sideways by the sample spacing on every + // update and the sparkline jittered left and right instead of scrolling with the clock + const double window_start = window_end - range; + double value = 0.0; + for (auto it = first; it != last; ++it) { + if (sig->getValue((*it)->dat, (*it)->size, &value)) { + double x = can->toSeconds((*it)->mono_time) - window_start; + // the caller hands over a bit of data from before the window so the oldest samples walk out under + // the clip rect instead of vanishing at the left edge; they must not move the scale though + if (x >= 0.0) { + min_val = std::min(min_val, value); + max_val = std::max(max_val, value); + } + points_.push_back({x, value}); + } + } + if (min_val > max_val) { // nothing inside the window, the lead in is all there is + for (const auto &p : points_) { + min_val = std::min(min_val, p.y); + max_val = std::max(max_val, p.y); + } + } + + if (points_.empty()) { + render_points_.clear(); + size = {}; + return; + } + + freq_ = points_.size() / std::max(points_.back().x - points_.front().x, 1.0); + render(sig->color, range, sz, window_end); +} + +void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double window_end) { + bool is_flat_line = min_val == max_val; + if (is_flat_line) { + min_val -= 1.0; + max_val += 1.0; + } + + const double xscale = (sz.x - 1) / (double)range; + // Leave room for the stroke's antialiasing fringe and the 3x3 endpoint marker + // at both extrema, including the half-pixel offset applied by ImGui's strokes. + const double ypadding = std::min(3.0, sz.y / 2.0); + const double yscale = (sz.y - 2 * ypadding) / (max_val - min_val); + const double span = points_.back().x - points_.front().x; + bool draw_individual_points = (span * xscale / points_.size()) > 8.0; + + // transform or downsample the points + render_points_.reserve(points_.size()); + render_points_.clear(); + if (draw_individual_points) { + for (const auto &p : points_) { + render_points_.emplace_back(p.x * xscale, ypadding + (max_val - p.y) * yscale); + } + } else if (is_flat_line) { + double y = sz.y / 2.0; + render_points_.emplace_back(points_.front().x * xscale, y); + render_points_.emplace_back(points_.back().x * xscale, y); + } else { + double prev_y = points_.front().y; + render_points_.emplace_back(points_.front().x * xscale, ypadding + (max_val - prev_y) * yscale); + bool in_flat = false; + + for (size_t i = 1; i < points_.size(); ++i) { + const auto &p = points_[i]; + double y = p.y; + if (std::abs(y - prev_y) < 1e-6) { + in_flat = true; + } else { + if (in_flat) render_points_.emplace_back(points_[i - 1].x * xscale, ypadding + (max_val - prev_y) * yscale); + render_points_.emplace_back(p.x * xscale, ypadding + (max_val - y) * yscale); + in_flat = false; + } + prev_y = y; + } + if (in_flat) render_points_.emplace_back(points_.back().x * xscale, ypadding + (max_val - prev_y) * yscale); + } + + size = sz; + color_ = color; + draw_individual_points_ = draw_individual_points; + window_end_ = window_end; + xscale_ = xscale; +} + +void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos, ImU32 color) const { + if (render_points_.empty()) return; + // Pick up theme changes while paused. + if (color == 0) color = toImU32(sparklineColor(color_)); + + // update() only runs when a message of this id arrives, so a slow message would hold the sparkline + // still for many frames and then move it in one step. scroll the rendered polyline by the time that + // has passed since it was built, which keeps the motion at the frame rate whatever the message rate is + const float shift = std::clamp((float)((can->currentSec() - window_end_) * xscale_), 0.0f, size.x); + + // physical pixels: the framebuffer is 2x the logical size on hidpi displays + const float px = 1.0f / std::max(1.0f, ImGui::GetIO().DisplayFramebufferScale.x); + auto snap = [&](float x) { return std::floor(x / px) * px; }; + + // a sample sits at t * xscale + k on screen, where k moves with the clock. snapping k to whole pixels + // keeps every sample's subpixel phase fixed to its timestamp, so the line looks identical from frame + // to frame and from update to update and only ever moves by whole pixels; moving it by fractions + // shifted the antialiasing coverage every frame and the thin peaks sparkled + ImVec2 offset(pos.x - shift, pos.y); + const double k = offset.x - (window_end_ * xscale_ - (size.x - 1)); + offset.x += snap(k) - k; + auto point_at = [&](const ImVec2 &p) { return ImVec2(offset.x + p.x, offset.y + p.y); }; + + draw_list->PushClipRect(pos, ImVec2(pos.x + size.x, pos.y + size.y), true); + + // a point is a 3x3 square + auto draw_point = [&](const ImVec2 &p) { draw_list->AddRectFilled(ImVec2(p.x - 1.5f, p.y - 1.5f), ImVec2(p.x + 1.5f, p.y + 1.5f), color); }; + + // Keep ordinary curves joined so short segments don't leave antialiasing seams. + // Split at turns sharper than 120 degrees: a joined narrow spike folds back on + // itself and loses its tip. Both paths retain the actual peak and every sample. + const float thickness = 1.5f; + for (size_t i = 0; i < render_points_.size(); ++i) { + const auto &p = render_points_[i]; + draw_list->PathLineTo(point_at(p)); + if (i > 0 && i + 1 < render_points_.size()) { + const auto &prev = render_points_[i - 1]; + const auto &next = render_points_[i + 1]; + const double ax = p.x - prev.x, ay = p.y - prev.y; + const double bx = next.x - p.x, by = next.y - p.y; + const double dot = ax * bx + ay * by; + if (dot < 0 && 4 * dot * dot > (ax * ax + ay * ay) * (bx * bx + by * by)) { + draw_list->PathStroke(color, ImDrawFlags_None, thickness); + draw_list->PathLineTo(point_at(p)); + } + } + } + draw_list->PathStroke(color, ImDrawFlags_None, thickness); + if (draw_individual_points_) { + for (const auto &p : render_points_) draw_point(point_at(p)); + } else { + draw_point(point_at(render_points_.back())); + } + draw_list->PopClipRect(); +} diff --git a/openpilot/tools/cabana/ui/chart/sparkline.h b/openpilot/tools/cabana/ui/chart/sparkline.h new file mode 100644 index 0000000000..9d5420f4b6 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/sparkline.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbc.h" +#include "tools/cabana/streams/abstractstream.h" + +class Sparkline { +public: + void update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, ImVec2 sz, double window_end); + inline double freq() const { return freq_; } + bool isEmpty() const { return render_points_.empty(); } + // emits the rendered polyline at pos (top-left, screen coordinates) + void draw(ImDrawList *draw_list, ImVec2 pos, ImU32 color = 0) const; + + ImVec2 size = {}; // empty when isEmpty() + double min_val = 0; + double max_val = 0; + +private: + struct Point { + double x, y; + }; + void render(const CabanaColor &color, int range, ImVec2 sz, double window_end); + + std::vector points_; + std::vector render_points_; + CabanaColor color_; + double window_end_ = 0; // the time the polyline was built for, so draw() can scroll it on from there + double xscale_ = 0; + bool draw_individual_points_ = false; + double freq_ = 0; +}; diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.cc b/openpilot/tools/cabana/ui/chart/tiplabel.cc new file mode 100644 index 0000000000..83c693babf --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/tiplabel.cc @@ -0,0 +1,144 @@ +#define IMGUI_DEFINE_MATH_OPERATORS // ImVec2 arithmetic, must precede imgui.h +#include "tools/cabana/ui/chart/tiplabel.h" + +#include +#include + +#include "tools/cabana/ui/util.h" + +ImVec2 TipLabel::layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) const { + const float font_size = ImGui::GetFontSize(); + const float line_height = std::ceil(ImGui::GetTextLineHeight() + 2); + const float marker = std::floor(markerSize()); + const float gap = 8; + const float width = column_widths_[0] + column_widths_[1] + column_widths_[2] + column_widths_[3] + gap * 3; + const ImU32 muted = ImGui::GetColorU32(ImGuiCol_TextDisabled); + float y = std::round(origin.y); + auto draw = [&](float x, const std::string &text, ImU32 color) { + if (p) p->AddText(ImVec2(std::round(x), y), color, text.c_str()); + }; + const char *heading = !text_.empty() && !text_[0].has_marker ? text_[0].name.c_str() : "Signal"; + const char *headers[] = {heading, "Value", "Min", "Max"}; + if (compact_) { + const float right = origin.x + std::max(0.0f, area_.GetWidth() - MARGIN * 2 - 1); + auto cell = [&](float left, const std::string &text, ImU32 color) { + if (p) drawElidedText(p, ImRect(ImVec2(left, y), ImVec2(right, y + font_size)), text, color); + }; + cell(origin.x, heading, muted); + y += line_height; + for (const auto &line : text_) { + if (!line.has_marker) continue; + if (p) p->AddRectFilled(ImVec2(origin.x, y + (font_size - marker) * 0.5f), + ImVec2(origin.x + marker, y + (font_size + marker) * 0.5f), line.marker); + cell(origin.x + marker + 6, line.name, fg); + y += line_height; + const std::string *values[] = {&line.value, &line.min, &line.max}; + const float label_width = ImGui::CalcTextSize("Value").x + gap; + for (int i = 0; i < 3; ++i) { + draw(origin.x, headers[i + 1], muted); + pushMonoFont(font_size); + cell(origin.x + label_width, *values[i], i == 0 ? fg : muted); + popMonoFont(); + y += line_height; + } + } + return ImVec2(right - origin.x, y - origin.y); + } + float x = origin.x; + for (int i = 0; i < 4; ++i) { + draw(i ? x + column_widths_[i] - ImGui::CalcTextSize(headers[i]).x : x, headers[i], muted); + x += column_widths_[i] + gap; + } + y += line_height; + if (p) p->AddLine(ImVec2(origin.x, y - 2), ImVec2(origin.x + width, y - 2), ImGui::GetColorU32(ImGuiCol_Border)); + for (const auto &line : text_) { + if (!line.has_marker) continue; + if (p) { + const ImVec2 marker_pos(std::round(origin.x), std::round(y + (font_size - marker) * 0.5f)); + p->AddRectFilled(marker_pos, marker_pos + ImVec2(marker, marker), line.marker); + } + if (p) drawElidedText(p, ImRect(ImVec2(origin.x + marker + 6, y), + ImVec2(origin.x + column_widths_[0], y + font_size)), line.name, fg); + x = origin.x + column_widths_[0] + gap; + pushMonoFont(font_size); + const std::string *values[] = {&line.value, &line.min, &line.max}; + for (int i = 0; i < 3; ++i) { + draw(x + column_widths_[i + 1] - ImGui::CalcTextSize(values[i]->c_str()).x, *values[i], i == 0 ? fg : muted); + x += column_widths_[i + 1] + gap; + } + popMonoFont(); + y += line_height; + } + return ImVec2(width, y - origin.y); +} + +ImVec2 TipLabel::sizeHint() const { + return layoutLines(nullptr, ImVec2(0, 0), 0) + ImVec2(MARGIN * 2, MARGIN * 2); +} + +void TipLabel::showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect) { + bool same_signals = text.size() == text_.size(); + for (size_t i = 1; same_signals && i < text.size(); ++i) same_signals = text[i].name == text_[i].name; + if (!same_signals) column_widths_ = {}; + text_ = text; + anchor_ = pt; + area_ = rect; + visible_ = !text_.empty(); +} + +void TipLabel::updateLayout() { + // Playback notifications can update the text outside an ImGui window scope. + // Measure and position it only while the owning chart is being drawn. + column_widths_[0] = std::max(column_widths_[0], ImGui::CalcTextSize(text_.empty() ? "Signal" : text_[0].name.c_str()).x); + for (const auto &line : text_) { + if (line.has_marker) column_widths_[0] = std::max(column_widths_[0], std::ceil(markerSize() + 6 + ImGui::CalcTextSize(line.name.c_str()).x)); + } + pushMonoFont(ImGui::GetFontSize()); + const float number_width = std::ceil(ImGui::CalcTextSize("-0.00000").x); + for (int i = 1; i < 4; ++i) column_widths_[i] = std::max(column_widths_[i], number_width); + for (const auto &line : text_) { + const std::string *values[] = {&line.value, &line.min, &line.max}; + for (int i = 0; i < 3; ++i) column_widths_[i + 1] = std::max(column_widths_[i + 1], std::ceil(ImGui::CalcTextSize(values[i]->c_str()).x)); + } + popMonoFont(); + const ImGuiViewport *viewport = ImGui::GetWindowViewport(); + ImRect bounds(viewport->WorkPos, viewport->WorkPos + viewport->WorkSize); + bounds.ClipWith(area_); + if (bounds.GetWidth() <= 0 || bounds.GetHeight() <= 0) { + visible_ = false; + return; + } + area_ = bounds; + const float numeric_width = column_widths_[1] + column_widths_[2] + column_widths_[3] + 24 + MARGIN * 2 + 1; + compact_ = bounds.GetWidth() < numeric_width + ImGui::GetFontSize() * 4; + column_widths_[0] = std::min(column_widths_[0], std::max(40.0f, std::min(ImGui::GetFontSize() * 16, bounds.GetWidth() - numeric_width))); + if (!text_.empty()) { + ImVec2 extra(1, 1); + size_ = sizeHint() + extra; + ImVec2 tip_pos(anchor_.x + 8, area_.Min.y + 2); + if (anchor_.x >= area_.GetCenter().x) { + tip_pos.x = anchor_.x - size_.x - 8; + } + tip_pos.x = std::clamp(tip_pos.x, bounds.Min.x, std::max(bounds.Min.x, bounds.Max.x - size_.x)); + tip_pos.y = std::clamp(tip_pos.y, bounds.Min.y, std::max(bounds.Min.y, bounds.Max.y - size_.y)); + pos_ = tip_pos; + visible_ = true; + return; + } + visible_ = false; +} + +void TipLabel::draw(const ImRect &rect) { + if (!visible_) return; + area_ = rect; + updateLayout(); + if (!visible_) return; + + ImDrawList *p = ImGui::GetWindowDrawList(); + p->PushClipRect(area_.Min, area_.Max, true); + // filled panel with a 1px frame + p->AddRectFilled(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_PopupBg), ImGui::GetStyle().PopupRounding); + p->AddRect(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_Border), ImGui::GetStyle().PopupRounding); + layoutLines(p, pos_ + ImVec2(MARGIN, MARGIN), ImGui::GetColorU32(ImGuiCol_Text)); + p->PopClipRect(); +} diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.h b/openpilot/tools/cabana/ui/chart/tiplabel.h new file mode 100644 index 0000000000..7128a56384 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/tiplabel.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +// A signal row, or a time heading when has_marker is false. +struct TipLine { + bool has_marker = false; + ImU32 marker = 0; + std::string name; + std::string value, min, max; +}; + +class TipLabel { +public: + void showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect); + void hide() { visible_ = false; } + bool isVisible() const { return visible_; } + void draw(const ImRect &rect); // call inside the owning chart window, after drawing the plot + +private: + // lays the lines out from origin, drawing them when p is given; returns the size of the text block + ImVec2 layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) const; + ImVec2 sizeHint() const; + void updateLayout(); + + static constexpr float MARGIN = 6.0f; + std::vector text_; + std::array column_widths_{}; + ImVec2 anchor_; + ImRect area_; + ImVec2 pos_; + ImVec2 size_; + bool compact_ = false; + bool visible_ = false; +}; diff --git a/openpilot/tools/cabana/ui/dialogs/filedialog.cc b/openpilot/tools/cabana/ui/dialogs/filedialog.cc new file mode 100644 index 0000000000..c03dcad205 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/filedialog.cc @@ -0,0 +1,224 @@ +#include "tools/cabana/ui/dialogs/filedialog.h" + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" + +namespace fs = std::filesystem; + +namespace FileDialog { + +namespace { + +// case insensitive, numeric aware collation ("mazda_3_2019" before "mazda_2017"): punctuation is ignored at +// the first level ("FORD_CADS_64" before "FORD_CADS.dbc"), digit runs compare numerically, case is ignored; +// ties fall back to a plain comparison +bool naturalLess(const std::string &a, const std::string &b) { + auto skip = [](const std::string &s, size_t &i) { + while (i < s.size() && !isalnum(static_cast(s[i]))) ++i; + }; + size_t i = 0, j = 0; + for (;;) { + skip(a, i); + skip(b, j); + if (i >= a.size() || j >= b.size()) break; + if (isdigit(static_cast(a[i])) && isdigit(static_cast(b[j]))) { + size_t ie = i, je = j; + while (ie < a.size() && isdigit(static_cast(a[ie]))) ++ie; + while (je < b.size() && isdigit(static_cast(b[je]))) ++je; + const unsigned long long na = std::stoull(a.substr(i, ie - i)), nb = std::stoull(b.substr(j, je - j)); + if (na != nb) return na < nb; + i = ie; + j = je; + } else { + const int ca = tolower(static_cast(a[i])), cb = tolower(static_cast(b[j])); + if (ca != cb) return ca < cb; + ++i; + ++j; + } + } + const bool a_done = i >= a.size(), b_done = j >= b.size(); + if (a_done != b_done) return a_done; + return a < b; +} + +enum class Mode { OpenFile, SaveFile, Directory }; + +struct State { + bool active = false; + Mode mode = Mode::OpenFile; + std::string title; + std::string extension; + fs::path dir; + std::string dir_input; + std::string filename; + std::vector entries; + Callback callback; +}; + +State g_state; +PopupOwner g_owner; + +void listDir() { + State &s = g_state; + s.entries.clear(); + std::error_code ec; + for (const auto &entry : fs::directory_iterator(s.dir, ec)) { + const std::string name = entry.path().filename().string(); + if (name.empty() || name[0] == '.') continue; + const bool is_dir = entry.is_directory(ec); + if (!is_dir && s.mode == Mode::Directory) continue; + if (!is_dir && !s.extension.empty() && entry.path().extension() != s.extension) continue; + s.entries.push_back(entry); + } + std::sort(s.entries.begin(), s.entries.end(), [](const auto &a, const auto &b) { + std::error_code sort_ec; + const bool da = a.is_directory(sort_ec), db = b.is_directory(sort_ec); + return da != db ? da : naturalLess(a.path().filename().string(), b.path().filename().string()); + }); + s.dir_input = s.dir.string(); +} + +void setDir(const fs::path &dir) { + std::error_code ec; + fs::path d = fs::is_directory(dir, ec) ? fs::absolute(dir, ec) : fs::current_path(ec); + g_state.dir = d.lexically_normal(); + listDir(); +} + +void start(Mode mode, const std::string &title, const fs::path &dir, const std::string &filename, + const std::string &extension, Callback cb) { + State &s = g_state; + s = State{}; + s.active = true; + s.mode = mode; + s.title = title; + s.extension = extension; + s.filename = filename; + s.callback = std::move(cb); + g_owner.reset(); + setDir(dir); +} + +void finish(const std::string &path) { + Callback cb = std::move(g_state.callback); + g_state = State{}; + g_owner.reset(); + if (cb) cb(path); +} + +void accept(const fs::path &path) { + if (g_state.mode == Mode::SaveFile) { + std::error_code ec; + if (fs::exists(path, ec)) { + const std::string name = path.filename().string(); + MessageBox::question(g_state.title, name + " already exists.\nDo you want to replace it?", [path](bool ok) { + if (ok) finish(path.string()); + }); + return; + } + } + finish(path.string()); +} + +} // namespace + +void getOpenFileName(const std::string &title, const std::string &dir, const std::string &extension, Callback cb) { + start(Mode::OpenFile, title, dir, "", extension, std::move(cb)); +} + +void getSaveFileName(const std::string &title, const std::string &default_path, const std::string &extension, Callback cb) { + const fs::path p(default_path); + start(Mode::SaveFile, title, p.parent_path(), p.filename().string(), extension, std::move(cb)); +} + +void getExistingDirectory(const std::string &title, const std::string &dir, Callback cb) { + start(Mode::Directory, title, dir, "", "", std::move(cb)); +} + +void draw() { + State &s = g_state; + if (!s.active) return; + const std::string popup_id = s.title + "###FileDialog"; + if (!beginDialog(popup_id.c_str(), &g_owner, ImVec2(640.0f, 480.0f), 0)) return; + + if (ImGui::Button("Up")) setDir(s.dir.parent_path()); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + if (inputText("##dir", &s.dir_input, "", ImGuiInputTextFlags_EnterReturnsTrue)) setDir(s.dir_input); + + const float footer = ImGui::GetFrameHeightWithSpacing() * (s.mode == Mode::Directory ? 1.0f : 2.0f) + ImGui::GetStyle().ItemSpacing.y; + bool ok = false, cancel = false; + fs::path result, pending_dir; + ImGui::BeginChild("entries", ImVec2(0, -footer), ImGuiChildFlags_Borders); + std::error_code dir_ec; + for (size_t i = 0; i < s.entries.size(); ++i) { + const auto &entry = s.entries[i]; + const bool is_dir = entry.is_directory(dir_ec); + const std::string name = entry.path().filename().string(); + const std::string label = (is_dir ? std::string(icon::FOLDER) : std::string(icon::FILE_EARMARK)) + " " + name; + ImGui::PushID(static_cast(i)); + const bool selected = !is_dir && name == s.filename; + if (selectable(label.c_str(), selected, ImGuiSelectableFlags_AllowDoubleClick)) { + const bool double_clicked = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); + if (is_dir) { + if (double_clicked) { + pending_dir = entry.path(); + } else if (s.mode == Mode::Directory) { + s.filename = name; + } + } else { + s.filename = name; + if (double_clicked && s.mode == Mode::OpenFile) { + result = entry.path(); + ok = true; + } + } + } + ImGui::PopID(); + if (ok || !pending_dir.empty()) break; + } + ImGui::EndChild(); + if (!pending_dir.empty()) setDir(pending_dir); + + if (s.mode != Mode::Directory) { + const std::string filter = s.extension.empty() ? "*" : "*" + s.extension; + ImGui::SetNextItemWidth(-(ImGui::CalcTextSize(filter.c_str()).x + ImGui::GetStyle().ItemSpacing.x)); + if (inputText("##name", &s.filename, "File name", ImGuiInputTextFlags_EnterReturnsTrue)) ok = true; + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("%s", filter.c_str()); + } + const char *accept_label = s.mode == Mode::SaveFile ? "Save" : (s.mode == Mode::Directory ? "Choose" : "Open"); + dialogButtons(accept_label, &ok, &cancel); + + if (ok && result.empty()) { + if (s.mode == Mode::Directory) { + result = s.filename.empty() ? s.dir : s.dir / s.filename; + } else if (!s.filename.empty()) { + result = fs::path(s.filename).is_absolute() ? fs::path(s.filename) : s.dir / s.filename; + if (s.mode == Mode::SaveFile && !s.extension.empty() && result.extension().empty()) result += s.extension; + if (s.mode == Mode::OpenFile && !fs::is_regular_file(result, dir_ec)) ok = false; + } else { + ok = false; + } + } + if (ok || cancel) ImGui::CloseCurrentPopup(); + // nested so the overwrite prompt stacks on this dialog; it may finish the dialog through accept() + MessageBox::draw(); + if (!s.active) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (cancel) { + finish(""); + } else if (ok) { + accept(result); + } +} + +} // namespace FileDialog diff --git a/openpilot/tools/cabana/ui/dialogs/filedialog.h b/openpilot/tools/cabana/ui/dialogs/filedialog.h new file mode 100644 index 0000000000..529f223179 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/filedialog.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include +#include + +// file browser. One dialog at a time; the callback gets an empty path on cancel. +namespace FileDialog { + +using Callback = std::function; + +void getOpenFileName(const std::string &title, const std::string &dir, const std::string &extension, Callback cb); +void getSaveFileName(const std::string &title, const std::string &default_path, const std::string &extension, Callback cb); +void getExistingDirectory(const std::string &title, const std::string &dir, Callback cb); + +void draw(); // once per frame, at the top popup level + +} // namespace FileDialog diff --git a/openpilot/tools/cabana/ui/dialogs/messagebox.cc b/openpilot/tools/cabana/ui/dialogs/messagebox.cc new file mode 100644 index 0000000000..bac66037a9 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/messagebox.cc @@ -0,0 +1,87 @@ +#include "tools/cabana/ui/dialogs/messagebox.h" + +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/util.h" + +namespace MessageBox { + +namespace { + +struct Box { + std::string title; + std::string text; + std::string detailed_text; + bool has_cancel = false; + std::function on_result; +}; + +std::deque g_queue; +bool g_show_details = false; +PopupOwner g_owner; + +void push(Box box) { g_queue.push_back(std::move(box)); } + +std::function wrap(std::function on_close) { + if (!on_close) return nullptr; + return [on_close = std::move(on_close)](bool) { on_close(); }; +} + +} // namespace + +void information(const std::string &title, const std::string &text, std::function on_close) { + push({.title = title, .text = text, .on_result = wrap(std::move(on_close))}); +} + +void warning(const std::string &title, const std::string &text, const std::string &detailed_text, + std::function on_close) { + push({.title = title, .text = text, .detailed_text = detailed_text, .on_result = wrap(std::move(on_close))}); +} + +void question(const std::string &title, const std::string &text, std::function on_result) { + push({.title = title, .text = text, .has_cancel = true, .on_result = std::move(on_result)}); +} + +void draw() { + if (g_queue.empty()) return; + Box &box = g_queue.front(); + const std::string popup_id = box.title + "###MessageBox"; + const bool first = g_owner.popup_id == 0; + if (!g_owner.begin(popup_id.c_str())) return; + // AlwaysAutoResize sizes the popup from its contents; keep the title bar text from being clipped + const ImGuiStyle &style = ImGui::GetStyle(); + const float min_width = ImGui::CalcTextSize(box.title.c_str()).x + style.FramePadding.x * 2 + style.WindowPadding.x * 2; + ImGui::SetNextWindowSizeConstraints(ImVec2(min_width, 0.0f), ImVec2(FLT_MAX, FLT_MAX)); + setNextDialogWindow(ImVec2(0.0f, 0.0f)); + if (!ImGui::BeginPopupModal(popup_id.c_str(), nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings)) return; + if (first) g_show_details = false; + bool result = false, done = false; + ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + 480.0f); + ImGui::TextUnformatted(box.text.c_str()); + ImGui::PopTextWrapPos(); + if (g_show_details) { + ImGui::InputTextMultiline("##details", box.detailed_text.data(), box.detailed_text.size() + 1, + ImVec2(480.0f, 160.0f), ImGuiInputTextFlags_ReadOnly); + } + ImGui::Separator(); + if (!box.detailed_text.empty()) { + // the details button sits at the left of the button box + if (ImGui::Button(g_show_details ? "Hide Details" : "Show Details")) g_show_details = !g_show_details; + ImGui::SameLine(); + } + dialogButtons("OK", &result, &done, true, box.has_cancel ? "Cancel" : nullptr); + if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) result = true; + if (result) done = true; + if (done) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (done) { + g_owner.reset(); + Box finished = std::move(g_queue.front()); + g_queue.pop_front(); + if (finished.on_result) finished.on_result(result); + } +} + +} // namespace MessageBox diff --git a/openpilot/tools/cabana/ui/dialogs/messagebox.h b/openpilot/tools/cabana/ui/dialogs/messagebox.h new file mode 100644 index 0000000000..1e30d33ddb --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/messagebox.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +// Boxes are queued and shown one at a time as modal popups; the caller passes a continuation where it +// needs the answer. +namespace MessageBox { + +// on_close (optional) runs when the box is dismissed +void information(const std::string &title, const std::string &text, std::function on_close = nullptr); +void warning(const std::string &title, const std::string &text, const std::string &detailed_text = "", + std::function on_close = nullptr); +// Ok | Cancel; on_result(true) for Ok +void question(const std::string &title, const std::string &text, std::function on_result); + +void draw(); // once per frame, at the top popup level + +} // namespace MessageBox diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc new file mode 100644 index 0000000000..665edb7c98 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -0,0 +1,274 @@ +#include "tools/cabana/ui/dialogs/routesdialog.h" + +#include +#include +#include +#include + +#include "json11/json11.hpp" +#include "tools/replay/py_tools.h" +#include "tools/cabana/ui/theme.h" + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +namespace { +const char *PERIOD_NAMES[] = {"Last week", "Last 2 weeks", "Last month", "Last 6 months", "Preserved"}; +const int PERIOD_DAYS[] = {7, 14, 30, 180, -1}; +} // namespace + +void RoutesDialog::open(std::function on_done) { + if (alive_) return; + on_done_ = std::move(on_done); + open_ = false; + popup_.reset(); + s_ = State{}; + alive_ = std::make_shared(true); + + fetchDevices(); +} + +void RoutesDialog::fetchDevices() { + routes::fetchDevices([this, alive = std::weak_ptr(alive_)](std::vector devices, bool success, int error_code) { + utils::runOnMainThread(utils::guarded(alive.lock(), [this, devices = std::move(devices), success, error_code]() { + setDeviceList(devices, success, error_code); + })); + }); +} + +void RoutesDialog::setDeviceList(const std::vector &devices, bool success, int error_code) { + if (success) { + s_.login = false; + open_ = true; + s_.devices.clear(); + for (const auto &device : devices) s_.devices.push_back(device.dongle_id); + s_.devices_loaded = true; + s_.device_index = 0; + fetchRoutes(); + } else if (error_code == 401) { + s_.login = true; + open_ = true; + } else { + // Initial failures are shown on the calling window without opening the route browser. + MessageBox::warning("Error", "Network error", "", + utils::guarded(alive_, [this]() { finish(false); })); + } +} + +void RoutesDialog::fetchRoutes() { + if (!s_.devices_loaded || s_.devices.empty()) return; + + s_.routes.clear(); + s_.route_index = -1; + s_.empty_text = "Loading..."; + + const int request_id = ++s_.fetch_id; + auto on_routes = [this, alive = std::weak_ptr(alive_), request_id](std::vector list, bool success, int) { + utils::runOnMainThread(utils::guarded(alive.lock(), [this, list = std::move(list), success, request_id]() { + if (s_.fetch_id == request_id) setRouteList(list, success); + })); + }; + routes::fetchRoutes(s_.devices[s_.device_index], PERIOD_DAYS[s_.period_index], std::move(on_routes)); +} + +void RoutesDialog::setRouteList(const std::vector &list, bool success) { + if (success) { + for (const auto &route : list) { + const int mins = static_cast((route.end_ms - route.start_ms) / 60000); + s_.routes.push_back({routes::formatUnixMs(route.start_ms) + " " + std::to_string(mins) + " min", route.name}); + } + if (!s_.routes.empty()) s_.route_index = 0; + } else { + MessageBox::warning("Error", "Failed to fetch routes. Check your network connection.", "", + utils::guarded(alive_, [this]() { finish(false); })); + } + s_.empty_text = "No items"; +} + +void RoutesDialog::finish(bool accepted) { + if (auth_abort_) *auth_abort_ = true; + auth_abort_.reset(); + alive_.reset(); + open_ = false; + auto on_done = std::move(on_done_); + if (on_done) on_done(accepted, accepted && s_.route_index >= 0 ? s_.routes[s_.route_index].name : ""); +} + +void RoutesDialog::draw() { + if (!open_) return; + if (!beginDialog("Remote Routes", &popup_, ImVec2(480.0f, 420.0f))) return; + + if (s_.login) { + drawLogin(); + if (open_) { + MessageBox::draw(); + if (!open_) ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + return; + } + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Device"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + if (s_.devices_loaded) { + if (comboBox("##device", &s_.device_index, s_.devices)) fetchRoutes(); + } else { + int idx = 0; + ImGui::BeginDisabled(); + comboBox("##device", &idx, {"Loading..."}); + ImGui::EndDisabled(); + } + ImGui::SetNextItemWidth(-1.0f); + if (dropdown::Combo("##period", &s_.period_index, PERIOD_NAMES, IM_ARRAYSIZE(PERIOD_NAMES))) fetchRoutes(); + + bool accepted = false, rejected = false; + const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild("routes", ImVec2(0, -footer), ImGuiChildFlags_Borders); + if (s_.routes.empty()) { + const ImVec2 size = ImGui::CalcTextSize(s_.empty_text.c_str()); + const ImVec2 avail = ImGui::GetContentRegionAvail(); + ImGui::SetCursorPos(ImVec2((avail.x - size.x) * 0.5f, (avail.y - size.y) * 0.5f)); + ImGui::TextUnformatted(s_.empty_text.c_str()); + } + for (int i = 0; i < static_cast(s_.routes.size()); ++i) { + ImGui::PushID(i); + if (selectable(s_.routes[i].label.c_str(), s_.route_index == i, ImGuiSelectableFlags_AllowDoubleClick)) { + s_.route_index = i; + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) accepted = true; + } + ImGui::PopID(); + } + ImGui::EndChild(); + + dialogButtons("OK", &accepted, &rejected); + MessageBox::draw(); + if (accepted || rejected || !open_) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (accepted || rejected) finish(accepted); +} + +void RoutesDialog::signIn(const std::string &provider) { + s_.provider = provider == "google" ? "Google" : provider == "apple" ? "Apple" : "GitHub"; + s_.auth_error.clear(); + auth_abort_ = std::make_shared>(false); + std::thread([this, alive = std::weak_ptr(alive_), abort = auth_abort_, provider]() { + const std::string result = PyTools::authenticate(provider, abort.get()); + utils::runOnMainThread(utils::guarded(alive.lock(), [this, abort, result]() { + if (*abort) return; + auth_abort_.reset(); + std::string error; + auto status = json11::Json::parse(result, error); + if (status["success"].bool_value()) { + s_.login = false; + fetchDevices(); + } else { + s_.auth_error = status["error"].string_value(); + if (s_.auth_error.empty()) s_.auth_error = "Could not start sign-in. Please try again."; + } + })); + }).detach(); +} + +void RoutesDialog::drawLogin() { + const auto &p = palette(); + const char *providers[] = {"Google", "Apple", "GitHub"}; + const char *methods[] = {"google", "apple", "github"}; + const char *icons[] = {"\xef\x8f\xb0", "\xef\x99\x9b", "\xef\x8f\xad"}; + IconTextButtonOptions button_options{.height = 44.0f, .rounding = 8.0f, .icon_gap = 16.0f, .center_content = true}; + float button_width = iconTextButtonWidth("", "Choose another method", button_options); + for (int i = 0; i < 3; ++i) { + const std::string label = std::string("Sign in with ") + providers[i]; + button_width = std::max(button_width, iconTextButtonWidth(icons[i], label, button_options)); + button_options.label_width = std::max(button_options.label_width, ImGui::CalcTextSize(label.c_str()).x); + } + button_width += ImGui::GetStyle().FramePadding.x * 4; + // Keep the footer anchored while longer errors scroll inside the content area. + const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild("login_content", ImVec2(0, -footer)); + ImGui::Indent(16); + const float gap = ImGui::GetStyle().ItemSpacing.y; + const float wrap_x = ImGui::GetWindowWidth() - 28; + const float text_width = wrap_x - ImGui::GetCursorPosX(); + const char *chooser_intro = "Use your comma account to browse recorded drives and open a route for analysis."; + const auto waiting_intro = [](const std::string &provider) { + return "Sign in with " + provider + " in your browser, then return to Cabana to choose a device and route."; + }; + const std::string intro = auth_abort_ ? waiting_intro(s_.provider) : chooser_intro; + const char *hint = "Use the comma account paired with your device."; + // Reserve the same space in every state so the heading and controls never jump. + float intro_height = ImGui::CalcTextSize(chooser_intro, nullptr, false, text_width).y; + for (const char *provider : providers) { + intro_height = std::max(intro_height, ImGui::CalcTextSize(waiting_intro(provider).c_str(), nullptr, false, text_width).y); + } + const float controls_height = 3 * (button_options.height + 2 * gap) + + ImGui::CalcTextSize(hint, nullptr, false, text_width).y; + const float content_height = 28 + 2 * gap + intro_height + 2 * gap + 12 + controls_height; + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + std::max(0.0f, (ImGui::GetContentRegionAvail().y - content_height) * 0.5f)); + ImGui::PushFont(boldFont(), 28.0f); + ImGui::TextUnformatted("Open your routes in Cabana"); + ImGui::PopFont(); + ImGui::Spacing(); + ImGui::PushTextWrapPos(wrap_x); + const float controls_y = ImGui::GetCursorPosY() + intro_height + 2 * gap + 12; + const float buttons_x = ImGui::GetCursorPosX() + (ImGui::GetContentRegionAvail().x - 16 - button_width) * 0.5f; + if (auth_abort_) { + ImGui::TextWrapped("%s", intro.c_str()); + ImGui::SetCursorPosY(controls_y); + ImGui::BeginChild("auth_status", ImVec2(-16, 76), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground); + const char *status = "Waiting for browser sign-in"; + const char *timeout = "This request expires after 3 minutes."; + const float spinner_size = ImGui::GetFontSize(); + const float status_width = spinner_size + 8 + ImGui::CalcTextSize(status).x; + ImGui::SetCursorPos(ImVec2((ImGui::GetWindowWidth() - status_width) * 0.5f, 16)); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const float angle = std::fmod(ImGui::GetTime() * 4.0, 2.0 * IM_PI); + auto *draw_list = ImGui::GetWindowDrawList(); + draw_list->PathArcTo(ImVec2(pos.x + spinner_size * 0.5f, pos.y + spinner_size * 0.5f), + spinner_size * 0.35f, angle, angle + IM_PI * 1.5f, 24); + draw_list->PathStroke(ImGui::GetColorU32(p.accent), 0, 2.0f); + ImGui::Dummy(ImVec2(spinner_size, spinner_size)); + ImGui::SameLine(0, 8); + ImGui::TextUnformatted(status); + ImGui::SetCursorPos(ImVec2((ImGui::GetWindowWidth() - ImGui::CalcTextSize(timeout).x) * 0.5f, 40)); + ImGui::TextUnformatted(timeout); + ImGui::EndChild(); + ImGui::SetCursorPosY(controls_y + 2 * (button_options.height + 2 * gap)); + ImGui::SetCursorPosX(buttons_x); + if (iconTextButton("auth_retry", "", "Choose another method", button_width, button_options)) { + *auth_abort_ = true; + auth_abort_.reset(); + } + } else { + ImGui::TextWrapped("%s", intro.c_str()); + ImGui::SetCursorPosY(controls_y); + const char *selected_method = nullptr; + for (int i = 0; i < 3; ++i) { + ImGui::SetCursorPosX(buttons_x); + if (iconTextButton(methods[i], icons[i], std::string("Sign in with ") + providers[i], + button_width, button_options)) selected_method = methods[i]; + ImGui::Spacing(); + } + const char *message = s_.auth_error.empty() ? hint : s_.auth_error.c_str(); + const float width = ImGui::GetContentRegionAvail().x - 16; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (width - ImGui::CalcTextSize(message).x) * 0.5f)); + ImGui::TextWrapped("%s", message); + // Finish drawing this state before signIn clears its error text. + if (selected_method) signIn(selected_method); + } + ImGui::PopTextWrapPos(); + ImGui::Unindent(16); + ImGui::EndChild(); + ImGui::Separator(); + bool rejected = false; + dialogButtons("Cancel", &rejected, nullptr, true, nullptr); + if (rejected || dialogEscapePressed()) { + ImGui::CloseCurrentPopup(); + finish(false); + } +} diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.h b/openpilot/tools/cabana/ui/dialogs/routesdialog.h new file mode 100644 index 0000000000..c0f6db791c --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "tools/cabana/routes.h" +#include "tools/cabana/ui/util.h" + +// "Remote routes" browser. on_done gets accepted=true with the selected route name ("" if none), accepted=false on cancel. +class RoutesDialog { +public: + ~RoutesDialog() { if (auth_abort_) *auth_abort_ = true; } + void open(std::function on_done); + void draw(); + bool isActive() const { return alive_ != nullptr; } + +private: + void fetchDevices(); + void signIn(const std::string &provider); + void drawLogin(); + void setDeviceList(const std::vector &devices, bool success, int error_code); + void setRouteList(const std::vector &list, bool success); + void fetchRoutes(); + void finish(bool accepted); + + struct RouteItem { + std::string label; + std::string name; + }; + + struct State { + bool login = false; + std::string provider; + std::string auth_error; + bool devices_loaded = false; + std::vector devices; + int device_index = 0; + int period_index = 0; + std::vector routes; + int route_index = -1; + std::string empty_text = "No items"; + int fetch_id = 0; // the reply of an older request is dropped + }; + + std::shared_ptr> auth_abort_; + bool open_ = false; + PopupOwner popup_; + State s_; + std::function on_done_; + // created by open() and reset by finish(); guards main-thread callbacks from detached worker threads + std::shared_ptr alive_; +}; diff --git a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc new file mode 100644 index 0000000000..c8dd4fb665 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc @@ -0,0 +1,111 @@ +#include "tools/cabana/ui/dialogs/settingsdialog.h" + +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +namespace { + +const int MIN_CACHE_MINUTES = 30; +const int MAX_CACHE_MINUTES = 120; + +// the label sits in the left column, the field in the right one, all fields aligned +enum FormLabel { THEME, CACHED_MINUTES, DRAG_DIRECTION, CHART_HEIGHT, FORM_LABEL_COUNT }; +const char *FORM_LABELS[FORM_LABEL_COUNT] = {"Color Theme", "Max Cached Minutes", "Drag Direction", "Chart Height"}; + +float formLabelWidth() { + float w = 0.0f; + for (const char *label : FORM_LABELS) w = std::max(w, ImGui::CalcTextSize(label).x); + return ImGui::GetCursorPosX() + w + ImGui::GetStyle().ItemSpacing.x; +} + +void formRow(FormLabel label, float label_width) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(FORM_LABELS[label]); + ImGui::SameLine(label_width); + ImGui::SetNextItemWidth(-FLT_MIN); +} + +void settingInputInt(const char *id, int *value, int step, int step_fast, int minimum, int maximum) { + inputInt((std::string("##") + id).c_str(), value, step, step_fast); + *value = std::clamp(*value, minimum, maximum); +} + +} // namespace + +void SettingsDialog::open() { + theme_ = settings.theme; + cached_minutes_ = settings.max_cached_minutes; + drag_direction_ = settings.drag_direction; + chart_height_ = settings.chart_height; + log_livestream_ = settings.log_livestream; + log_path_ = settings.log_path; + open_ = true; + popup_.reset(); +} + +void SettingsDialog::draw() { + if (!open_) return; + if (!beginDialog("Settings", &popup_, ImVec2(400.0f, 0.0f))) return; + const float label_width = formLabelWidth(); + + ImGui::SeparatorText("General"); + static const char *themes[] = {"Light", "Dark"}; + formRow(THEME, label_width); + int theme_index = theme_ - LIGHT_THEME; + if (dropdown::Combo("##theme", &theme_index, themes, IM_ARRAYSIZE(themes))) theme_ = theme_index + LIGHT_THEME; + formRow(CACHED_MINUTES, label_width); + settingInputInt("cached_minutes", &cached_minutes_, 1, 10, MIN_CACHE_MINUTES, MAX_CACHE_MINUTES); + + ImGui::SeparatorText("New Signal Settings"); + static const char *directions[] = {"MSB First", "LSB First", "Always Little Endian", "Always Big Endian"}; + formRow(DRAG_DIRECTION, label_width); + dropdown::Combo("##drag_direction", &drag_direction_, directions, IM_ARRAYSIZE(directions)); + + ImGui::SeparatorText("Chart"); + formRow(CHART_HEIGHT, label_width); + settingInputInt("chart_height", &chart_height_, 10, 10, 100, 500); + + checkBox("Enable live stream logging", &log_livestream_); + ImGui::BeginDisabled(!log_livestream_); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Browse...") + ImGui::GetStyle().ItemSpacing.x)); + inputText("##log_path", &log_path_, "", ImGuiInputTextFlags_ReadOnly); + ImGui::SameLine(); + if (ImGui::Button("Browse...")) { + FileDialog::getExistingDirectory("Log File Location", utils::homePath(), [this](const std::string &fn) { + if (!fn.empty()) log_path_ = fn; + }); + } + ImGui::EndDisabled(); + + ImGui::Separator(); + bool accepted = false, done = false; + dialogButtons("OK", &accepted, &done); + if (accepted) { + save(); + done = true; + } + FileDialog::draw(); // nested so the directory picker stacks on this modal + if (done) { + open_ = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void SettingsDialog::save() { + if (std::exchange(settings.theme, theme_) != settings.theme) applyTheme(settings.theme); + settings.max_cached_minutes = cached_minutes_; + settings.chart_height = chart_height_; + settings.log_livestream = log_livestream_; + settings.log_path = log_path_; + settings.drag_direction = (Settings::DragDirection)drag_direction_; + settings.changed(); +} diff --git a/openpilot/tools/cabana/ui/dialogs/settingsdialog.h b/openpilot/tools/cabana/ui/dialogs/settingsdialog.h new file mode 100644 index 0000000000..c9947a91a1 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/settingsdialog.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +#include "tools/cabana/core/settings.h" +#include "tools/cabana/ui/util.h" + +class SettingsDialog { +public: + void open(); + void draw(); + +private: + void save(); + + bool open_ = false; + PopupOwner popup_; + int theme_ = 0; + int cached_minutes_ = 0; + int drag_direction_ = 0; + int chart_height_ = 0; + bool log_livestream_ = false; + std::string log_path_; +}; diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.cc b/openpilot/tools/cabana/ui/dialogs/streamselector.cc new file mode 100644 index 0000000000..d320216109 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.cc @@ -0,0 +1,329 @@ +#include "tools/cabana/ui/dialogs/streamselector.h" + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/streams/devicestream.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +void OpenReplayWidget::draw() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Route"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Remote Route...") + toolbarButtonWidth("Local Route...") + + ImGui::GetStyle().ItemSpacing.x * 2)); + inputText("##route", &route_, "Enter a route name or browse for a local or remote route"); + ImGui::SameLine(); + ImGui::BeginDisabled(routes_dialog_.isActive()); + if (ImGui::Button("Remote Route...")) { + routes_dialog_.open(utils::guarded(alive_, [this](bool accepted, const std::string &route) { + if (accepted) route_ = route; + })); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button("Local Route...")) { + FileDialog::getExistingDirectory("Open Local Route", settings.last_route_dir, utils::guarded(alive_, [this](const std::string &dir) { + if (!dir.empty()) { + route_ = dir; + settings.last_route_dir = std::filesystem::absolute(dir).parent_path().string(); + } + })); + } + checkBox("Road camera", &cameras_[0]); + ImGui::SameLine(); + checkBox("Driver camera", &cameras_[1]); + ImGui::SameLine(); + checkBox("Wide road camera", &cameras_[2]); +} + +void OpenReplayWidget::drawPopups() { + routes_dialog_.draw(); +} + +std::unique_ptr OpenReplayWidget::open() { + std::string route = route_; + std::string data_dir; + if (auto idx = route.rfind('/'); idx != std::string::npos && util::file_exists(route)) { + data_dir = route.substr(0, idx + 1); + route = route.substr(idx + 1); + } + + bool is_valid_format = Route::parseRoute(route).str.size() > 0; + if (!is_valid_format) { + MessageBox::warning("Warning", "Invalid route format: '" + route + "'"); + } else { + auto replay_stream = std::make_unique(); + Connection err = replay_stream->error.connect([](const std::string &msg) { + MessageBox::warning("Error", msg); + }); + uint32_t flags = REPLAY_FLAG_NONE; + if (cameras_[1]) flags |= REPLAY_FLAG_CABIN_CAMERA; + if (cameras_[2]) flags |= REPLAY_FLAG_WIDE_ROAD; + if (flags == REPLAY_FLAG_NONE && !cameras_[0]) flags = REPLAY_FLAG_NO_VIPC; + + if (replay_stream->loadRoute(route, data_dir, flags)) { + return replay_stream; + } + } + return nullptr; +} + +namespace { +const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U}; +const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U}; +} + +OpenPandaWidget::OpenPandaWidget() { + if (can && dynamic_cast(can) != nullptr) { + already_connected_ = true; + return; + } + refreshSerials(); + buildConfigForm(); +} + +void OpenPandaWidget::refreshSerials() { + serials_ = Panda::list(); + serial_index_ = 0; +} + +void OpenPandaWidget::buildConfigForm() { + std::string serial = serial_index_ < static_cast(serials_.size()) ? serials_[serial_index_] : ""; + has_fd_ = false; + has_panda_ = !serial.empty(); + if (has_panda_) { + try { + Panda panda(serial); + has_fd_ = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2); + } catch (const std::exception &e) { + fprintf(stderr, "failed to open panda %s\n", serial.c_str()); + has_panda_ = false; + } + } + + if (has_panda_) { + config.serial = serial; + config.bus_config.resize(3); + can_speed_index_.assign(3, 0); + data_speed_index_.assign(3, 0); + for (int i = 0; i < 3; i++) { + for (int j = 0; j < static_cast(std::size(speeds)); j++) { + if (speeds[j] == config.bus_config[i].can_speed_kbps) can_speed_index_[i] = j; + } + for (int j = 0; j < static_cast(std::size(data_speeds)); j++) { + if (data_speeds[j] == config.bus_config[i].data_speed_kbps) data_speed_index_[i] = j; + } + } + } else { + config.serial = ""; + } +} + +void OpenPandaWidget::draw() { + if (already_connected_) { + ImGui::Text("Already connected to %s.", can->routeName().c_str()); + ImGui::TextUnformatted("Select File > Close Stream before connecting to another panda."); + return; + } + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Serial"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Refresh") + ImGui::GetStyle().ItemSpacing.x)); + if (comboBox("##serial", &serial_index_, serials_)) buildConfigForm(); + ImGui::SameLine(); + if (ImGui::Button("Refresh")) { + refreshSerials(); + buildConfigForm(); + } + + if (!has_panda_) { + ImGui::TextUnformatted("No panda found"); + return; + } + for (int i = 0; i < static_cast(config.bus_config.size()); i++) { + ImGui::PushID(i); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Bus %d:", i); + ImGui::SameLine(); + ImGui::TextUnformatted("CAN Speed (kbps):"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (comboBox("##can_speed", &can_speed_index_[i], speeds, (int)std::size(speeds))) { + config.bus_config[i].can_speed_kbps = speeds[can_speed_index_[i]]; + } + if (has_fd_) { + ImGui::SameLine(); + checkBox("CAN-FD", &config.bus_config[i].can_fd); + ImGui::SameLine(); + ImGui::TextUnformatted("Data Speed (kbps):"); + ImGui::SameLine(); + ImGui::BeginDisabled(!config.bus_config[i].can_fd); + ImGui::SetNextItemWidth(90.0f); + if (comboBox("##data_speed", &data_speed_index_[i], data_speeds, (int)std::size(data_speeds))) { + config.bus_config[i].data_speed_kbps = data_speeds[data_speed_index_[i]]; + } + ImGui::EndDisabled(); + } + ImGui::PopID(); + } +} + +std::unique_ptr OpenPandaWidget::open() { + try { + return std::make_unique(config); + } catch (std::exception &e) { + MessageBox::warning("Warning", std::string("Failed to connect to panda: '") + e.what() + "'"); + return nullptr; + } +} + +void OpenDeviceWidget::draw() { + ImGui::RadioButton("MSGQ", &mode_, 0); + ImGui::RadioButton("ZMQ", &mode_, 1); + // the radio buttons are the label column, the ip address is the field column + const float label_width = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemInnerSpacing.x + + std::max(ImGui::CalcTextSize("MSGQ").x, ImGui::CalcTextSize("ZMQ").x) + + ImGui::GetStyle().ItemInnerSpacing.x; + ImGui::SameLine(label_width); + ImGui::BeginDisabled(mode_ != 1); + ImGui::SetNextItemWidth(-1.0f); + validatedText("##ip", &ip_address_, validateIpAddress, "Enter device IP address", ipValidator); + ImGui::EndDisabled(); +} + +std::unique_ptr OpenDeviceWidget::open() { + std::string ip = ip_address_.empty() ? "127.0.0.1" : ip_address_; + bool msgq = mode_ == 0; + return std::make_unique(msgq ? "" : ip); +} + +#ifdef __linux__ + +OpenSocketCanWidget::OpenSocketCanWidget() { + refreshDevices(); +} + +void OpenSocketCanWidget::refreshDevices() { + devices_.clear(); + // type 280 = ARPHRD_CAN + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator("/sys/class/net", ec)) { + std::ifstream type_file(entry.path() / "type"); + int type = 0; + if (type_file >> type && type == 280) { + devices_.push_back(entry.path().filename().string()); + } + } + device_index_ = 0; + config.device = devices_.empty() ? "" : devices_[0]; +} + +void OpenSocketCanWidget::draw() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Device"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Refresh") + ImGui::GetStyle().ItemSpacing.x)); + if (comboBox("##device", &device_index_, devices_)) config.device = devices_[device_index_]; + ImGui::SameLine(); + if (ImGui::Button("Refresh")) refreshDevices(); +} + +std::unique_ptr OpenSocketCanWidget::open() { + try { + return std::make_unique(config); + } catch (std::exception &e) { + MessageBox::warning("Warning", std::string("Failed to connect to SocketCAN device: '") + e.what() + "'"); + return nullptr; + } +} +#endif + +void StreamSelector::open(Callback on_done) { + on_done_ = std::move(on_done); + open_ = true; + popup_.reset(); + first_frame_ = true; + dbc_file_.clear(); + widgets_.clear(); + widgets_.push_back(std::make_unique()); + widgets_.push_back(std::make_unique()); +#ifdef __linux__ + if (SocketCanStream::available()) { + widgets_.push_back(std::make_unique()); + } +#endif + widgets_.push_back(std::make_unique()); +} + +void StreamSelector::draw() { + if (!open_) return; + if (!beginDialog("Open Stream", &popup_, ImVec2(768.0f, 0.0f))) return; + + AbstractOpenStreamWidget *current = nullptr; + const ImVec4 pane = ImGui::GetStyleColorVec4(ImGuiCol_WindowBg); + ImGui::PushStyleColor(ImGuiCol_ChildBg, pane); + ImGui::PushStyleColor(ImGuiCol_TabSelected, pane); + if (ImGui::BeginTabBar("streams")) { + for (auto &w : widgets_) { + // a fresh dialog every time, so the first tab is always the current one + ImGuiTabItemFlags tab_flags = (first_frame_ && w == widgets_.front()) ? ImGuiTabItemFlags_SetSelected : 0; + if (ImGui::BeginTabItem(w->title(), nullptr, tab_flags)) { + current = w.get(); + ImGui::BeginChild("tab", ImVec2(0, 130.0f), ImGuiChildFlags_Borders); + w->draw(); + ImGui::EndChild(); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } + ImGui::PopStyleColor(2); + first_frame_ = false; + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("DBC File"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Browse...") + ImGui::GetStyle().ItemSpacing.x)); + inputText("##dbc", &dbc_file_, "Choose a DBC file to open", ImGuiInputTextFlags_ReadOnly); + ImGui::SameLine(); + if (ImGui::Button("Browse...")) { + FileDialog::getOpenFileName("Open File", settings.last_dir, ".dbc", [this](const std::string &fn) { + if (!fn.empty()) { + dbc_file_ = fn; + settings.last_dir = std::filesystem::absolute(fn).parent_path().string(); + } + }); + } + ImGui::Separator(); + + bool accepted = false, rejected = false; + std::unique_ptr stream; + bool open_clicked = false; + dialogButtons("Open", &open_clicked, &rejected, current != nullptr && current->openEnabled()); + if (open_clicked) { + if (stream = current->open(); stream) accepted = true; + } + + // nested so they stack on this modal + if (current) current->drawPopups(); + FileDialog::draw(); + MessageBox::draw(); + + if (accepted || rejected) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (accepted || rejected) { + open_ = false; + widgets_.clear(); + auto on_done = std::move(on_done_); + if (on_done) on_done(std::move(stream), dbc_file_); + } +} diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.h b/openpilot/tools/cabana/ui/dialogs/streamselector.h new file mode 100644 index 0000000000..b57e602cab --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/streams/pandastream.h" +#ifdef __linux__ +#include "tools/cabana/streams/socketcanstream.h" +#endif +#include "tools/cabana/ui/dialogs/routesdialog.h" +#include "tools/cabana/ui/util.h" + +class AbstractOpenStreamWidget { +public: + virtual ~AbstractOpenStreamWidget() = default; + virtual const char *title() const = 0; + virtual void draw() = 0; + // nested dialogs, drawn at the modal's level: a popup opened from inside the tab's child window is + // never the top-most modal's own call site, so PopupOwner would skip it + virtual void drawPopups() {} + virtual std::unique_ptr open() = 0; + virtual bool openEnabled() const { return true; } +}; + +class OpenReplayWidget : public AbstractOpenStreamWidget { +public: + const char *title() const override { return "Replay"; } + void draw() override; + void drawPopups() override; + std::unique_ptr open() override; + +private: + std::string route_; + bool cameras_[3] = {true, false, false}; + RoutesDialog routes_dialog_; + // guards dialog continuations that outlive the stream selector + std::shared_ptr alive_ = std::make_shared(true); +}; + +class OpenPandaWidget : public AbstractOpenStreamWidget { +public: + OpenPandaWidget(); + const char *title() const override { return "Panda"; } + void draw() override; + std::unique_ptr open() override; + bool openEnabled() const override { return !already_connected_; } + +private: + void refreshSerials(); + void buildConfigForm(); + + bool already_connected_ = false; + std::vector serials_; + int serial_index_ = 0; + bool has_panda_ = false; + bool has_fd_ = false; + std::vector can_speed_index_, data_speed_index_; + PandaStreamConfig config = {}; +}; + +class OpenDeviceWidget : public AbstractOpenStreamWidget { +public: + const char *title() const override { return "Device"; } + void draw() override; + std::unique_ptr open() override; + +private: + int mode_ = 1; // 0 = MSGQ, 1 = ZMQ + std::string ip_address_; +}; + +#ifdef __linux__ +class OpenSocketCanWidget : public AbstractOpenStreamWidget { +public: + OpenSocketCanWidget(); + const char *title() const override { return "SocketCAN"; } + void draw() override; + std::unique_ptr open() override; + +private: + void refreshDevices(); + + std::vector devices_; + int device_index_ = 0; + SocketCanStreamConfig config = {}; +}; +#endif + +class StreamSelector { +public: + using Callback = std::function stream, const std::string &dbc_file)>; + // on_done gets a null stream on cancel + void open(Callback on_done); + void draw(); + +private: + bool open_ = false; + PopupOwner popup_; + bool first_frame_ = false; + std::string dbc_file_; + std::vector> widgets_; + Callback on_done_; +}; diff --git a/openpilot/tools/cabana/ui/dropdown.h b/openpilot/tools/cabana/ui/dropdown.h new file mode 100644 index 0000000000..e6ce25f16b --- /dev/null +++ b/openpilot/tools/cabana/ui/dropdown.h @@ -0,0 +1,142 @@ +#pragma once + +#include +#include + +#include "tools/cabana/ui/theme.h" + +namespace dropdown { +constexpr float PADDING_X = 9.0f; +constexpr float PADDING_Y = 6.0f; +constexpr float SPACING_X = 10.0f; +constexpr float SPACING_Y = 8.0f; +constexpr float ROUNDING = 6.0f; +constexpr float BORDER = 1.0f; +constexpr int WINDOW_STYLE_VARS = 3; + +class PopupViewportScope { +public: + PopupViewportScope() : main_(ImGui::GetMainViewport()), flags_(main_->Flags) { + // Keep detached popups above their owner; reset ownership when docked. + ImGuiWindowClass window_class; + const ImGuiViewport *owner = ImGui::GetWindowViewport(); + if (owner != main_) { + // Popups ignore NoAutoMerge, so exclude the main viewport during Begin. + main_->Flags &= ~ImGuiViewportFlags_CanHostOtherWindows; + window_class.ParentViewportId = owner->ID; + } + ImGui::SetNextWindowClass(&window_class); + } + ~PopupViewportScope() { main_->Flags = flags_; } + +private: + ImGuiViewport *main_; + ImGuiViewportFlags flags_; +}; + +inline void pushStyle() { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(PADDING_X, PADDING_Y)); + ImGui::PushStyleVar(ImGuiStyleVar_PopupRounding, ROUNDING); + ImGui::PushStyleVar(ImGuiStyleVar_PopupBorderSize, BORDER); +} + +inline bool finishBegin(bool open) { + if (open) ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(SPACING_X, SPACING_Y)); + else ImGui::PopStyleVar(WINDOW_STYLE_VARS); + return open; +} + +inline void PositionBelowItem(const char *id, bool align_right = false) { + const ImRect anchor(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + char name[32]; + ImFormatString(name, IM_ARRAYSIZE(name), "##Popup_%08x", ImGui::GetID(id)); + if (ImGuiWindow *popup = ImGui::FindWindowByName(name); popup && popup->WasActive) { + // Place like a combo using the owner's current monitor bounds. + const auto *viewport = static_cast(ImGui::GetWindowViewport()); + ImRect bounds = viewport->GetMainRect(); + if ((ImGui::GetIO().ConfigFlags & ImGuiConfigFlags_ViewportsEnable) && viewport->PlatformMonitor >= 0) { + const auto &monitor = ImGui::GetPlatformIO().Monitors[viewport->PlatformMonitor]; + bounds = ImRect(monitor.WorkPos, ImVec2(monitor.WorkPos.x + monitor.WorkSize.x, monitor.WorkPos.y + monitor.WorkSize.y)); + } + bounds.Expand(ImVec2(-ImGui::GetStyle().DisplaySafeAreaPadding.x, -ImGui::GetStyle().DisplaySafeAreaPadding.y)); + ImGuiDir direction = align_right ? ImGuiDir_Left : ImGuiDir_Down; + const ImVec2 pos = ImGui::FindBestWindowPosForPopupEx(anchor.GetBL(), ImGui::CalcWindowNextAutoFitSize(popup), + &direction, bounds, anchor, ImGuiPopupPositionPolicy_ComboBox); + ImGui::SetNextWindowPos(pos); + } else { + ImGui::SetNextWindowPos(align_right ? anchor.GetBR() : anchor.GetBL(), ImGuiCond_Always, ImVec2(align_right ? 1.0f : 0.0f, 0)); + } +} + +inline bool BeginPopup(const char *id, ImGuiWindowFlags flags = 0) { + PopupViewportScope viewport_scope; + pushStyle(); + return finishBegin(ImGui::BeginPopup(id, flags)); +} +inline bool BeginPopupContextItem(const char *id = nullptr, ImGuiPopupFlags flags = ImGuiPopupFlags_MouseButtonRight) { + PopupViewportScope viewport_scope; + pushStyle(); + return finishBegin(ImGui::BeginPopupContextItem(id, flags)); +} +inline void EndPopup() { + ImGui::PopStyleVar(); + ImGui::EndPopup(); + ImGui::PopStyleVar(WINDOW_STYLE_VARS); +} +inline bool BeginMenu(const char *label, bool enabled = true) { + PopupViewportScope viewport_scope; + pushStyle(); + // An open menu keeps its selection color while the pointer is over its label. + const bool selected = ImGui::IsPopupOpen(label); + if (selected) ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImGui::GetStyleColorVec4(ImGuiCol_Header)); + const bool open = ImGui::BeginMenu(label, enabled); + if (selected) ImGui::PopStyleColor(); + return finishBegin(open); +} +inline void EndMenu() { + ImGui::PopStyleVar(); + ImGui::EndMenu(); + ImGui::PopStyleVar(WINDOW_STYLE_VARS); +} +inline bool BeginCombo(const char *label, const char *preview, ImGuiComboFlags flags = 0) { + PopupViewportScope viewport_scope; + pushStyle(); + return finishBegin(ImGui::BeginCombo(label, preview, flags)); +} +inline void EndCombo() { + ImGui::PopStyleVar(); + ImGui::EndCombo(); + ImGui::PopStyleVar(WINDOW_STYLE_VARS); +} + +inline bool Item(const char *label, const char *shortcut = nullptr, bool selected = false, bool enabled = true) { + return ImGui::MenuItem(label, shortcut, selected, enabled); +} +inline bool Item(const char *label, const char *shortcut, bool *selected, bool enabled = true) { + if (!Item(label, shortcut, selected && *selected, enabled)) return false; + if (selected) *selected = !*selected; + return true; +} + +inline bool Combo(const char *label, int *index, const char *const items[], int count) { + bool changed = false; + if (BeginCombo(label, *index >= 0 && *index < count ? items[*index] : "")) { + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + if (Item(items[i], nullptr, i == *index) && i != *index) { + *index = i; + changed = true; + } + if (i == *index) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + EndCombo(); + } + return changed; +} +inline bool Combo(const char *label, int *index, const char *items) { + std::vector labels; + for (const char *item = items; *item; item += std::strlen(item) + 1) labels.push_back(item); + return Combo(label, index, labels.data(), labels.size()); +} +} // namespace dropdown diff --git a/openpilot/tools/cabana/ui/helpoverlay.cc b/openpilot/tools/cabana/ui/helpoverlay.cc new file mode 100644 index 0000000000..f89e8b0b7e --- /dev/null +++ b/openpilot/tools/cabana/ui/helpoverlay.cc @@ -0,0 +1,201 @@ +#include "tools/cabana/ui/helpoverlay.h" + +#include +#include +#include +#include +#include + +#include "tools/cabana/ui/util.h" + +namespace { +struct HelpRun { + std::string text; + bool bold = false; + bool chip = false; // background-color:lightGray + bool swatch = false; // a colored square + ImU32 color = 0; // 0 = default text color +}; + +ImU32 helpColor(const std::string &name) { + if (name == "gray") return IM_COL32(128, 128, 128, 255); + if (name == "blue") return IM_COL32(0, 0, 255, 255); + if (name == "red") return IM_COL32(255, 0, 0, 255); + unsigned rgb = 0; + if (name.size() == 7 && name[0] == '#' && sscanf(name.c_str() + 1, "%6x", &rgb) == 1) { + return IM_COL32((rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, 255); + } + return 0; +} + +std::vector> parseHelpHtml(const std::string &raw) { + std::vector> lines(1); + HelpRun style; + std::vector span_stack; + bool prev_space = true; // html collapses whitespace; leading whitespace is dropped + std::string pending; + auto flush = [&]() { + if (!pending.empty()) { + HelpRun run = style; + run.text = pending; + lines.back().push_back(run); + pending.clear(); + } + }; + auto push_swatch = [&](ImU32 color) { + flush(); + HelpRun run = style; + run.swatch = true; + if (color) run.color = color; + lines.back().push_back(run); + prev_space = false; + }; + for (size_t i = 0; i < raw.size(); ++i) { + const char c = raw[i]; + if (c == '<') { + const size_t close = raw.find('>', i); + if (close == std::string::npos) break; + const std::string tag = raw.substr(i + 1, close - i - 1); + i = close; + if (tag.compare(0, 3, "!--") == 0) continue; + flush(); + if (tag == "b") { + style.bold = true; + } else if (tag == "/b") { + style.bold = false; + } else if (tag.compare(0, 2, "br") == 0) { + lines.emplace_back(); + prev_space = true; + } else if (tag.compare(0, 4, "span") == 0) { + span_stack.push_back(style); + const size_t st = tag.find("style=\""); + if (st != std::string::npos) { + const std::string css = tag.substr(st + 7, tag.find('"', st + 7) - st - 7); + size_t pos = 0; + while (pos < css.size()) { + const size_t semi = css.find(';', pos); + const std::string decl = css.substr(pos, semi == std::string::npos ? std::string::npos : semi - pos); + const size_t colon = decl.find(':'); + if (colon != std::string::npos) { + const std::string key = decl.substr(0, colon), value = decl.substr(colon + 1); + if (key == "color") style.color = helpColor(value); + if (key == "background-color") style.chip = true; + } + if (semi == std::string::npos) break; + pos = semi + 1; + } + } + } else if (tag == "/span") { + if (!span_stack.empty()) { + style = span_stack.back(); + span_stack.pop_back(); + } + } + } else if (c == '&') { + static const std::pair entities[] = {{" ", " "}}; + bool matched = false; + for (const auto &[name, text] : entities) { + if (raw.compare(i, strlen(name), name) == 0) { + pending += text; + prev_space = false; + i += strlen(name) - 1; + matched = true; + break; + } + } + if (!matched && raw.compare(i, 7, "■") == 0) { // the filled square of the byte color legend + push_swatch(0); + i += 6; + matched = true; + } + if (!matched) { + pending += c; + prev_space = false; + } + } else if (isspace(static_cast(c))) { + if (!prev_space) pending += ' '; + prev_space = true; + } else if (c == '#' && i + 6 < raw.size() && helpColor(raw.substr(i, 7)) != 0) { // #rrggbb legend token + push_swatch(helpColor(raw.substr(i, 7))); + i += 6; + } else { + pending += c; + prev_space = false; + } + } + flush(); + for (auto &line : lines) { // trim the collapsed whitespace at the line ends + if (!line.empty() && !line.back().text.empty() && line.back().text.back() == ' ') line.back().text.pop_back(); + if (!line.empty() && !line.front().text.empty() && line.front().text.front() == ' ') line.front().text.erase(0, 1); + } + while (!lines.empty() && lines.back().empty()) lines.pop_back(); + return lines; +} +} // namespace + +void HelpOverlay::toggle() { + visible_ = !visible_; + opened_frame_ = ImGui::GetFrameCount(); +} + +void HelpOverlay::add(const std::string &text, const ImRect &rect) { + if (visible_) texts_.push_back({text, rect, ImGui::GetWindowViewport()}); +} + +void HelpOverlay::draw() { + if (!visible_) return; + // Each panel belongs to a viewport; detached panels need their own foreground layer. + std::vector viewports{ImGui::GetMainViewport()}; + for (const auto &entry : texts_) { + if (std::find(viewports.begin(), viewports.end(), entry.viewport) == viewports.end()) viewports.push_back(entry.viewport); + } + for (auto *viewport : viewports) { + ImGui::GetForegroundDrawList(viewport)->AddRectFilled(viewport->Pos, ImVec2(viewport->Pos.x + viewport->Size.x, viewport->Pos.y + viewport->Size.y), IM_COL32(0, 0, 0, 50)); + } + ImFont *font = ImGui::GetFont(); + ImFont *bold_font = boldFont() ? boldFont() : font; + const float font_size = ImGui::GetFontSize(); + const float line_h = ImGui::GetTextLineHeightWithSpacing(); + auto run_width = [&](const HelpRun &r) { + if (r.swatch) return font_size; + return (r.bold ? bold_font : font)->CalcTextSizeA(font_size, FLT_MAX, 0.0f, r.text.c_str()).x; + }; + for (const auto &[raw, rect, viewport] : texts_) { + ImDrawList *dl = ImGui::GetForegroundDrawList(viewport); + if (raw.empty()) continue; + const auto lines = parseHelpHtml(raw); + float width = 0; + for (const auto &line : lines) { + float w = 0; + for (const auto &r : line) w += run_width(r); + width = std::max(width, w); + } + const ImVec2 size(width, lines.size() * line_h); + const ImVec2 center((rect.Min.x + rect.Max.x) * 0.5f, (rect.Min.y + rect.Max.y) * 0.5f); + const ImVec2 min(center.x - size.x * 0.5f - 8.0f, center.y - size.y * 0.5f - 8.0f); + const ImVec2 max(center.x + size.x * 0.5f + 8.0f, center.y + size.y * 0.5f + 8.0f); + dl->AddRectFilled(min, max, ImGui::GetColorU32(ImGuiCol_PopupBg), ImGui::GetStyle().PopupRounding); + dl->AddRect(min, max, ImGui::GetColorU32(ImGuiCol_Border), ImGui::GetStyle().PopupRounding); + float y = min.y + 8.0f; + for (const auto &line : lines) { + float x = min.x + 8.0f; + for (const auto &r : line) { + const float w = run_width(r); + const auto background = fromImVec4(r.chip ? palette().button : palette().surface); + const auto source = r.color ? fromImVec4(ImGui::ColorConvertU32ToFloat4(r.color)) : fromImVec4(palette().text); + const ImU32 color = toImU32(contrastColor(source, background, r.swatch ? 3.0 : 4.5)); + if (r.swatch) { + dl->AddRectFilled(ImVec2(x + 2, y + 3), ImVec2(x + font_size - 2, y + font_size - 1), color); + } else { + if (r.chip) dl->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + font_size), ImGui::GetColorU32(ImGuiCol_Button), 3.0f); + dl->AddText(r.bold ? bold_font : font, font_size, ImVec2(x, y), color, r.text.c_str()); + } + x += w; + } + y += line_h; + } + } + texts_.clear(); + // ignore the release of the click that opened the overlay + if (ImGui::IsMouseReleased(ImGuiMouseButton_Left) && ImGui::GetFrameCount() != opened_frame_) visible_ = false; +} diff --git a/openpilot/tools/cabana/ui/helpoverlay.h b/openpilot/tools/cabana/ui/helpoverlay.h new file mode 100644 index 0000000000..1431a4c762 --- /dev/null +++ b/openpilot/tools/cabana/ui/helpoverlay.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +#include "imgui_internal.h" + +// Dims the window and shows each widget's whatsThis text at its center; any click closes it. +// The texts are rich text: ,
, , &entities; and #rrggbb +// tokens (the video legend) are rendered, everything else is ignored. +class HelpOverlay { +public: + void toggle(); + bool visible() const { return visible_; } + // collected while the widgets draw, consumed by draw() + void add(const std::string &text, const ImRect &rect); + void draw(); + +private: + struct Entry { + std::string text; + ImRect rect; + ImGuiViewport *viewport; + }; + std::vector texts_; + bool visible_ = false; + int opened_frame_ = -1; +}; diff --git a/openpilot/tools/cabana/ui/icons.h b/openpilot/tools/cabana/ui/icons.h new file mode 100644 index 0000000000..89b8300862 --- /dev/null +++ b/openpilot/tools/cabana/ui/icons.h @@ -0,0 +1,40 @@ +#pragma once + +// Bootstrap Icons codepoints; loadFonts() merges the icon font into each text font. +namespace icon { +constexpr const char ARROW_CLOCKWISE[] = "\xef\x84\x96"; +constexpr const char ARROW_COUNTERCLOCKWISE[] = "\xef\x84\x97"; +constexpr const char CHEVRON_LEFT[] = "\xef\x8a\x84"; +constexpr const char CHEVRON_RIGHT[] = "\xef\x8a\x85"; +constexpr const char ASPECT_RATIO[] = "\xef\x85\x90"; +constexpr const char ASPECT_RATIO_FILL[] = "\xef\x85\x8f"; +constexpr const char EXCLAMATION_TRIANGLE[] = "\xef\x8c\xba"; +constexpr const char FAST_FORWARD[] = "\xef\x9f\xb3"; +constexpr const char FILETYPE_CSV[] = "\xef\x9d\x83"; +constexpr const char FOLDER[] = "\xef\x8f\x91"; +constexpr const char FILE_EARMARK[] = "\xef\x8d\xa9"; +constexpr const char FILE_EARMARK_RULED[] = "\xef\x8e\x84"; +constexpr const char GRAPH_UP[] = "\xef\x8f\xb2"; +constexpr const char GRIP_HORIZONTAL[] = "\xef\x8f\xbd"; +constexpr const char INFO_CIRCLE[] = "\xef\x90\xb0"; +constexpr const char PAUSE[] = "\xef\x93\x83"; +constexpr const char PENCIL[] = "\xef\x93\x8b"; +constexpr const char PLAY[] = "\xef\x93\xb4"; +constexpr const char REPEAT[] = "\xef\xa0\x93"; +constexpr const char REPEAT_1[] = "\xef\xa0\x92"; +constexpr const char REWIND[] = "\xef\xa0\x98"; +constexpr const char SKIP_END[] = "\xef\x95\x97"; +constexpr const char STOPWATCH[] = "\xef\x96\x96"; +constexpr const char THREE_DOTS[] = "\xef\x97\x94"; +constexpr const char X_LG[] = "\xef\x99\x99"; +constexpr const char ZOOM_OUT[] = "\xef\x98\xad"; +constexpr const char PLUS_LG[] = "\xef\x99\x8d"; +constexpr const char DASH_LG[] = "\xef\x98\xbb"; +constexpr const char TRASH[] = "\xef\x9e\x8b"; +constexpr const char ARROWS_COLLAPSE[] = "\xef\x85\x8b"; +constexpr const char BOX_ARROW_UP_RIGHT[] = "\xef\x87\x85"; +constexpr const char BOX_ARROW_IN_DOWN_LEFT[] = "\xef\x86\xba"; +constexpr const char WINDOW_PLUS[] = "\xef\x9b\x90"; +constexpr const char CHEVRON_DOUBLE_RIGHT[] = "\xef\x8a\x80"; +constexpr const char THREE_DOTS_VERTICAL[] = "\xef\x97\x93"; +} // namespace icon diff --git a/openpilot/tools/cabana/ui/inistate.cc b/openpilot/tools/cabana/ui/inistate.cc new file mode 100644 index 0000000000..258344126a --- /dev/null +++ b/openpilot/tools/cabana/ui/inistate.cc @@ -0,0 +1,152 @@ +#include "tools/cabana/ui/inistate.h" + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/qtstate.h" +#include "tools/cabana/ui/util.h" + +namespace inistate { + +MainWindowState main_window; + +namespace { + +void *readOpen(ImGuiContext *, ImGuiSettingsHandler *, const char *name) { + return strcmp(name, "MainWindow") == 0 ? (void *)&main_window : nullptr; +} + +void readLine(ImGuiContext *, ImGuiSettingsHandler *, void *entry, const char *line) { + auto *state = (MainWindowState *)entry; + int x = 0, y = 0, flag = 0; + float ratio = 0.0f; + if (sscanf(line, "Pos=%d,%d", &x, &y) == 2) { + state->pos[0] = x; + state->pos[1] = y; + } else if (sscanf(line, "Size=%d,%d", &x, &y) == 2) { + state->size[0] = x; + state->size[1] = y; + state->has_geometry = true; + } else if (sscanf(line, "Maximized=%d", &flag) == 1) { + state->maximized = flag != 0; + } else if (sscanf(line, "VideoSplitterRatio=%f", &ratio) == 1) { + state->video_splitter_ratio = ratio; + } else if (sscanf(line, "MessagesVisible=%d", &flag) == 1) { + state->messages_visible = flag != 0; + } else if (sscanf(line, "ChartsVisible=%d", &flag) == 1) { + state->charts_visible = flag != 0; + } else if (sscanf(line, "VideoVisible=%d", &flag) == 1) { + state->video_visible = flag != 0; + } +} + +void writeAll(ImGuiContext *, ImGuiSettingsHandler *handler, ImGuiTextBuffer *buf) { + buf->appendf("[%s][MainWindow]\n", handler->TypeName); + if (main_window.has_geometry) { + buf->appendf("Pos=%d,%d\n", main_window.pos[0], main_window.pos[1]); + buf->appendf("Size=%d,%d\n", main_window.size[0], main_window.size[1]); + } + buf->appendf("Maximized=%d\n", main_window.maximized ? 1 : 0); + buf->appendf("VideoSplitterRatio=%.4f\n", main_window.video_splitter_ratio); + buf->appendf("MessagesVisible=%d\n", main_window.messages_visible ? 1 : 0); + buf->appendf("VideoVisible=%d\n", main_window.video_visible ? 1 : 0); + buf->appendf("ChartsVisible=%d\n", main_window.charts_visible ? 1 : 0); + buf->append("\n"); +} + +std::string migrateQtHeaderState(const qtstate::QtHeaderState &header) { + // imgui restarts the hash at "###" in a window name and BeginTable seeds the table id from the window id + const ImGuiID table_id = ImHashStr("messages", 0, ImHashStr(MESSAGES_PANEL_ID, 0)); + const float cell_padding = ImGui::GetStyle().CellPadding.x; + + ImGuiTextBuffer buf; + buf.appendf("[Table][0x%08X,%d]\n", table_id, qtstate::kMessageColumnCount); + for (int i = 0; i < qtstate::kMessageColumnCount; ++i) { + buf.appendf("Column %-2d", i); + if (i == 6) { + buf.append(" Weight=1.0000"); // DATA is the stretch column + } else { + buf.appendf(" Width=%d", std::max(1, (int)(header.width[i] - 2 * cell_padding))); + } + buf.appendf(" Visible=%d Order=%d", header.hidden[i] ? 0 : 1, header.visual[i]); + if (header.sort_shown && i == header.sort_section) { + // the port feeds imgui the flipped direction so the arrow matches Qt (flipSortDirection + // in ui/widgets/messageswidget.cc): Qt ascending is imgui descending + buf.appendf(" Sort=0%c", header.sort_order == 0 ? '^' : 'v'); + } + buf.append("\n"); + } + buf.append("\n"); + return std::string(buf.c_str()); +} + +std::string migrateQtState() { + auto geometry = qtstate::parseQtGeometry(settings.geometry); + auto splitter = qtstate::parseQtSplitter(settings.video_splitter_state); + + ImGuiTextBuffer buf; + if (geometry || splitter) { + buf.append("[Cabana][MainWindow]\n"); + if (geometry) { + buf.appendf("Pos=%d,%d\n", geometry->x, geometry->y); + buf.appendf("Size=%d,%d\n", geometry->w, geometry->h); + buf.appendf("Maximized=%d\n", geometry->maximized ? 1 : 0); + } + if (splitter) buf.appendf("VideoSplitterRatio=%.4f\n", splitter->ratio); + buf.append("\n"); + } + if (auto header = qtstate::parseQtHeaderState(settings.message_header_state)) { + buf.append(migrateQtHeaderState(*header).c_str()); + } + return std::string(buf.c_str()); +} + +void migrateDockLayout() { + // Show dock tabs hidden by older layouts. + if (const auto *center = ImGui::FindWindowSettingsByID(ImHashStr("###CenterWidget"))) { + if (auto *node = ImGui::DockBuilderGetNode(center->DockId)) { + node->LocalFlags &= ~ImGuiDockNodeFlags_NoTabBar; + } + } +} + +} // namespace + +void addSettingsHandler() { + ImGuiSettingsHandler handler; + handler.TypeName = "Cabana"; + handler.TypeHash = ImHashStr("Cabana"); + handler.ReadOpenFn = readOpen; + handler.ReadLineFn = readLine; + handler.WriteAllFn = writeAll; + ImGui::AddSettingsHandler(&handler); +} + +void load() { + if (settings.ui_state.empty()) settings.ui_state = migrateQtState(); + if (!settings.ui_state.empty()) + ImGui::LoadIniSettingsFromMemory(settings.ui_state.data(), settings.ui_state.size()); + + migrateDockLayout(); +} + +void applyWindowGeometry(GLFWwindow *window) { + // Qt restoreGeometry corrects off-screen geometry, here we rely on the window manager + if (main_window.has_geometry && main_window.size[0] > 0 && main_window.size[1] > 0) { + glfwSetWindowPos(window, main_window.pos[0], main_window.pos[1]); + glfwSetWindowSize(window, main_window.size[0], main_window.size[1]); + } + if (main_window.maximized) glfwMaximizeWindow(window); +} + +std::string save() { + return std::string(ImGui::SaveIniSettingsToMemory()); +} + +} // namespace inistate diff --git a/openpilot/tools/cabana/ui/inistate.h b/openpilot/tools/cabana/ui/inistate.h new file mode 100644 index 0000000000..fd17c7e98e --- /dev/null +++ b/openpilot/tools/cabana/ui/inistate.h @@ -0,0 +1,28 @@ +#pragma once +#include + +struct GLFWwindow; + +// The imgui frontend's persisted state: the imgui ini text (windows, dock layout, table +// state) plus a custom [Cabana][MainWindow] section, stored as one string in Settings. +namespace inistate { + +struct MainWindowState { + int pos[2] = {0, 0}; + int size[2] = {0, 0}; + bool maximized = false; + bool has_geometry = false; + float video_splitter_ratio = -1.0f; // < 0: video at its size hint + bool messages_visible = true; + bool video_visible = true; + bool charts_visible = true; +}; + +extern MainWindowState main_window; + +void addSettingsHandler(); // register the [Cabana] ini section +void load(); // migrate Qt state if needed, then LoadIniSettingsFromMemory +void applyWindowGeometry(GLFWwindow *window); // glfw pos/size/maximize from main_window +std::string save(); // SaveIniSettingsToMemory (caller fills main_window first) + +} // namespace inistate diff --git a/openpilot/tools/cabana/cabana.cc b/openpilot/tools/cabana/ui/main.cc similarity index 65% rename from openpilot/tools/cabana/cabana.cc rename to openpilot/tools/cabana/ui/main.cc index b18484c556..e111c4d9f9 100644 --- a/openpilot/tools/cabana/cabana.cc +++ b/openpilot/tools/cabana/ui/main.cc @@ -1,18 +1,23 @@ #include #include +#include #include +#include #include -#include - -#include "tools/cabana/mainwin.h" #include "tools/cabana/streams/devicestream.h" #include "tools/cabana/streams/pandastream.h" #include "tools/cabana/streams/replaystream.h" #ifdef __linux__ #include "tools/cabana/streams/socketcanstream.h" #endif -#include "tools/cabana/utils/qtutil.h" +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/utils/util.h" + +#ifdef __GLIBC__ +#include +#endif namespace { @@ -25,6 +30,7 @@ struct CabanaArgs { bool msgq = false; bool panda = false; bool no_vipc = false; + bool no_cache = false; std::string panda_serial; std::string socketcan; std::string zmq; @@ -56,11 +62,11 @@ void printUsage(const char *argv0) { " --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" + " --no-cache turn off the local route file cache\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]); @@ -70,9 +76,8 @@ bool takeValue(int argc, char *argv[], int &i, std::string &out) { 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; +// the process exit code, or nullopt to continue +std::optional parseArgs(int argc, char *argv[], CabanaArgs &args) { for (int i = 1; i < argc; ++i) { const char *a = argv[i]; if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) { @@ -97,8 +102,7 @@ int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { args.panda = true; } else if (std::strcmp(a, "--socketcan") == 0) { if (!takeValue(argc, argv, i, args.socketcan)) return 1; -#ifdef __linux__ -#else +#ifndef __linux__ fprintf(stderr, "error: --socketcan is only supported on Linux\n"); return 1; #endif @@ -108,6 +112,8 @@ int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { 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, "--no-cache") == 0) { + args.no_cache = true; } else if (std::strcmp(a, "--dbc") == 0) { if (!takeValue(argc, argv, i, args.dbc)) return 1; } else if (a[0] == '-') { @@ -122,51 +128,45 @@ int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { return 1; } } - ok = true; - return 0; + return std::nullopt; } } // namespace int main(int argc, char *argv[]) { - QCoreApplication::setApplicationName("Cabana"); - initApp(argc, argv, false); - QApplication app(argc, argv); - app.setApplicationDisplayName("Cabana"); - //app.setWindowIcon(QIcon(":cabana-icon.png")); // TODO: do this in imgui - - // Marshal exit onto the GUI thread (qApp methods are not thread-safe). - UnixSignalHandler signalHandler([]() { - QMetaObject::invokeMethod(qApp, []() { - printf("\nexiting...\n"); - qApp->closeAllWindows(); - qApp->exit(); - }, Qt::QueuedConnection); - }); - utils::setTheme(settings.theme); +#ifdef __GLIBC__ + // Worker threads (sparklines, chart series, replay) would each get their own glibc malloc arena and the + // arenas fragment without bound (RSS grew ~3 MB/min with charts open). macOS has a single allocator zone. + mallopt(M_ARENA_MAX, 1); +#endif + // ensure the current dir matches the executable's directory + std::error_code ec; + std::filesystem::current_path(executableDir(), ec); CabanaArgs args; - bool args_ok = false; - if (const int code = parseArgs(argc, argv, args, args_ok); !args_ok) { - return code; - } + if (auto code = parseArgs(argc, argv, args)) return *code; - AbstractStream *stream = nullptr; + std::unique_ptr stream; + StreamLoader stream_loader; if (args.msgq) { - stream = new DeviceStream(); + stream = std::make_unique(); } else if (!args.zmq.empty()) { - stream = new DeviceStream(args.zmq); - } else if (args.panda || !args.panda_serial.empty()) { + stream = std::make_unique(args.zmq); + } else if (args.panda) { try { - stream = new PandaStream({.serial = args.panda_serial}); + stream = std::make_unique(PandaStreamConfig{.serial = args.panda_serial}); } catch (std::exception &e) { fprintf(stderr, "%s\n", e.what()); - return 0; + return 1; } #ifdef __linux__ - } else if (SocketCanStream::available() && !args.socketcan.empty()) { - stream = new SocketCanStream({.device = args.socketcan}); + } else if (!args.socketcan.empty()) { + if (!SocketCanStream::available()) { + fprintf(stderr, "error: SocketCAN is not available on this system\n"); + return 1; + } + stream = std::make_unique(SocketCanStreamConfig{.device = args.socketcan}); #endif } else { uint32_t replay_flags = REPLAY_FLAG_NONE; @@ -174,23 +174,29 @@ int main(int argc, char *argv[]) { 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; + if (args.no_cache) replay_flags |= REPLAY_FLAG_NO_FILE_CACHE; - QString route; + std::string route; if (!args.route.empty()) { - route = QString::fromStdString(args.route); + route = args.route; } else if (args.demo) { route = DEMO_ROUTE; } - if (!route.isEmpty()) { - auto replay_stream = std::make_unique(); - Connection err = replay_stream->error.connect([](const std::string &msg) { fprintf(stderr, "%s\n", msg.c_str()); }); - if (!replay_stream->loadRoute(route.toStdString(), args.data_dir, replay_flags, args.auto_source)) { - return 0; - } - stream = replay_stream.release(); + if (!route.empty()) { + // the route file listing hits the comma API; load behind the window instead of before it + stream_loader = [route, data_dir = args.data_dir, replay_flags, auto_source = args.auto_source]() -> std::unique_ptr { + auto replay_stream = std::make_unique(); + Connection err = replay_stream->error.connect([](const std::string &msg) { + fprintf(stderr, "%s\n", msg.c_str()); + utils::runOnMainThread([msg]() { MessageBox::warning("Error", msg); }); + }); + if (!replay_stream->loadRoute(route, data_dir, replay_flags, auto_source)) { + return nullptr; + } + return replay_stream; + }; } } - MainWindow w(stream, QString::fromStdString(args.dbc)); - return app.exec(); + return run(std::move(stream), std::move(stream_loader), args.dbc); } diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc new file mode 100644 index 0000000000..80ae6a3da2 --- /dev/null +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -0,0 +1,944 @@ +#include "tools/cabana/ui/mainwin.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include + +#include "json11/json11.hpp" +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/inistate.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/tools/findsignal.h" +#include "tools/cabana/ui/tools/findsimilarbits.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/export.h" +#include "tools/cabana/utils/util.h" +#include "tools/replay/py_tools.h" +#include "tools/replay/util.h" + +namespace { +// dock window ids (the visible titles change, the part after ### is the identity) +constexpr const char *VIDEO_PANEL = "###VideoPanel"; +constexpr const char *CENTER_PANEL = "Signals###CenterWidget"; +constexpr const char *CHARTS_WINDOW = "Charts###ChartsWindow"; +} // namespace + +MainWindow::MainWindow(GLFWwindow *window, std::unique_ptr stream, StreamLoader stream_loader, + const std::string &dbc_file) : window_(window) { + can = &dummy_; + messages_visible_ = inistate::main_window.messages_visible; + video_visible_ = inistate::main_window.video_visible; + charts_visible_ = inistate::main_window.charts_visible; + loadFingerprints(); + 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") { + opendbc_names_.push_back(entry.path().filename().string()); + } + } + std::sort(opendbc_names_.begin(), opendbc_names_.end()); + + // download handlers are called from download threads + installDownloadProgressHandler([this](uint64_t cur, uint64_t total, bool success) { + utils::runOnMainThread([this, cur, total, success]() { updateDownloadProgress(cur, total, success); }); + }); + installMessageHandler([this](ReplyMsgType type, const std::string &msg) { + utils::runOnMainThread([this, msg]() { showStatusMessage(msg, 2000); }); + }); + + connections_.push_back(dbc()->fileChanged.connect([this]() { dbcFileChanged(); })); + connections_.push_back(UndoStack::instance()->cleanChanged.connect([this](bool clean) { + window_modified_ = !clean; + updateWindowTitle(); + })); + + startup_stream_ = std::move(stream); + startup_loader_ = std::move(stream_loader); + nextFrame([this, dbc_file]() { + if (startup_loader_) { + loadStartupStream(dbc_file); + } else { + startup_stream_ ? openStream(std::move(startup_stream_), dbc_file) : selectAndOpenStream(); + } + }); +} + +void MainWindow::loadFingerprints() { + std::ifstream json_file((executableDir() / "dbc/car_fingerprint_to_dbc.json")); + 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; + for (const auto &kv : doc.object_items()) { + if (kv.second.is_string()) { + fingerprint_to_dbc_.emplace(kv.first, kv.second.string_value()); + } + } +} + +void MainWindow::drawFileMenu() { + const bool has_stream = hasStream(); + if (dropdown::Item("Open Stream...")) selectAndOpenStream(); + if (dropdown::Item("Close Stream", nullptr, false, has_stream)) closeStream(); + if (dropdown::Item("Export to CSV...", nullptr, false, has_stream)) exportToCSV(); + ImGui::Separator(); + + if (dropdown::Item("New DBC File", shortcut("N").c_str())) newFile(); + if (dropdown::Item("Open DBC File...", shortcut("O").c_str())) openFile(); + + if (dropdown::BeginMenu("Manage DBC Files", has_stream)) { + drawManageDBCsMenu(); + dropdown::EndMenu(); + } + if (dropdown::BeginMenu("Open Recent")) { + drawRecentFilesMenu(); + dropdown::EndMenu(); + } + + ImGui::Separator(); + if (dropdown::BeginMenu("Load DBC from commaai/opendbc")) { + for (const auto &name : opendbc_names_) { + if (dropdown::Item(name.c_str())) loadDBCFromOpendbc(name); + } + dropdown::EndMenu(); + } + if (dropdown::Item("Load DBC from Clipboard")) loadFromClipboard(); + + ImGui::Separator(); + const int cnt = dbc()->nonEmptyDBCCount(); + const std::string save_text = cnt > 1 ? "Save " + std::to_string(cnt) + " DBCs..." : "Save DBC..."; + if (dropdown::Item(save_text.c_str(), shortcut("S").c_str(), false, cnt > 0)) save(); + if (dropdown::Item("Save DBC As...", shortcut("Shift+S").c_str(), false, cnt == 1)) saveAs(); + // TODO: Support clipboard for multiple files + if (dropdown::Item("Copy DBC to Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); + + ImGui::Separator(); + if (dropdown::Item("Settings...")) openSettings(); + + ImGui::Separator(); + if (dropdown::Item("Exit", shortcut("Q").c_str())) close(); +} + +void MainWindow::drawMenuBar() { + // Avoid a double border with the separator drawn below. + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + const bool open = ImGui::BeginMainMenuBar(); + ImGui::PopStyleVar(); + if (!open) return; + if (dropdown::BeginMenu("File")) { + drawFileMenu(); + dropdown::EndMenu(); + } + + if (dropdown::BeginMenu("Edit")) { + auto stack = UndoStack::instance(); + const std::string undo_text = stack->canUndo() ? "Undo " + stack->undoText() : "Undo"; + const std::string redo_text = stack->canRedo() ? "Redo " + stack->redoText() : "Redo"; + if (dropdown::Item(undo_text.c_str(), shortcut("Z").c_str(), false, stack->canUndo())) stack->undo(); + if (dropdown::Item(redo_text.c_str(), shortcut("Shift+Z").c_str(), false, stack->canRedo())) stack->redo(); + dropdown::EndMenu(); + } + + if (dropdown::BeginMenu("View")) { + if (dropdown::Item("Full Screen", shortcut("F11").c_str())) toggleFullScreen(); + ImGui::Separator(); + dropdown::Item(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); + dropdown::Item(videoPanelTitle(), nullptr, &video_visible_); + dropdown::Item("Charts", nullptr, &charts_visible_); + ImGui::Separator(); + if (dropdown::Item("Reset Window Layout")) { + messages_visible_ = video_visible_ = charts_visible_ = true; + reset_layout_ = true; + } + dropdown::EndMenu(); + } + + if (dropdown::BeginMenu("Tools", hasStream())) { + if (dropdown::Item("Find Similar Bits")) findSimilarBits(); + if (dropdown::Item("Find Signal")) findSignal(); + dropdown::EndMenu(); + } + + if (dropdown::BeginMenu("Help")) { + if (dropdown::Item("Help", "F1")) toggleHelp(); + dropdown::EndMenu(); + } + const ImVec2 min = ImGui::GetWindowPos(); + const ImVec2 max(min.x + ImGui::GetWindowWidth(), min.y + ImGui::GetWindowHeight()); + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(min.x, max.y - 1.0f), max, ImGui::GetColorU32(ImGuiCol_Border)); + ImGui::EndMainMenuBar(); +} + +void MainWindow::createDockWidgets() { + widget_connections_.clear(); + messages_widget_ = std::make_unique(); + widget_connections_.push_back(messages_widget_->msgSelectionChanged.connect([this](const MessageId &id) { center_widget_.setMessage(id); })); + + charts_widget_ = std::make_unique(); + center_widget_.setChartsWidget(charts_widget_.get()); + video_widget_ = std::make_unique(); +} + +void MainWindow::showStatusMessage(const std::string &msg, int timeout_ms) { + status_bar_.message = msg; + status_bar_.message_until = timeout_ms > 0 ? ImGui::GetTime() + timeout_ms / 1000.0 : 0; +} + +void MainWindow::updateWindowTitle() { + std::string title; + for (auto f : dbc()->nonEmptyDBCFiles()) { + if (!title.empty()) title += " | "; + title += "(" + toString(dbc()->sources(f)) + ") " + f->name(); + } + if (window_modified_) title += "*"; + if (hasStream()) { + const std::string stream_title = can->liveStreaming() ? videoPanelTitle() : can->routeName(); + title = title.empty() ? stream_title : stream_title + " \xe2\x80\x94 " + title; + } + if (!title.empty()) title += " \xe2\x80\x94 "; // em dash separator + title += "Cabana"; + glfwSetWindowTitle(window_, title.c_str()); +} + +void MainWindow::dbcFileChanged() { + UndoStack::instance()->clear(); + updateWindowTitle(); + nextFrame([this]() { restoreSessionState(); }); +} + +void MainWindow::selectAndOpenStream() { + stream_selector_.open([this](std::unique_ptr stream, const std::string &dbc_file) { + if (stream) { + openStream(std::move(stream), dbc_file); + } else if (!stream_) { + openStream(std::make_unique()); + } + }); +} + +// the route file listing hits the comma API, so the loader runs on a worker behind the window +void MainWindow::loadStartupStream(const std::string &dbc_file) { + wait_dlg_.text = "Loading route..."; + wait_dlg_.value = 0; + wait_dlg_.open = true; + wait_dlg_.show_at = ImGui::GetTime() + 4.0; // minimum duration before the dialog shows + ThreadPool::instance().run([this, dbc_file, loader = std::move(startup_loader_)]() { + AbstractStream *loaded = nullptr; + std::string error; + try { + loaded = loader().release(); + } catch (const std::exception &e) { + // the pool swallows exceptions, so the wait dialog would spin forever + error = e.what(); + } + utils::runOnMainThread([this, dbc_file, loaded, error]() { + wait_dlg_.open = false; + std::unique_ptr stream(loaded); + if (!error.empty()) { + fprintf(stderr, "%s\n", error.c_str()); + MessageBox::warning("Failed to load route", error); + } + stream ? openStream(std::move(stream), dbc_file) : openStream(std::make_unique()); + }); + }); +} + +void MainWindow::closeStream() { + openStream(std::make_unique()); + if (dbc()->nonEmptyDBCCount() > 0) { + dbc()->fileChanged(); + } + showStatusMessage("Stream closed"); +} + +void MainWindow::exportToCSV() { + std::string dir = settings.last_dir + "/" + can->routeName() + ".csv"; + FileDialog::getSaveFileName("Export stream to CSV file", dir, ".csv", [](const std::string &fn) { + if (!fn.empty()) { + utils::exportToCSV(fn); + } + }); +} + +void MainWindow::newFile(SourceSet s) { + closeFile(s, [s]() { dbc()->open(s, std::string(""), std::string("")); }); +} + +void MainWindow::openFile(SourceSet s) { + remindSaveChanges([this, s]() { + FileDialog::getOpenFileName("Open File", settings.last_dir, ".dbc", [this, s](const std::string &fn) { + if (!fn.empty()) { + loadFile(fn, s); + } + }); + }); +} + +void MainWindow::loadFile(const std::string &fn, SourceSet s, std::function then) { + if (!fn.empty()) { + closeFile(s, [this, fn, s, then]() { + std::string error; + if (dbc()->open(s, fn, &error)) { + updateRecentFiles(fn); + showStatusMessage("DBC file " + fn + " loaded", 2000); + if (then) then(); + } else { + MessageBox::warning("Failed to load DBC file", "Failed to parse DBC file " + fn, error, then); + } + }); + } else if (then) { + then(); + } +} + +void MainWindow::loadDBCFromOpendbc(const std::string &name) { + loadFile(std::string(OPENDBC_FILE_PATH) + "/" + name); +} + +void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { + std::string text; + if (!utils::getClipboardText(&text)) { + MessageBox::warning("Load from Clipboard", "No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland)."); + return; + } + if (text.empty()) { + MessageBox::warning("Load from Clipboard", "Clipboard is empty."); + return; + } + + closeFile(s, [s, text]() { + std::string error; + bool ret = dbc()->open(s, std::string(""), text, &error); + if (ret && dbc()->nonEmptyDBCCount() > 0) { + MessageBox::information("Load from Clipboard", "DBC loaded successfully."); + } else { + MessageBox::warning("Failed to load DBC from clipboard", "Make sure the clipboard contains correctly formatted DBC text.", error); + } + }); +} + +MainWindow::~MainWindow() { + installDownloadProgressHandler(nullptr); + installMessageHandler(nullptr); + releaseStream(); + can = nullptr; +} + +// the tool dialogs are connected into the messages widget, and the video widget's RouteInfoDlg keeps a raw +// pointer to the replay, so the dialogs go first and the widgets before the stream; the stream's destructor +// joins the threads that read the global `can` +void MainWindow::releaseStream() { + tool_dialogs_.clear(); + wait_dlg_.connection.disconnect(); + wait_dlg_.open = false; + widget_connections_.clear(); + charts_widget_.reset(); + video_widget_.reset(); + center_widget_.clear(); + messages_widget_.reset(); + stream_connections_.clear(); + stream_.reset(); + can = &dummy_; +} + +void MainWindow::openStream(std::unique_ptr stream, const std::string &dbc_file) { + releaseStream(); + startStream(std::move(stream), dbc_file); +} + +void MainWindow::startStream(std::unique_ptr stream, const std::string &dbc_file) { + stream_ = std::move(stream); + can = stream_.get(); + stream_connections_.push_back(can->error.connect([](const std::string &msg) { + MessageBox::warning("Error", msg); + })); + can->start(); + updateWindowTitle(); + + loadFile(dbc_file, SOURCE_ALL, [this]() { + showStatusMessage("Stream [" + can->routeName() + "] started", 2000); + createDockWidgets(); + + // Don't overwrite already loaded DBC + if (!dbc()->nonEmptyDBCCount()) { + newFile(); + } + + stream_connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { eventsMerged(); })); + + if (hasStream()) { + wait_dlg_.text = can->liveStreaming() ? "Waiting for the live stream to start..." : "Loading segment data..."; + wait_dlg_.value = 0; + wait_dlg_.open = true; + wait_dlg_.show_at = ImGui::GetTime() + 4.0; // minimum duration before the dialog shows + wait_dlg_.connection = can->eventsMerged.connect([this](const MessageEventsMap &) { + wait_dlg_.open = false; + wait_dlg_.connection.disconnect(); + }); + } + }); +} + +void MainWindow::eventsMerged() { + const std::string fingerprint = can->carFingerprint(); + if (!can->liveStreaming() && std::exchange(car_fingerprint_, fingerprint) != fingerprint) { + // Don't overwrite already loaded DBC + auto it = fingerprint_to_dbc_.find(car_fingerprint_); + if (!dbc()->nonEmptyDBCCount() && it != fingerprint_to_dbc_.end()) { + nextFrame([this, dbc_name = it->second]() { loadDBCFromOpendbc(dbc_name + ".dbc"); }); + } + } +} + +void MainWindow::saveFiles(bool as, std::function then) { + const std::vector files = dbc()->nonEmptyDBCFiles(); + auto next = std::make_shared>(); + *next = [this, as, files, next, then](size_t i) { + if (i >= files.size()) { + if (then) then(); + return; + } + auto cb = [next, i]() { (*next)(i + 1); }; + as ? saveFileAs(files[i], cb) : saveFile(files[i], cb); + }; + (*next)(0); +} + +void MainWindow::save(std::function then) { + saveFiles(false, std::move(then)); +} + +void MainWindow::saveAs(std::function then) { + saveFiles(true, std::move(then)); +} + +void MainWindow::closeFile(SourceSet s, std::function then) { + remindSaveChanges([s, then]() { + if (s == SOURCE_ALL) { + dbc()->closeAll(); + } else { + dbc()->close(s); + } + if (then) then(); + }); +} + +void MainWindow::closeFile(DBCFile *dbc_file) { + assert(dbc_file != nullptr); + remindSaveChanges([this, dbc_file]() { + dbc()->close(dbc_file); + // Ensure we always have at least one file open + if (dbc()->dbcCount() == 0) { + newFile(); + } + }); +} + +void MainWindow::saveFile(DBCFile *dbc_file, std::function then) { + assert(dbc_file != nullptr); + if (!dbc_file->filename.empty()) { + dbc_file->save(); + UndoStack::instance()->setClean(); + showStatusMessage("File saved", 2000); + if (then) then(); + } else if (!dbc_file->isEmpty()) { + saveFileAs(dbc_file, then); + } else if (then) { + then(); + } +} + +void MainWindow::saveFileAs(DBCFile *dbc_file, std::function then) { + std::string title = "Save File (bus: " + toString(dbc()->sources(dbc_file)) + ")"; + std::string default_path = (std::filesystem::path(settings.last_dir) / "untitled.dbc").string(); + FileDialog::getSaveFileName(title, default_path, ".dbc", [this, dbc_file, then](const std::string &fn) { + if (!fn.empty()) { + dbc_file->saveAs(fn); + UndoStack::instance()->setClean(); + showStatusMessage("File saved as " + fn, 2000); + updateRecentFiles(fn); + } + if (then) then(); + }); +} + +void MainWindow::saveToClipboard() { + // Should not be called with more than 1 file open + for (auto dbc_file : dbc()->nonEmptyDBCFiles()) { + saveFileToClipboard(dbc_file); + } +} + +void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { + assert(dbc_file != nullptr); + copyToClipboard(dbc_file->generateDBC()); +} + +void MainWindow::copyToClipboard(const std::string &text) { + if (utils::setClipboardText(text)) { + MessageBox::information("Copy to Clipboard", "DBC copied successfully."); + } else { + MessageBox::warning("Copy to Clipboard", "Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland)."); + } +} + +void MainWindow::drawManageDBCsMenu() { + for (int source : can->sources) { + if (source >= 64) continue; // Sent and blocked buses are handled implicitly + + SourceSet ss = {source, uint8_t(source + 128), uint8_t(source + 192)}; + + auto dbc_file = dbc()->findDBCFile(source); + const std::string title = "Bus " + std::to_string(source) + " (" + (dbc_file ? dbc_file->name() : "No DBCs loaded") + ")"; + ImGui::PushID(source); + if (dropdown::BeginMenu(title.c_str())) { + if (dropdown::Item("New DBC File")) newFile(ss); + if (dropdown::Item("Open DBC File...")) openFile(ss); + if (dropdown::Item("Load DBC from Clipboard")) loadFromClipboard(ss, false); + + // Show sub-menu for each dbc for this source. + if (dbc_file) { + ImGui::Separator(); + dropdown::Item((dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")").c_str(), nullptr, false, false); + if (dropdown::Item("Save...")) saveFile(dbc_file); + if (dropdown::Item("Save As...")) saveFileAs(dbc_file); + if (dropdown::Item("Copy to Clipboard")) saveFileToClipboard(dbc_file); + if (dropdown::Item("Remove from This Bus...")) closeFile(ss, {}); + if (dropdown::Item("Remove from All Buses...")) closeFile(dbc_file); + } + dropdown::EndMenu(); + } + ImGui::PopID(); + } +} + +void MainWindow::updateRecentFiles(const std::string &fn) { + settings.recent_files.erase(std::remove(settings.recent_files.begin(), settings.recent_files.end(), fn), settings.recent_files.end()); + settings.recent_files.insert(settings.recent_files.begin(), fn); + while (settings.recent_files.size() > MAX_RECENT_FILES) { + settings.recent_files.pop_back(); + } + settings.last_dir = std::filesystem::absolute(fn).parent_path().string(); +} + +void MainWindow::drawRecentFilesMenu() { + int num_recent_files = std::min(settings.recent_files.size(), MAX_RECENT_FILES); + if (!num_recent_files) { + dropdown::Item("No Recent Files", nullptr, false, false); + return; + } + + for (int i = 0; i < num_recent_files; ++i) { + std::string text = std::to_string(i + 1) + " " + std::filesystem::path(settings.recent_files[i]).filename().string(); + ImGui::PushID(i); + if (dropdown::Item(text.c_str())) loadFile(settings.recent_files[i]); + ImGui::PopID(); + } +} + +void MainWindow::remindSaveChanges(std::function then) { + if (UndoStack::instance()->isClean()) { + UndoStack::instance()->clear(); + if (then) then(); + return; + } + std::string text = "You have unsaved changes. Select OK to save them or Cancel to discard them."; + MessageBox::question("Unsaved Changes", text, [this, then](bool ok) { + if (ok) { + save([this, then]() { remindSaveChanges(then); }); + } else { + UndoStack::instance()->clear(); + if (then) then(); + } + }); +} + +void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool success) { + const double fraction = total > 0 ? cur / (double)total : 0.0; + if (wait_dlg_.open) wait_dlg_.value = (int)(fraction * 100); + if (success && cur < total) { + status_bar_.progress_value = fraction; + status_bar_.progress_text = "Downloading " + std::to_string((int)(fraction * 100)) + "% (" + formattedDataSize(total) + ")"; + status_bar_.progress_visible = true; + } else { + status_bar_.progress_visible = false; + } +} + +void MainWindow::close() { + if (closing_) return; + closing_ = true; + remindSaveChanges([this]() { finishClose(); }); +} + +void MainWindow::finishClose() { + // save states + auto &state = inistate::main_window; + state.maximized = glfwGetWindowAttrib(window_, GLFW_MAXIMIZED); + if (full_screen_) { +#ifndef __APPLE__ + // macOS full screen is the native Cocoa toggle, keep the loaded geometry there + state.pos[0] = windowed_rect_[0]; state.pos[1] = windowed_rect_[1]; + state.size[0] = windowed_rect_[2]; state.size[1] = windowed_rect_[3]; +#endif + } else if (!state.maximized) { + glfwGetWindowPos(window_, &state.pos[0], &state.pos[1]); + glfwGetWindowSize(window_, &state.size[0], &state.size[1]); + } + state.has_geometry = state.size[0] > 0 && state.size[1] > 0; + state.messages_visible = messages_visible_; + state.video_visible = video_visible_; + state.charts_visible = charts_visible_; + settings.ui_state = inistate::save(); + + saveSessionState(); + settings.save(); + exited_ = true; +} + +void MainWindow::openSettings() { + settings_dialog_.open(); +} + +void MainWindow::findSimilarBits() { + auto dlg = std::make_unique(); + dlg->connections_.push_back(dlg->openMessage.connect([this](const MessageId &id) { messages_widget_->selectMessage(id); })); + tool_dialogs_.push_back(std::move(dlg)); +} + +void MainWindow::findSignal() { + auto dlg = std::make_unique(); + dlg->connections_.push_back(dlg->openMessage.connect([this](const MessageId &id) { messages_widget_->selectMessage(id); })); + tool_dialogs_.push_back(std::move(dlg)); +} + +void MainWindow::toggleHelp() { + help_overlay_.toggle(); +} + +void MainWindow::toggleFullScreen() { +#ifdef __APPLE__ + toggleNativeFullScreen(window_); +#else + full_screen_ = !full_screen_; + if (full_screen_) { + glfwGetWindowPos(window_, &windowed_rect_[0], &windowed_rect_[1]); + glfwGetWindowSize(window_, &windowed_rect_[2], &windowed_rect_[3]); + GLFWmonitor *monitor = glfwGetPrimaryMonitor(); + const GLFWvidmode *mode = glfwGetVideoMode(monitor); + glfwSetWindowMonitor(window_, monitor, 0, 0, mode->width, mode->height, mode->refreshRate); + } else { + glfwSetWindowMonitor(window_, nullptr, windowed_rect_[0], windowed_rect_[1], windowed_rect_[2], windowed_rect_[3], 0); + glfwMaximizeWindow(window_); + } +#endif +} + +void MainWindow::saveSessionState() { + settings.recent_dbc_file = ""; + settings.active_msg_id = ""; + settings.selected_msg_ids.clear(); + settings.active_charts.clear(); + + const auto files = dbc()->nonEmptyDBCFiles(); + if (!files.empty()) settings.recent_dbc_file = files.front()->filename; + + if (auto *detail = center_widget_.getDetailWidget()) { + auto [active_id, ids] = detail->serializeMessageIds(); + settings.active_msg_id = active_id; + settings.selected_msg_ids = ids; + } + if (charts_widget_) { + settings.active_charts = charts_widget_->serializeChartIds(); + } +} + +void MainWindow::restoreSessionState() { + if (settings.recent_dbc_file.empty() || dbc()->nonEmptyDBCCount() == 0) return; + + if (dbc()->nonEmptyDBCFiles().front()->filename != settings.recent_dbc_file) return; + + if (!settings.selected_msg_ids.empty()) { + center_widget_.ensureDetailWidget()->restoreTabs(settings.active_msg_id, settings.selected_msg_ids); + } + + if (charts_widget_ != nullptr && !settings.active_charts.empty()) { + charts_widget_->restoreChartsFromIds(settings.active_charts); + } +} + +void MainWindow::handleShortcuts() { + const ImGuiIO &io = ImGui::GetIO(); + for (const KeyEvent &e : takeKeyEvents()) { + const bool ctrl = e.mods & (GLFW_MOD_CONTROL | GLFW_MOD_SUPER); + const bool shift = e.mods & GLFW_MOD_SHIFT; + // a focused text input consumes Space but not the Ctrl/F-key sequences + if (e.key == GLFW_KEY_SPACE && !ctrl && can && !io.WantTextInput) can->pause(!can->isPaused()); + if (e.key == GLFW_KEY_F1) toggleHelp(); + if (e.key == GLFW_KEY_F11 && ctrl) toggleFullScreen(); + // an open popup or a focused text input takes Esc first + if (e.key == GLFW_KEY_ESCAPE && full_screen_ && !io.WantTextInput && + !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) { + toggleFullScreen(); + } + if (!ctrl) continue; + if (e.key == GLFW_KEY_N) newFile(); + if (e.key == GLFW_KEY_O) openFile(); + if (e.key == GLFW_KEY_S) { + if (shift) { + if (dbc()->nonEmptyDBCCount() == 1) saveAs(); + } else if (dbc()->nonEmptyDBCCount() > 0) { + save(); + } + } + // a focused text input swallows Ctrl+Z / Ctrl+Shift+Z + if (e.key == GLFW_KEY_Z && !io.WantTextInput) shift ? UndoStack::instance()->redo() : UndoStack::instance()->undo(); + if (e.key == GLFW_KEY_Q) close(); + } +} + +void MainWindow::drawStatusBar() { + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetStyle().Colors[ImGuiCol_MenuBarBg]); + ImGui::BeginChild("status_bar", ImVec2(0, ImGui::GetFrameHeight()), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar); + const ImVec2 min = ImGui::GetWindowPos(); + ImGui::GetWindowDrawList()->AddRectFilled(min, ImVec2(min.x + ImGui::GetWindowWidth(), min.y + 1.0f), ImGui::GetColorU32(ImGuiCol_Border)); + // a borderless child gets no WindowPadding, so both ends sit flush against the edge and clip. Inset by + // WindowPadding.x, which lines the text up with the content of the docked panels above (the messages table). + const float width = ImGui::GetContentRegionAvail().x; + const float pad = ImGui::GetStyle().WindowPadding.x; + pushMonoFont(ImGui::GetStyle().FontSizeBase); + const float fps_x = std::max(pad, width - pad - ImGui::CalcTextSize("999 FPS").x); + popMonoFont(); + const float progress_width = std::min(300.0f, std::max(0.0f, fps_x - pad - 20.0f)); + const float progress_x = fps_x - progress_width - 10.0f; + const float message_end = status_bar_.progress_visible ? progress_x - ImGui::GetStyle().ItemSpacing.x : fps_x - 10.0f; + ImGui::PushClipRect(min, ImVec2(min.x + std::max(pad, message_end), min.y + ImGui::GetWindowHeight()), true); + ImGui::SetCursorPosX(pad); + ImGui::AlignTextToFramePadding(); + const float text_y = ImGui::GetCursorPosY(); + // a temporary message hides the normal widgets, permanent widgets stay on the right + auto &bar = status_bar_; + if (!bar.message.empty() && (bar.message_until == 0 || ImGui::GetTime() < bar.message_until)) { + ImGui::TextUnformatted(bar.message.c_str()); + } else { + bar.message.clear(); + ImGui::TextUnformatted("For help, press F1"); + } + ImGui::PopClipRect(); + if (bar.progress_visible && progress_width > 0) { + const float progress_height = 16.0f; + ImGui::PushFont(ImGui::GetFont(), 12.0f); + const std::string percentage = std::to_string((int)(bar.progress_value * 100)) + "%"; + const char *label = bar.progress_text.c_str(); + const float text_width = progress_width - 2 * ImGui::GetStyle().FramePadding.x; + if (ImGui::CalcTextSize(label).x > text_width) label = percentage.c_str(); + if (ImGui::CalcTextSize(label).x > text_width) label = ""; + ImGui::SameLine(progress_x); + ImGui::SetCursorPosY((ImGui::GetWindowHeight() - progress_height) / 2.0f); + ImGui::ProgressBar(bar.progress_value, ImVec2(progress_width, progress_height), label); + ImGui::PopFont(); + ImGui::SetItemTooltip("%s", bar.progress_text.c_str()); + } + ImGui::SameLine(fps_x); + ImGui::SetCursorPosY(text_y); + pushMonoFont(ImGui::GetStyle().FontSizeBase); + ImGui::Text("%3.0f FPS", ImGui::GetIO().Framerate); + popMonoFont(); + ImGui::SetItemTooltip("UI rendering rate (frames per second)"); + ImGui::EndChild(); + ImGui::PopStyleColor(); +} + +void MainWindow::drawWaitDialog() { + const char *id = "###WaitDialog"; + if (wait_dlg_.open && !ImGui::IsPopupOpen(id) && ImGui::GetTime() >= wait_dlg_.show_at) ImGui::OpenPopup(id); + if (!ImGui::IsPopupOpen(id)) return; // keep submitting until CloseCurrentPopup ran, a stale modal blocks all input + ImGui::SetNextWindowSize(ImVec2(400.0f, 0.0f), ImGuiCond_Always); + setNextDialogWindow(ImVec2(0.0f, 0.0f)); + if (ImGui::BeginPopupModal(id, nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextUnformatted(wait_dlg_.text.c_str()); + // no text until the progress is set + ImGui::ProgressBar(wait_dlg_.value / 100.0f, ImVec2(-1.0f, 0.0f), wait_dlg_.value == 0 ? "" : (const char *)nullptr); + bool abort = false, rejected = false; + dialogButtons("Abort", &abort, &rejected, true, nullptr); + if (abort || rejected) { + wait_dlg_.open = false; + close(); + } + if (!wait_dlg_.open) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } +} + +void MainWindow::drawDockspace() { + const ImGuiViewport *viewport = ImGui::GetMainViewport(); + // Use the menu bar's current-frame reservation, including on the first frame. + const ImRect work_rect = static_cast(viewport)->GetBuildWorkRect(); + ImGui::SetNextWindowPos(work_rect.Min); + ImGui::SetNextWindowSize(work_rect.GetSize()); + ImGui::SetNextWindowViewport(viewport->ID); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus | + ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoBackground | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; + ImGui::Begin("##host", nullptr, flags); + ImGui::PopStyleVar(3); + + // the status bar sits below the dockspace: reserve its height plus the item spacing between the two, + // otherwise the host window is a few pixels taller than the viewport and scrolls + const float status_height = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y; + const ImVec2 dock_size(ImGui::GetContentRegionAvail().x, ImGui::GetContentRegionAvail().y - status_height); + const ImGuiID dock_id = ImGui::GetID("cabana_dockspace"); + if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr || + (!ImGui::FindWindowByName(CHARTS_WINDOW) && !ImGui::FindWindowSettingsByID(ImHashStr(CHARTS_WINDOW)))) { + // Messages left, route above charts on the right, details in the middle. + ImGui::DockBuilderRemoveNode(dock_id); + ImGui::DockBuilderAddNode(dock_id, ImGuiDockNodeFlags_DockSpace); + ImGui::DockBuilderSetNodePos(dock_id, ImGui::GetCursorScreenPos()); + ImGui::DockBuilderSetNodeSize(dock_id, dock_size); + ImGuiID center = dock_id, left = 0, right = 0, charts = 0; + ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.28f, &left, ¢er); + ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.4f, &right, ¢er); + ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.55f, &charts, &right); + ImGui::DockBuilderDockWindow(CHARTS_WINDOW, charts); + ImGui::DockBuilderDockWindow(MESSAGES_PANEL_ID, left); + ImGui::DockBuilderDockWindow(VIDEO_PANEL, right); + ImGui::DockBuilderDockWindow(CENTER_PANEL, center); + ImGui::DockBuilderFinish(dock_id); + reset_layout_ = false; + } + // a panel never shrinks past half the width where the signal view's tool bar squishes + const float min_panel_width = (SignalView::minimumWidth() + (ImGui::GetStyle().WindowPadding.x + ImGui::GetStyle().WindowBorderSize) * 2) * 0.5f; + ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(min_panel_width, ImGui::GetStyle().WindowMinSize.y)); + ImGui::DockSpace(dock_id, dock_size); + ImGui::PopStyleVar(); + drawStatusBar(); + ImGui::End(); +} + +namespace { +// closing a panel that floated out into its own os window brings it back into the default layout, only +// the close button of a docked panel hides it +bool floatingOut() { return ImGui::GetWindowViewport() != ImGui::GetMainViewport(); } + +// the side panels float out like the dialogs, and their dock nodes have no window menu button: its +// only entry hides the tab bar, and with it the title and the close button +void setNextPanelClass() { + ImGuiWindowClass window_class; + window_class.ViewportFlagsOverrideSet = ImGuiViewportFlags_NoAutoMerge; + window_class.DockNodeFlagsOverrideSet = ImGuiDockNodeFlags_NoWindowMenuButton; + ImGui::SetNextWindowClass(&window_class); +} + +bool beginPanel(const char *name, bool *open, ImGuiWindowFlags flags = 0) { + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + const bool visible = ImGui::Begin(name, open, flags | ImGuiWindowFlags_NoCollapse); + ImGui::PopStyleVar(); + return visible; +} +} // namespace + +void MainWindow::drawMessagesPanel() { + const std::string name = (messages_widget_ ? messages_widget_->title() : "MESSAGES") + std::string(MESSAGES_PANEL_ID); + setNextPanelClass(); + if (beginPanel(name.c_str(), &messages_visible_)) { + if (messages_widget_) { + help_overlay_.add(messages_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + messages_widget_->draw(); + } + } + const bool floating = floatingOut(); + ImGui::End(); + if (!messages_visible_ && floating) messages_visible_ = reset_layout_ = true; +} + +void MainWindow::drawVideoPanel() { + const std::string name = std::string(videoPanelTitle()) + VIDEO_PANEL; + setNextPanelClass(); + const bool video_open = beginPanel(name.c_str(), &video_visible_); + const bool floating = floatingOut(); + if (video_widget_ && !video_open) { + video_widget_->setVisible(false); // the dock is collapsed or tabbed behind another one, like hideEvent + } else if (video_widget_) { + help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + video_widget_->draw(); + } + ImGui::End(); + if (!video_visible_ && floating) video_visible_ = reset_layout_ = true; +} + +void MainWindow::draw() { +#ifdef __APPLE__ + full_screen_ = isNativeFullScreen(window_); +#endif + auto pending = std::move(next_frame_); + next_frame_.clear(); + for (auto &fn : pending) fn(); + + if (ImGui::GetTopMostPopupModal() == nullptr) { + handleShortcuts(); + } else { + takeKeyEvents(); // modal dialogs swallow the shortcuts + } + if (!full_screen_) drawMenuBar(); + drawDockspace(); + + // the central widget has no scrollbars of its own (the views inside scroll) + setNextPanelClass(); + if (beginPanel(CENTER_PANEL, nullptr, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + center_widget_.draw(); + if (auto *detail = center_widget_.getDetailWidget(); detail && help_overlay_.visible()) { + for (const auto &[text, rect] : detail->helpRects()) help_overlay_.add(text, rect); + } + } + ImGui::End(); + // Submit the same dock windows while loading, so ImGui doesn't collapse their + // nodes and then redistribute the layout when the stream's widgets arrive. + if (messages_visible_) drawMessagesPanel(); + if (video_widget_ && !video_visible_) video_widget_->setVisible(false); + if (video_visible_) drawVideoPanel(); + if (charts_visible_) { + const std::string charts_title = "Charts: " + std::to_string(charts_widget_ ? charts_widget_->chartCount() : 0) + "###ChartsWindow"; + setNextPanelClass(); + if (beginPanel(charts_title.c_str(), &charts_visible_, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + if (charts_widget_) { + help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + charts_widget_->draw(); + } + } + ImGui::End(); + } + for (auto it = tool_dialogs_.begin(); it != tool_dialogs_.end();) { + it = (*it)->draw() ? it + 1 : tool_dialogs_.erase(it); + } + + stream_selector_.draw(); + settings_dialog_.draw(); + drawWaitDialog(); + FileDialog::draw(); + MessageBox::draw(); + help_overlay_.draw(); + + // Escape closes the top-most non-modal popup (a menu or a combo list) on its own; the modal dialogs + // handled Escape themselves above when they were on top + if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + ImGuiWindow *top = topPopupWindow(); + if (top != nullptr && !(top->Flags & ImGuiWindowFlags_Modal)) ImGui::ClosePopupToLevel(GImGui->OpenPopupStack.Size - 1, true); + } +} diff --git a/openpilot/tools/cabana/ui/mainwin.h b/openpilot/tools/cabana/ui/mainwin.h new file mode 100644 index 0000000000..b7afa1769d --- /dev/null +++ b/openpilot/tools/cabana/ui/mainwin.h @@ -0,0 +1,136 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/settingsdialog.h" +#include "tools/cabana/ui/dialogs/streamselector.h" +#include "tools/cabana/ui/helpoverlay.h" +#include "tools/cabana/ui/tools/tooldialog.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/widgets/detailwidget.h" +#include "tools/cabana/ui/widgets/messageswidget.h" +#include "tools/cabana/ui/widgets/videowidget.h" + +struct GLFWwindow; + +class MainWindow { +public: + MainWindow(GLFWwindow *window, std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file); + ~MainWindow(); + void draw(); + void close(); // remind unsaved changes, save state, exit + bool exited() const { return exited_; } + void showStatusMessage(const std::string &msg, int timeout_ms = 0); + void loadFile(const std::string &fn, SourceSet s = SOURCE_ALL, std::function then = {}); + + void selectAndOpenStream(); + void openStream(std::unique_ptr stream, const std::string &dbc_file = {}); + void closeStream(); + void exportToCSV(); + + void newFile(SourceSet s = SOURCE_ALL); + void openFile(SourceSet s = SOURCE_ALL); + void loadDBCFromOpendbc(const std::string &name); + void save(std::function then = {}); + void saveAs(std::function then = {}); + void saveToClipboard(); + +private: + bool hasStream() const { return dynamic_cast(can) == nullptr; } + const char *videoPanelTitle() const { return hasStream() && can->liveStreaming() ? "Live Stream" : "Video"; } + void releaseStream(); + void startStream(std::unique_ptr stream, const std::string &dbc_file); + void loadStartupStream(const std::string &dbc_file); + void remindSaveChanges(std::function then); + void closeFile(SourceSet s, std::function then); + void closeFile(DBCFile *dbc_file); + void saveFiles(bool as, std::function then); + void saveFile(DBCFile *dbc_file, std::function then = {}); + void saveFileAs(DBCFile *dbc_file, std::function then = {}); + void saveFileToClipboard(DBCFile *dbc_file); + void copyToClipboard(const std::string &text); + void loadFingerprints(); + void loadFromClipboard(SourceSet s = SOURCE_ALL, bool close_all = true); + void updateRecentFiles(const std::string &fn); + void dbcFileChanged(); + void updateDownloadProgress(uint64_t cur, uint64_t total, bool success); + void openSettings(); + void findSimilarBits(); + void findSignal(); + void toggleHelp(); + void toggleFullScreen(); + void updateWindowTitle(); + void eventsMerged(); + void saveSessionState(); + void restoreSessionState(); + void finishClose(); + void nextFrame(std::function fn) { next_frame_.push_back(std::move(fn)); } + void createDockWidgets(); + + void handleShortcuts(); + void drawMenuBar(); + void drawFileMenu(); + void drawManageDBCsMenu(); + void drawRecentFilesMenu(); + void drawDockspace(); + void drawMessagesPanel(); + void drawVideoPanel(); + void drawStatusBar(); + void drawWaitDialog(); + + GLFWwindow *window_; + std::unique_ptr startup_stream_; // opened on the first frame + StreamLoader startup_loader_; // run on a worker after the first frame + std::unique_ptr stream_; // `can` points here, or at dummy_ when no stream is open + DummyStream dummy_; + std::unique_ptr messages_widget_; + CenterWidget center_widget_; + std::unique_ptr video_widget_; + std::unique_ptr charts_widget_; + StreamSelector stream_selector_; + SettingsDialog settings_dialog_; + HelpOverlay help_overlay_; + std::unordered_map fingerprint_to_dbc_; + std::vector opendbc_names_; + enum { MAX_RECENT_FILES = 15 }; + std::string car_fingerprint_; + bool messages_visible_ = true; + bool video_visible_ = true; + bool charts_visible_ = true; + bool reset_layout_ = false; + bool full_screen_ = false; +#ifndef __APPLE__ + int windowed_rect_[4] = {0, 0, 1600, 900}; +#endif + std::vector> tool_dialogs_; + bool closing_ = false; + bool exited_ = false; + bool window_modified_ = false; + struct StatusBar { + std::string message; + double message_until = 0; + bool progress_visible = false; + float progress_value = 0; + std::string progress_text; + } status_bar_; + // "Loading segment data..." dialog + struct WaitDialog { + bool open = false; + double show_at = 0; + std::string text; + int value = 0; + Connection connection; + } wait_dlg_; + std::vector> next_frame_; + Connections connections_; + Connections stream_connections_; + Connections widget_connections_; +}; diff --git a/openpilot/tools/cabana/ui/qtstate.cc b/openpilot/tools/cabana/ui/qtstate.cc new file mode 100644 index 0000000000..f4283420cd --- /dev/null +++ b/openpilot/tools/cabana/ui/qtstate.cc @@ -0,0 +1,171 @@ +#include "tools/cabana/ui/qtstate.h" + +#include +#include + +namespace qtstate { + +namespace { + +// big-endian QDataStream reader; any read past the end latches the failed state +class Cursor { +public: + explicit Cursor(const std::vector &data) : data_(data) {} + + bool ok() const { return ok_; } + size_t remaining() const { return ok_ ? data_.size() - pos_ : 0; } + + uint8_t u8() { return read(1) ? data_[pos_ - 1] : 0; } + uint16_t u16() { + if (!read(2)) return 0; + return (uint16_t(data_[pos_ - 2]) << 8) | data_[pos_ - 1]; + } + uint32_t u32() { + if (!read(4)) return 0; + return (uint32_t(data_[pos_ - 4]) << 24) | (uint32_t(data_[pos_ - 3]) << 16) | + (uint32_t(data_[pos_ - 2]) << 8) | data_[pos_ - 1]; + } + int32_t i32() { return (int32_t)u32(); } + void skip(size_t n) { read(n); } + +private: + bool read(size_t n) { + if (!ok_ || data_.size() - pos_ < n) { + ok_ = false; + return false; + } + pos_ += n; + return true; + } + + const std::vector &data_; + size_t pos_ = 0; + bool ok_ = true; +}; + +} // namespace + +std::optional parseQtGeometry(const std::vector &data) { + Cursor c(data); + if (c.u32() != 0x01D9D0CB) return std::nullopt; + const uint16_t major = c.u16(); + c.u16(); // minor + if (!c.ok() || major > 3) return std::nullopt; + + c.skip(16); // frameGeometry + int x1 = c.i32(), y1 = c.i32(), x2 = c.i32(), y2 = c.i32(); // normalGeometry + c.i32(); // screenNumber + const bool maximized = c.u8() != 0; + c.u8(); // fullScreen + if (major >= 2) c.i32(); // screenWidth + if (major >= 3) { + // the client-area rect actually restored + x1 = c.i32(); y1 = c.i32(); x2 = c.i32(); y2 = c.i32(); + } + if (!c.ok()) return std::nullopt; + return QtGeometry{x1, y1, x2 - x1 + 1, y2 - y1 + 1, maximized}; +} + +std::optional parseQtSplitter(const std::vector &data) { + Cursor c(data); + if (c.i32() != 0xff) return std::nullopt; + const int32_t version = c.i32(); + if (!c.ok() || version > 1) return std::nullopt; + + const uint32_t count = c.u32(); + if (!c.ok() || count != 2) return std::nullopt; + const int32_t first = c.i32(); + const int32_t second = c.i32(); + if (!c.ok() || first + second <= 0) return std::nullopt; + return QtSplitter{first / float(first + second)}; +} + +std::optional parseQtHeaderState(const std::vector &data) { + constexpr int N = kMessageColumnCount; + Cursor c(data); + if (c.i32() != 0xff) return std::nullopt; + if (c.i32() != 0) return std::nullopt; // version + + c.i32(); // orientation + const int32_t sort_order = c.i32(); + const int32_t sort_section = c.i32(); + const bool sort_shown = c.u8() != 0; + if (!c.ok()) return std::nullopt; + + QtHeaderState state{}; + state.sort_section = sort_section; + state.sort_order = sort_order; + state.sort_shown = sort_shown; + for (int i = 0; i < N; ++i) state.visual[i] = i; + + // visualIndices: logical -> visual, empty means identity + const uint32_t visual_count = c.u32(); + if (!c.ok() || visual_count > c.remaining() / 4) return std::nullopt; + if (visual_count > 0) { + std::vector visual(visual_count); + for (uint32_t i = 0; i < visual_count; ++i) visual[i] = c.i32(); + if (!c.ok()) return std::nullopt; + bool valid = visual_count == (uint32_t)N; + if (valid) { + bool seen[N] = {}; + for (int v : visual) { + if (v < 0 || v >= N || seen[v]) { valid = false; break; } + seen[v] = true; + } + } + if (valid) { + for (int i = 0; i < N; ++i) state.visual[i] = visual[i]; + } + } + + // logicalIndices: visual -> logical, unused but must be consumed + const uint32_t logical_count = c.u32(); + if (!c.ok() || logical_count > c.remaining() / 4) return std::nullopt; + c.skip(logical_count * 4); + + // sectionHidden: QBitArray indexed by visual position + const uint32_t hidden_bits = c.u32(); + if (!c.ok() || hidden_bits / 8 > c.remaining()) return std::nullopt; + std::vector hidden_bytes((hidden_bits + 7) / 8); + for (auto &b : hidden_bytes) b = c.u8(); + if (!c.ok()) return std::nullopt; + + // hiddenSectionSize: logical index -> size before hiding + const uint32_t hidden_size_count = c.u32(); + if (!c.ok() || hidden_size_count > c.remaining() / 8) return std::nullopt; + std::map hidden_sizes; + for (uint32_t i = 0; i < hidden_size_count; ++i) { + const int key = c.i32(); + hidden_sizes[key] = c.i32(); + } + if (!c.ok()) return std::nullopt; + + c.i32(); // length + c.i32(); // sectionCount + c.skip(5); // movable, clickable, highlight, stretchLastSection, cascading + c.skip(24); // stretchSections, contentsSections, defaultSectionSize, minimumSectionSize, defaultAlignment, globalResizeMode + if (!c.ok()) return std::nullopt; + + // SectionItems in visual order; a Qt4 item with count != 1 stands for count sections + const uint32_t item_count = c.u32(); + if (!c.ok() || item_count > c.remaining() / 12) return std::nullopt; + std::vector sizes; + for (uint32_t i = 0; i < item_count; ++i) { + const int size = c.i32(); + const int count = c.i32(); + c.i32(); // resizeMode + if (!c.ok() || count <= 0 || (int)sizes.size() + count > N) return std::nullopt; + for (int j = 0; j < count; ++j) sizes.push_back(size / count); + } + if (!c.ok() || (int)sizes.size() != N) return std::nullopt; + + for (int i = 0; i < N; ++i) { + const int v = state.visual[i]; + state.hidden[i] = (uint32_t)v < hidden_bits && (hidden_bytes[v / 8] & (1 << (v % 8))) != 0; + auto it = hidden_sizes.find(i); + state.width[i] = (state.hidden[i] && it != hidden_sizes.end()) ? it->second : sizes[v]; + } + return state; +} + +} // namespace qtstate diff --git a/openpilot/tools/cabana/ui/qtstate.h b/openpilot/tools/cabana/ui/qtstate.h new file mode 100644 index 0000000000..76dbd7400f --- /dev/null +++ b/openpilot/tools/cabana/ui/qtstate.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include + +// Parsers for the Qt frontend's persisted QByteArray blobs (QWidget::saveGeometry, +// QSplitter::saveState, QHeaderView::saveState). Used once to migrate a Qt cabana.json +// to the imgui frontend's ini state. No Qt and no imgui. +// TODO: Delete this migration (qtstate.{h,cc}, migrateQtState in inistate.cc and the Qt +// byte-array fields in Settings) after users have had time to migrate to ui_state. +namespace qtstate { + +constexpr int kMessageColumnCount = 7; + +struct QtGeometry { + int x, y, w, h; + bool maximized; +}; + +struct QtSplitter { + float ratio; +}; + +struct QtHeaderState { + int sort_section; + int sort_order; // 0 = Qt::AscendingOrder, 1 = Qt::DescendingOrder + bool sort_shown; + int visual[kMessageColumnCount]; + int width[kMessageColumnCount]; + bool hidden[kMessageColumnCount]; +}; + +std::optional parseQtGeometry(const std::vector &data); +std::optional parseQtSplitter(const std::vector &data); +std::optional parseQtHeaderState(const std::vector &data); + +} // namespace qtstate diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc new file mode 100644 index 0000000000..eda301a566 --- /dev/null +++ b/openpilot/tools/cabana/ui/theme.cc @@ -0,0 +1,251 @@ +#include "tools/cabana/ui/theme.h" + +#include +#include +#include + +#include "implot.h" +#include "tools/cabana/core/settings.h" + +namespace fs = std::filesystem; + +namespace { + +constexpr Palette DARK_PALETTE = { + .text = rgb(0xffffff), .text_disabled = rgb(0xcccccc), .text_inactive = rgb(0xa4a4a4), .text_selected = rgb(0xffffff), + .window = rgb(0x353535), .surface = rgb(0x2b2c2d), + .frame = rgb(0x2b2c2d), .frame_hovered = rgb(0x363738), .frame_active = rgb(0x434445), + .button = rgb(0x3f4041), .button_hovered = rgb(0x525354), .button_active = rgb(0x303132), + .header = rgb(0x2d74d2), .header_hovered = rgb(0x323944), .header_active = rgb(0x434445), + .accent = rgb(0x90bfff), + .border = rgb(0xaaaaaa), .separator = rgb(0x353535), .scrollbar_grab = rgb(0xaaaaaa), + .slider_track = rgb(0xaaaaaa), + .tab = rgb(0x2c2c2c), .tab_hovered = rgb(0x444647), .tab_selected = rgb(0x393a3b), .table_header = rgb(0x3f4041), + .grid = rgb(0xdddddd, 50.0f / 255.0f), .badge = rgb(0x626262), + .sparkline_saturation = 1.0f, .sparkline_value = 1.0f, +}; + +constexpr Palette LIGHT_PALETTE = { + .text = rgb(0x000000), .text_disabled = rgb(0x595959), .text_inactive = rgb(0x595959), .text_selected = rgb(0xffffff), + .window = rgb(0xefefef), .surface = rgb(0xffffff), + .frame = rgb(0xffffff), .frame_hovered = rgb(0xf5f9fc), .frame_active = rgb(0xe7f3fb), + .button = rgb(0xefefef), .button_hovered = rgb(0xe7f3fb), .button_active = rgb(0xd4e7f4), + .header = rgb(0x226a99), .header_hovered = rgb(0xe7f3fb), .header_active = rgb(0xd4e7f4), + .accent = rgb(0x226a99), + .border = rgb(0x747474), .separator = rgb(0xd0d0d0), .scrollbar_grab = rgb(0x747474), + .slider_track = rgb(0x747474), + .tab = rgb(0xe5e5e5), .tab_hovered = rgb(0xefefef), .tab_selected = rgb(0xffffff), .table_header = rgb(0xefefef), + .grid = rgb(0x000000, 50.0f / 255.0f), .badge = rgb(0x626262), + .sparkline_saturation = 2.0f, .sparkline_value = 0.7f, +}; + +const Palette *g_palette = &LIGHT_PALETTE; +ImFont *g_ui_font = nullptr; +ImFont *g_bold_font = nullptr; +ImFont *g_mono_font = nullptr; +ImFont *g_large_font = nullptr; + +void addIconFont(float size, ImFont *base) { + ImFontConfig cfg; + cfg.MergeMode = base != nullptr; + cfg.GlyphMinAdvanceX = size; + if (base != nullptr) { + ImFontBaked *baked = base->GetFontBaked(size); + const float center = baked != nullptr ? (baked->Ascent + baked->Descent) * 0.5f : size * 0.5f; + cfg.GlyphOffset.y = std::round(size * 0.5f - center); + } + static const ImWchar ranges[] = {0xF000, 0xF8FF, 0}; + ImGui::GetIO().Fonts->AddFontFromFileTTF(BOOTSTRAP_ICONS_TTF, size, &cfg, ranges); +} + +ImFont *addFont(const fs::path &path, float size) { + ImFontConfig cfg; + cfg.OversampleH = 2; + cfg.OversampleV = 2; + ImFont *font = ImGui::GetIO().Fonts->AddFontFromFileTTF(path.c_str(), size, &cfg); + if (font != nullptr) addIconFont(size, font); + return font; +} + +double linearChannel(double value) { + value /= 255.0; + return value <= 0.04045 ? value / 12.92 : std::pow((value + 0.055) / 1.055, 2.4); +} + +double luminance(const CabanaColor &color) { + return 0.2126 * linearChannel(color.r) + 0.7152 * linearChannel(color.g) + 0.0722 * linearChannel(color.b); +} + +// Directed rounding preserves the luminance bound in 8-bit sRGB. +CabanaColor boundLuminance(const CabanaColor &color, double bound, bool minimum) { + const double current = luminance(color); + if (minimum ? current >= bound : current <= bound) return color; + auto channel = [&](uint8_t value) { + double c = linearChannel(value); + c = minimum ? c + (1.0 - c) * (bound - current) / (1.0 - current) : c * bound / current; + const double srgb = c <= 0.0031308 ? 12.92 * c : 1.055 * std::pow(c, 1.0 / 2.4) - 0.055; + const double scaled = std::clamp(srgb * 255.0, 0.0, 255.0); + return static_cast(minimum ? std::ceil(scaled) : std::floor(scaled)); + }; + return {channel(color.r), channel(color.g), channel(color.b), color.a}; +} + +CabanaColor composite(const CabanaColor &color, const CabanaColor &base) { + auto channel = [&](int foreground, int background) { + return static_cast(std::lround((foreground * color.a + background * (255 - color.a)) / 255.0)); + }; + return {channel(color.r, base.r), channel(color.g, base.g), channel(color.b, base.b)}; +} + +ImVec4 alpha(ImVec4 c, float a) { return ImVec4(c.x, c.y, c.z, a); } + +} // namespace + +void loadFonts() { + ImGuiIO &io = ImGui::GetIO(); + const fs::path fonts = fs::path(CABANA_FONTS_DIR); + g_ui_font = addFont(fonts / "Inter-Regular.ttf", UI_FONT_SIZE); + g_bold_font = addFont(fonts / "Inter-SemiBold.ttf", UI_FONT_SIZE); + g_mono_font = addFont(fonts / "JetBrainsMono-Medium.ttf", 15.0f); + g_large_font = addFont(fonts / "Inter-Bold.ttf", 50.0f); + if (g_ui_font != nullptr) io.FontDefault = g_ui_font; + if (g_bold_font == nullptr) g_bold_font = g_ui_font; + if (g_mono_font == nullptr) g_mono_font = g_ui_font; + if (g_large_font == nullptr) g_large_font = g_bold_font; +} + +void applyTheme(int theme) { + g_palette = theme == DARK_THEME ? &DARK_PALETTE : &LIGHT_PALETTE; + const Palette &p = *g_palette; + const ImVec4 none(0, 0, 0, 0); + + ImGuiStyle &style = ImGui::GetStyle(); + style = ImGuiStyle(); + style.WindowRounding = 6.0f; // dialogs and tooltips; docked panels and os windows ignore it + style.ChildRounding = 6.0f; + style.PopupRounding = 6.0f; + style.FrameRounding = 4.0f; + style.GrabRounding = 4.0f; + style.ScrollbarRounding = 4.0f; + style.TabRounding = 4.0f; + style.WindowBorderSize = 1.0f; + style.FrameBorderSize = 1.0f; + style.TabBorderSize = 1.0f; + style.WindowPadding = ImVec2(spacing::CONTROL, spacing::CONTROL); + style.FramePadding = ImVec2(spacing::CONTROL, spacing::INNER); + style.ItemSpacing = ImVec2(spacing::CONTROL, spacing::CONTROL); + style.ItemInnerSpacing = ImVec2(spacing::INNER, spacing::INNER); + style.CellPadding = ImVec2(6.0f, 4.0f); + style.ScrollbarSize = 14.0f; + style.GrabMinSize = 13.0f; + + ImVec4 *c = style.Colors; + c[ImGuiCol_Text] = p.text; + c[ImGuiCol_TextDisabled] = p.text_disabled; + c[ImGuiCol_WindowBg] = c[ImGuiCol_ScrollbarBg] = c[ImGuiCol_DockingEmptyBg] = p.window; + c[ImGuiCol_MenuBarBg] = p.window; + c[ImGuiCol_TitleBg] = c[ImGuiCol_TitleBgActive] = c[ImGuiCol_TitleBgCollapsed] = p.window; + c[ImGuiCol_ChildBg] = c[ImGuiCol_PopupBg] = p.surface; + c[ImGuiCol_Border] = c[ImGuiCol_TableBorderStrong] = p.border; + c[ImGuiCol_Separator] = c[ImGuiCol_TableBorderLight] = p.separator; + c[ImGuiCol_BorderShadow] = c[ImGuiCol_ResizeGrip] = c[ImGuiCol_TableRowBg] = none; + c[ImGuiCol_FrameBg] = p.frame; + c[ImGuiCol_FrameBgHovered] = p.frame_hovered; + c[ImGuiCol_FrameBgActive] = p.frame_active; + c[ImGuiCol_Button] = p.button; + c[ImGuiCol_ButtonHovered] = p.button_hovered; + c[ImGuiCol_SliderGrab] = p.accent; + c[ImGuiCol_ScrollbarGrab] = p.scrollbar_grab; + c[ImGuiCol_ScrollbarGrabHovered] = p.text_disabled; + c[ImGuiCol_ButtonActive] = p.button_active; + c[ImGuiCol_Header] = p.header; + c[ImGuiCol_HeaderHovered] = p.header_hovered; + c[ImGuiCol_HeaderActive] = p.header_active; + c[ImGuiCol_CheckMark] = c[ImGuiCol_NavCursor] = c[ImGuiCol_SliderGrabActive] = c[ImGuiCol_ScrollbarGrabActive] = p.accent; + c[ImGuiCol_SeparatorActive] = c[ImGuiCol_ResizeGripActive] = c[ImGuiCol_DragDropTarget] = p.accent; + c[ImGuiCol_TabSelectedOverline] = c[ImGuiCol_PlotHistogram] = p.accent; + c[ImGuiCol_SeparatorHovered] = c[ImGuiCol_ResizeGripHovered] = alpha(p.accent, 0.6f); + c[ImGuiCol_DockingPreview] = alpha(p.accent, 0.5f); + c[ImGuiCol_TextSelectedBg] = alpha(p.accent, 0.35f); + c[ImGuiCol_Tab] = c[ImGuiCol_TabDimmed] = p.tab; + c[ImGuiCol_TabHovered] = p.tab_hovered; + c[ImGuiCol_TabSelected] = c[ImGuiCol_TabDimmedSelected] = p.tab_selected; + c[ImGuiCol_TabDimmedSelectedOverline] = none; + c[ImGuiCol_TableHeaderBg] = p.table_header; + c[ImGuiCol_TableRowBgAlt] = none; + c[ImGuiCol_PlotLines] = p.text; + // ImGuiStyle() seeds every slot from the dark theme: set the rest so the light theme does not keep a white caret. + c[ImGuiCol_InputTextCursor] = c[ImGuiCol_UnsavedMarker] = p.text; + c[ImGuiCol_TextLink] = c[ImGuiCol_PlotLinesHovered] = c[ImGuiCol_PlotHistogramHovered] = c[ImGuiCol_NavWindowingHighlight] = p.accent; + c[ImGuiCol_TreeLines] = p.separator; + c[ImGuiCol_DragDropTargetBg] = alpha(p.accent, 0.2f); + // Disable the modal dim fade to make dialogs appear immediately. + c[ImGuiCol_ModalWindowDimBg] = c[ImGuiCol_NavWindowingDimBg] = none; + + // ChartView::drawAxes() pushes the other plot colors it needs; the rest are auto colors derived from the ImGui style. + ImPlot::GetStyle().Colors[ImPlotCol_AxisGrid] = p.grid; +} + +const Palette &palette() { return *g_palette; } + +CabanaColor contrastColor(CabanaColor color, const CabanaColor &background, double target) { + color.a = 255; + const double value = luminance(color), base = luminance(background); + if ((std::max(value, base) + 0.05) / (std::min(value, base) + 0.05) >= target) return color; + const bool lighter = base < 0.179; + const double bound = lighter ? target * (base + 0.05) - 0.05 : (base + 0.05) / target - 0.05; + return boundLuminance(color, std::clamp(bound, 0.0, 1.0), lighter); +} + +CabanaColor byteColor(const CabanaColor &color) { + const bool dark = palette().text.x > 0.5f; + const auto [h, s, v] = color.hsv(); + auto fill = CabanaColor::fromHsv(h, std::min(s, 0.30f), 0.86f, color.alphaF()); + if (dark) fill = boundLuminance(fill, 0.16, false); // 5:1 with white text + fill = composite(fill, fromImVec4(palette().surface)); + return dark ? fill : composite({255, 255, 255, 40}, fill); +} + +CabanaColor signalFill(const CabanaColor &color, bool defined) { + const bool dark = palette().text.x > 0.5f; + // Constrain the full-strength color before blending to preserve the activity ramp. + if (defined) return composite(boundLuminance(color, 0.18, !dark), fromImVec4(palette().surface)); + auto fill = composite(color, fromImVec4(palette().surface)); + fill = composite(dark ? CabanaColor(0, 0, 0, 115) : CabanaColor(255, 255, 255, 40), fill); + return boundLuminance(fill, 0.18, !dark); // 4.5:1 with the theme's text +} + +CabanaColor signalHighlight(CabanaColor color) { + color.a = 255; + return boundLuminance(boundLuminance(color, 0.175, true), 0.18, false); +} + +CabanaColor signalOutline(CabanaColor color, bool hovered) { + const bool dark = palette().text.x > 0.5f; + if (dark && hovered) color = color.lighter(150); + color.a = 255; + // 3:1 against normal and highlighted fills, including neighboring signals. + return dark ? boundLuminance(color, 0.67, true) : boundLuminance(color, 0.02, false); +} + +CabanaColor graphicColor(const CabanaColor &color, const ImVec4 &background) { + return contrastColor(color, fromImVec4(background), 3.0); +} + +CabanaColor sparklineColor(const CabanaColor &color) { + const Palette &p = palette(); + auto [h, s, v] = color.hsv(); + return graphicColor(CabanaColor::fromHsv(h, std::min(1.0f, s * p.sparkline_saturation), v * p.sparkline_value)); +} + +ImFont *boldFont() { return g_bold_font; } + +void pushMonoFont(float size) { + if (!g_mono_font) return; + size > 0.0f ? ImGui::PushFont(g_mono_font, size) : ImGui::PushFont(g_mono_font); +} +void popMonoFont() { if (g_mono_font) ImGui::PopFont(); } +void pushBoldFont() { if (g_bold_font) ImGui::PushFont(g_bold_font); } +void popBoldFont() { if (g_bold_font) ImGui::PopFont(); } +void pushLargeFont() { if (g_large_font) ImGui::PushFont(g_large_font); } +void popLargeFont() { if (g_large_font) ImGui::PopFont(); } diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h new file mode 100644 index 0000000000..4655219505 --- /dev/null +++ b/openpilot/tools/cabana/ui/theme.h @@ -0,0 +1,65 @@ +#pragma once + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/core/color.h" + +struct Palette { + ImVec4 text, text_disabled, text_inactive, text_selected; + ImVec4 window; // the background behind panels and docked windows + ImVec4 surface; // panels, popups, table bodies: what content is drawn on + ImVec4 frame, frame_hovered, frame_active; + ImVec4 button, button_hovered, button_active; + ImVec4 header, header_hovered, header_active; // selected fill; unselected hover/press fills + ImVec4 accent; + ImVec4 border, separator, scrollbar_grab; + ImVec4 slider_track; + ImVec4 tab, tab_hovered, tab_selected, table_header; + ImVec4 grid; + ImVec4 badge; // the fill behind the time labels drawn over a chart + float sparkline_saturation, sparkline_value; // HSV multipliers for signal colors +}; + +constexpr ImVec4 rgb(unsigned hex, float alpha = 1.0f) { + return ImVec4(((hex >> 16) & 255) / 255.0f, ((hex >> 8) & 255) / 255.0f, (hex & 255) / 255.0f, alpha); +} +inline ImVec4 colorRgb(int r, int g, int b, float alpha = 1.0f) { + return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, alpha); +} +inline ImU32 toImU32(const CabanaColor &c) { return IM_COL32(c.r, c.g, c.b, c.a); } +inline CabanaColor fromImVec4(const ImVec4 &c) { + return {static_cast(std::lround(c.x * 255)), static_cast(std::lround(c.y * 255)), + static_cast(std::lround(c.z * 255)), static_cast(std::lround(c.w * 255))}; +} +inline ImVec4 toImVec4(const CabanaColor &c) { return ImVec4(c.r / 255.0f, c.g / 255.0f, c.b / 255.0f, c.a / 255.0f); } +inline ImU32 withAlpha(ImU32 c, int alpha) { return (c & ~IM_COL32_A_MASK) | ((ImU32)alpha << IM_COL32_A_SHIFT); } + +// Logical pixels. External control gaps are equal on both axes; inner spacing +// is reserved for parts of one control (icon/label, checkbox/label, dropdown arrow). +namespace spacing { +constexpr float CONTROL = 8.0f; +constexpr float INNER = 4.0f; +constexpr float DIALOG_BUTTON_MIN_WIDTH = 80.0f; +} // namespace spacing + +constexpr float UI_FONT_SIZE = 16.0f; + +void loadFonts(); +void applyTheme(int theme); // Safe to call at runtime. +const Palette &palette(); +CabanaColor contrastColor(CabanaColor color, const CabanaColor &background, double target = 4.5); +CabanaColor byteColor(const CabanaColor &color); +CabanaColor signalFill(const CabanaColor &color, bool defined = true); +CabanaColor signalHighlight(CabanaColor color); +CabanaColor signalOutline(CabanaColor color, bool hovered = false); +CabanaColor graphicColor(const CabanaColor &color, const ImVec4 &background = palette().surface); +CabanaColor sparklineColor(const CabanaColor &color); + +ImFont *boldFont(); +void pushMonoFont(float size = 0.0f); +void popMonoFont(); +void pushBoldFont(); +void popBoldFont(); +void pushLargeFont(); +void popLargeFont(); diff --git a/openpilot/tools/cabana/ui/threadpool.h b/openpilot/tools/cabana/ui/threadpool.h new file mode 100644 index 0000000000..3580f55c78 --- /dev/null +++ b/openpilot/tools/cabana/ui/threadpool.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Reusing the same threads matters: a std::async thread per update lands each allocation in a different +// glibc malloc arena and the process RSS grows without bound. +class ThreadPool { +public: + static ThreadPool &instance() { + static ThreadPool pool(std::clamp(std::thread::hardware_concurrency(), 2u, 4u)); + return pool; + } + + std::future run(std::function fn) { + auto task = std::make_shared>(std::move(fn)); + std::future future = task->get_future(); + { + std::lock_guard lk(mutex_); + tasks_.push([task]() { (*task)(); }); + } + cv_.notify_one(); + return future; + } + + ~ThreadPool() { + { + std::lock_guard lk(mutex_); + stop_ = true; + } + cv_.notify_all(); + for (auto &thread : threads_) thread.join(); + } + +private: + explicit ThreadPool(unsigned n) { + for (unsigned i = 0; i < n; ++i) { + threads_.emplace_back([this]() { + for (;;) { + std::function task; + { + std::unique_lock lk(mutex_); + cv_.wait(lk, [this]() { return stop_ || !tasks_.empty(); }); + if (stop_ && tasks_.empty()) return; + task = std::move(tasks_.front()); + tasks_.pop(); + } + task(); + } + }); + } + } + + std::vector threads_; + std::queue> tasks_; + std::mutex mutex_; + std::condition_variable cv_; + bool stop_ = false; +}; + +// fn(begin, end) over [0, n) split into one chunk per pool thread plus one for the caller, which also +// waits for the others. Not for use from a pool thread. +inline void parallelFor(size_t n, const std::function &fn) { + const size_t chunks = std::clamp(std::thread::hardware_concurrency(), 2, 4) + 1; + const size_t chunk = (n + chunks - 1) / chunks; + if (chunk == 0) return; + std::vector> futures; + size_t begin = chunk; + for (; begin < n; begin += chunk) { + futures.push_back(ThreadPool::instance().run([&fn, begin, end = std::min(begin + chunk, n)]() { fn(begin, end); })); + } + // All tasks must finish before captures can go out of scope, even if one fails. + std::exception_ptr error; + try { fn(0, std::min(chunk, n)); } catch (...) { error = std::current_exception(); } + for (auto &f : futures) { + try { f.get(); } catch (...) { if (!error) error = std::current_exception(); } + } + if (error) std::rethrow_exception(error); +} diff --git a/openpilot/tools/cabana/ui/tools/findsignal.cc b/openpilot/tools/cabana/ui/tools/findsignal.cc new file mode 100644 index 0000000000..f977aa804a --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsignal.cc @@ -0,0 +1,330 @@ +#include "tools/cabana/ui/tools/findsignal.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { +constexpr int MAX_ROWS = 300; +} // namespace + +void SignalSearch::search(const std::function &cmp) { + const auto prev_sigs = !histories.empty() ? histories.back() : initial_signals; + filtered_signals.clear(); + filtered_signals.reserve(prev_sigs.size()); + + std::mutex lock; + parallelFor(prev_sigs.size(), [&](size_t begin, size_t end) { + for (size_t i = begin; i < end; ++i) { + const auto &s = prev_sigs[i]; + const auto &events = can->events(s.id); + auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent()); + auto last = events.cend(); + if (last_time < std::numeric_limits::max()) { + last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent()); + } + + auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); }); + if (it != last) { + auto values = s.values; + char buf[64]; + snprintf(buf, sizeof(buf), "(%.3f, %g)", can->toSeconds((*it)->mono_time), get_raw_value((*it)->dat, (*it)->size, s.sig)); + values.push_back(buf); + std::lock_guard lk(lock); + filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values}); + } + } + }); + + histories.push_back(filtered_signals); +} + +void SignalSearch::undo() { + if (!histories.empty()) { + histories.pop_back(); + filtered_signals.clear(); + if (!histories.empty()) filtered_signals = histories.back(); + } +} + +void SignalSearch::reset() { + histories.clear(); + filtered_signals.clear(); + initial_signals.clear(); +} + +FindSignalDlg::FindSignalDlg() { + setTitle("Find Signal"); +} + +FindSignalDlg::~FindSignalDlg() { + if (search_future_.valid()) search_future_.wait(); +} + +bool FindSignalDlg::draw() { + if (search_future_.valid() && search_future_.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + search_future_.get(); + searched_ = true; + } + searching_ = search_future_.valid(); + if (begin(ImVec2(900, 650))) { + const ImGuiStyle &style = ImGui::GetStyle(); + const float group_w = (ImGui::GetContentRegionAvail().x - style.ItemSpacing.x) / 2; + const float group_h = ImGui::GetTextLineHeightWithSpacing() + ImGui::GetFrameHeightWithSpacing() * 4 + + style.WindowPadding.y * 2 - style.ItemSpacing.y; + ImGui::BeginChild("Messages", ImVec2(group_w, group_h), ImGuiChildFlags_Borders); + drawMessageGroup(); + ImGui::EndChild(); + ImGui::SameLine(); + ImGui::BeginChild("Signal", ImVec2(group_w, group_h), ImGuiChildFlags_Borders); + drawPropertiesGroup(); + ImGui::EndChild(); + float footer = searched_ ? ImGui::GetTextLineHeightWithSpacing() : 0; + ImGui::BeginChild("Find signal", ImVec2(0, -footer), ImGuiChildFlags_Borders); + drawFindGroup(); + ImGui::EndChild(); + if (searched_) { + ImGui::Text("%zu matches. Right-click an item to create a signal. Double-click to open the message.", + search_.filtered_signals.size()); + } + } + return end(); +} + +void FindSignalDlg::drawMessageGroup() { + ImGui::BeginDisabled(searching_ || !search_.histories.empty()); + const float field_x = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Address").x + ImGui::GetStyle().ItemSpacing.x; + ImGui::TextUnformatted("Messages"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(field_x); + ImGui::SetNextItemWidth(-1); + inputText("##bus", &bus_, "Comma-separated values. Leave blank for all."); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Address"); + ImGui::SameLine(field_x); + ImGui::SetNextItemWidth(-1); + inputText("##address", &address_, "Comma-separated hex values. Leave blank for all."); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Time"); + ImGui::SameLine(field_x); + ImGui::SetNextItemWidth(70); + validatedText("##first_time", &first_time_, validateDouble); + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(70); + validatedText("##last_time", &last_time_, validateDouble); + ImGui::SameLine(); + ImGui::TextUnformatted("seconds"); + ImGui::EndDisabled(); +} + +void FindSignalDlg::drawPropertiesGroup() { + ImGui::BeginDisabled(searching_ || !search_.histories.empty()); + const float field_x = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Factor").x + ImGui::GetStyle().ItemSpacing.x; + ImGui::TextUnformatted("Signal"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Size"); + ImGui::SameLine(field_x); + ImGui::SetNextItemWidth(inputIntWidth(2)); + if (inputInt("##min_size", &min_size_, 1, 10)) min_size_ = std::clamp(min_size_, 1, 64); + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(inputIntWidth(2)); + if (inputInt("##max_size", &max_size_, 1, 10)) max_size_ = std::clamp(max_size_, 1, 64); + ImGui::SetCursorPosX(field_x); + checkBox("Little Endian", &little_endian_); + ImGui::SameLine(); + checkBox("Signed", &is_signed_); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Factor"); + ImGui::SameLine(field_x); + ImGui::SetNextItemWidth(100); + validatedText("##factor", &factor_, validateDouble); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Offset"); + ImGui::SameLine(field_x); + ImGui::SetNextItemWidth(100); + validatedText("##offset", &offset_, validateDouble); + ImGui::EndDisabled(); +} + +void FindSignalDlg::drawFindGroup() { + static const char *compare_items[] = {"=", ">", ">=", "!=", "<", "<=", "between"}; + const int compare_count = IM_ARRAYSIZE(compare_items); + ImGui::TextUnformatted("Find Signal"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Value"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90); + dropdown::Combo("##compare", &compare_, compare_items, compare_count); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + validatedText("##value1", &value1_, validateDouble); + if (compare_ == compare_count - 1) { + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + validatedText("##value2", &value2_, validateDouble); + } + ImGui::SameLine(); + const bool first = !searching_ && search_.histories.empty(); + ImGui::BeginDisabled(searching_ || search_.histories.size() <= 1); + if (ImGui::Button("Undo Previous Find")) { + search_.undo(); + searched_ = true; + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(searching_ || (search_.filtered_signals.empty() && !first)); + if (ImGui::Button(searching_ ? "Finding..." : (first ? "Find" : "Find Next"))) search(); + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(searching_ || first); + if (ImGui::Button("Reset")) { + search_.reset(); + searched_ = true; + } + ImGui::EndDisabled(); + + if (searching_) { + ImGui::BeginChild("view", ImVec2(0, 0), ImGuiChildFlags_Borders); + ImGui::EndChild(); + } else { + drawTable(); + } +} + +void FindSignalDlg::drawTable() { + static const char *titles[] = {"ID", "Start Bit, Size", "(Time, Value)"}; + const int columns = IM_ARRAYSIZE(titles); + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_NoSavedSettings; + if (!ImGui::BeginTable("view", columns + 1, flags, ImVec2(0, 0))) return; + ImGui::TableSetupScrollFreeze(0, 1); + const int rows = std::min(search_.filtered_signals.size(), MAX_ROWS); + // vertical header: row number, no width while there are no results + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed | (rows ? 0 : ImGuiTableColumnFlags_Disabled), 40.0f); + for (int c = 0; c < columns; ++c) { + auto column_flags = c == columns - 1 ? ImGuiTableColumnFlags_WidthStretch : ImGuiTableColumnFlags_WidthFixed; + ImGui::TableSetupColumn(titles[c], column_flags, c == 0 ? 80.0f : 120.0f); + } + tableHeadersRow(); + for (int row = 0; row < rows; ++row) { + const auto &s = search_.filtered_signals[row]; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(row); + if (ImGui::Selectable(std::to_string(row + 1).c_str(), false, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) openMessage(s.id); + } + drawContextMenu(row); + ImGui::PopID(); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(s.id.toString().c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::Text("%d, %d", s.sig.start_bit, s.sig.size); + ImGui::TableSetColumnIndex(3); + std::string values; + for (size_t i = 0; i < s.values.size(); ++i) { + if (i) values += " "; + values += s.values[i]; + } + ImGui::TextUnformatted(values.c_str()); + } + ImGui::EndTable(); +} + +void FindSignalDlg::search() { + if (search_.histories.empty()) { + setInitialSignals(); + } + auto v1 = utils::toDouble(value1_); + auto v2 = utils::toDouble(value2_); + std::function cmp = nullptr; + switch (compare_) { + case 0: cmp = [v1](double v) { return v == v1;}; break; + case 1: cmp = [v1](double v) { return v > v1;}; break; + case 2: cmp = [v1](double v) { return v >= v1;}; break; + case 3: cmp = [v1](double v) { return v != v1;}; break; + case 4: cmp = [v1](double v) { return v < v1;}; break; + case 5: cmp = [v1](double v) { return v <= v1;}; break; + case 6: cmp = [v1, v2](double v) { return v >= v1 && v <= v2;}; break; + } + searched_ = false; + // a thread of its own: the search fans out over the pool, which a pool thread must not wait on + search_future_ = std::async(std::launch::async, [this, cmp = std::move(cmp)]() { search_.search(cmp); }); + searching_ = true; +} + +void FindSignalDlg::setInitialSignals() { + std::set buses; + for (auto bus : utils::split(utils::trimmed(bus_), ',')) { + bus = utils::trimmed(bus); + if (!bus.empty()) buses.insert((unsigned short)utils::toULong(bus)); + } + + std::set addresses; + for (auto addr : utils::split(utils::trimmed(address_), ',')) { + addr = utils::trimmed(addr); + if (!addr.empty()) addresses.insert(utils::toULong(addr, 16)); + } + + cabana::Signal sig{}; + sig.is_little_endian = little_endian_; + sig.is_signed = is_signed_; + sig.factor = utils::toDouble(factor_); + sig.offset = utils::toDouble(offset_); + + double first_time_val = utils::toDouble(first_time_); + double last_time_val = utils::toDouble(last_time_); + auto [first_sec, last_sec] = std::minmax(first_time_val, last_time_val); + uint64_t first_time = can->toMonoTime(first_sec); + search_.last_time = std::numeric_limits::max(); + if (last_sec > 0) { + search_.last_time = can->toMonoTime(last_sec); + } + search_.initial_signals.clear(); + + for (const auto &[id, m] : can->lastMessages()) { + 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()) { + const int total_size = m.dat.size() * 8; + for (int size = min_size_; size <= max_size_; ++size) { + for (int start = 0; start <= total_size - size; ++start) { + SignalSearch::SearchSignal s{.id = id, .mono_time = first_time, .sig = sig}; + s.sig.start_bit = start; + s.sig.size = size; + updateMsbLsb(s.sig); + s.value = get_raw_value((*e)->dat, (*e)->size, s.sig); + search_.initial_signals.push_back(s); + } + } + } + } + } +} + +void FindSignalDlg::drawContextMenu(int row) { + if (dropdown::BeginPopupContextItem("menu")) { + if (dropdown::Item("Create Signal")) { + auto &s = search_.filtered_signals[row]; + UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); + openMessage(s.id); + } + dropdown::EndPopup(); + } +} diff --git a/openpilot/tools/cabana/ui/tools/findsignal.h b/openpilot/tools/cabana/ui/tools/findsignal.h new file mode 100644 index 0000000000..1db9bc47ee --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsignal.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/tools/tooldialog.h" + +struct SignalSearch { + struct SearchSignal { + MessageId id = {}; + uint64_t mono_time = 0; + cabana::Signal sig = {}; + double value = 0.; + std::vector values; + }; + + void search(const std::function &cmp); + void reset(); + void undo(); + + std::vector filtered_signals; + std::vector initial_signals; + std::vector> histories; + uint64_t last_time = std::numeric_limits::max(); +}; + +class FindSignalDlg : public ToolDialog { +public: + FindSignalDlg(); + ~FindSignalDlg() override; + bool draw() override; + + Observable openMessage; + +private: + void search(); + void setInitialSignals(); + void drawContextMenu(int row); + void drawMessageGroup(); + void drawPropertiesGroup(); + void drawFindGroup(); + void drawTable(); + + std::string value1_, value2_, factor_ = "1.0", offset_ = "0.0"; + std::string bus_, address_, first_time_ = "0", last_time_ = "MAX"; + int compare_ = 0; + int min_size_ = 8, max_size_ = 8; + bool little_endian_ = true, is_signed_ = false; + bool searched_ = false; // a search/undo/reset ran, so the stats line is shown + SignalSearch search_; + std::future search_future_; // search_ is off limits while it is valid + bool searching_ = false; +}; diff --git a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc new file mode 100644 index 0000000000..032308a9a1 --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc @@ -0,0 +1,180 @@ +#include "tools/cabana/ui/tools/findsimilarbits.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/util.h" + +FindSimilarBitsDlg::FindSimilarBitsDlg() { + setTitle("Find Similar Bits"); + + for (int bus : can->sources) { + bus_items_.push_back(bus); + } + updateMessages(); +} + +void FindSimilarBitsDlg::updateMessages() { + msg_items_.clear(); + msg_names_.clear(); + for (auto &[address, msg] : dbc()->getMessages(busAt(src_bus_))) { + msg_items_.push_back({msg.name, address}); + } + std::sort(msg_items_.begin(), msg_items_.end(), [](auto &l, auto &r) { return l.first < r.first; }); + for (auto &[name, _] : msg_items_) msg_names_.push_back(name); + msg_index_ = 0; +} + +bool FindSimilarBitsDlg::draw() { + if (begin(ImVec2(700, 500))) { + const float field_x = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Find From:").x + ImGui::GetStyle().ItemSpacing.x; + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Find From:"); + ImGui::SameLine(field_x); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + if (comboBox("##src_bus", &src_bus_, bus_items_.data(), (int)bus_items_.size())) updateMessages(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(200); + comboBox("##msg", &msg_index_, msg_names_); + ImGui::SetCursorPosX(field_x); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Byte Index"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(inputIntWidth(2)); + if (inputInt("##byte_idx", &byte_idx_, 1, 10)) byte_idx_ = std::clamp(byte_idx_, 0, 63); + ImGui::SameLine(); + ImGui::TextUnformatted("Bit Index"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(inputIntWidth(1)); + if (inputInt("##bit_idx", &bit_idx_, 1, 10)) bit_idx_ = std::clamp(bit_idx_, 0, 7); + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Find In:"); + ImGui::SameLine(field_x); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + comboBox("##find_bus", &find_bus_, bus_items_.data(), (int)bus_items_.size()); + ImGui::SameLine(); + ImGui::TextUnformatted("Equal"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + dropdown::Combo("##equal", &equal_, "Yes\0No\0"); + ImGui::SameLine(); + ImGui::TextUnformatted("Minimum Message Count"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(inputIntWidth(4)); + if (inputInt("##min_msgs", &min_msgs_, 1, 10)) min_msgs_ = std::max(min_msgs_, 0); + ImGui::SameLine(); + if (ImGui::Button("Find")) find(); + + drawTable(); + } + return end(); +} + +void FindSimilarBitsDlg::drawTable() { + // columns are set by find(); until then the table is an empty frame + if (!table_has_columns_) { + ImGui::BeginChild("table", ImVec2(0, 0), ImGuiChildFlags_Borders); + ImGui::EndChild(); + return; + } + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_NoSavedSettings; + if (!ImGui::BeginTable("table", 7, flags, ImVec2(0, 0))) return; + ImGui::TableSetupScrollFreeze(0, 1); + static const char *headers[] = {"Address", "Byte Index", "Bit Index", "Mismatches", "Messages", "Mismatched (%)"}; + // the fixed widths are section sizes: imgui adds the cell padding on top of the column width + const float padding = ImGui::GetStyle().CellPadding.x * 2; + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 40.0f - padding); // vertical header: row number + for (int c = 0; c < 6; ++c) { + ImGui::TableSetupColumn(headers[c], c == 5 ? ImGuiTableColumnFlags_WidthStretch : ImGuiTableColumnFlags_WidthFixed, + 100.0f - padding); + } + tableHeadersRow(); + ImGuiListClipper clipper; + clipper.Begin((int)table_.size()); + while (clipper.Step()) { + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) { + auto &m = table_[i]; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(i); + if (ImGui::Selectable(std::to_string(i + 1).c_str(), false, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + openMessage(MessageId{.source = busAt(find_bus_), .address = m.address}); + } + } + ImGui::PopID(); + ImGui::TableSetColumnIndex(1); + ImGui::Text("%x", m.address); + ImGui::TableSetColumnIndex(2); + ImGui::Text("%u", m.byte_idx); + ImGui::TableSetColumnIndex(3); + ImGui::Text("%u", m.bit_idx); + ImGui::TableSetColumnIndex(4); + ImGui::Text("%u", m.mismatches); + ImGui::TableSetColumnIndex(5); + ImGui::Text("%u", m.total); + ImGui::TableSetColumnIndex(6); + ImGui::Text("%.2f", m.perc); + } + } + ImGui::EndTable(); +} + +void FindSimilarBitsDlg::find() { + const uint32_t selected_address = msg_index_ < (int)msg_items_.size() ? msg_items_[msg_index_].second : 0; + table_ = calcBits(busAt(src_bus_), selected_address, byte_idx_, bit_idx_, busAt(find_bus_), equal_ == 0, min_msgs_); + table_has_columns_ = true; +} + +std::vector FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, + int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) { + std::unordered_map> mismatches; + std::unordered_map msg_count; + const auto &events = can->allEvents(); + int bit_to_find = -1; + for (const CanEvent *e : events) { + if (e->src == bus) { + if (e->address == selected_address && e->size > byte_idx) { + bit_to_find = ((e->dat[byte_idx] >> (7 - bit_idx)) & 1) != 0; + } + } + if (e->src == find_bus) { + ++msg_count[e->address]; + if (bit_to_find == -1) continue; + + auto &mismatched = mismatches[e->address]; + if (mismatched.size() < e->size * 8) { + mismatched.resize(e->size * 8); + } + for (int i = 0; i < e->size; ++i) { + for (int j = 0; j < 8; ++j) { + int bit = ((e->dat[i] >> (7 - j)) & 1) != 0; + mismatched[i * 8 + j] += equal ? (bit != bit_to_find) : (bit == bit_to_find); + } + } + } + } + + std::vector result; + result.reserve(mismatches.size()); + for (auto it = mismatches.begin(); it != mismatches.end(); ++it) { + if (auto cnt = msg_count[it->first]; cnt > (uint32_t)min_msgs_cnt) { + auto &mismatched = it->second; + for (int i = 0; i < (int)mismatched.size(); ++i) { + if (float perc = (mismatched[i] / (double)cnt) * 100; perc < 50) { + result.push_back({it->first, (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); + } + } + } + } + std::sort(result.begin(), result.end(), [](auto &l, auto &r) { return l.perc < r.perc; }); + return result; +} diff --git a/openpilot/tools/cabana/ui/tools/findsimilarbits.h b/openpilot/tools/cabana/ui/tools/findsimilarbits.h new file mode 100644 index 0000000000..c60bf1562b --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsimilarbits.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/ui/tools/tooldialog.h" + +class FindSimilarBitsDlg : public ToolDialog { +public: + FindSimilarBitsDlg(); + bool draw() override; + + Observable openMessage; + +private: + struct Mismatch { + uint32_t address, byte_idx, bit_idx, mismatches, total; + float perc; + }; + std::vector calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus, + bool equal, int min_msgs_cnt); + uint8_t busAt(int index) const { return index < (int)bus_items_.size() ? bus_items_[index] : 0; } + void updateMessages(); + void find(); + void drawTable(); + + std::vector table_; // rows, replaced by find() + bool table_has_columns_ = false; + std::vector bus_items_; // the source and the find bus combos + int src_bus_ = 0, find_bus_ = 0; + std::vector> msg_items_; // (name, address) of the source bus + std::vector msg_names_; + int msg_index_ = 0; + int equal_ = 0; + int byte_idx_ = 0, bit_idx_ = 0; + int min_msgs_ = 100; +}; diff --git a/openpilot/tools/cabana/ui/tools/routeinfo.cc b/openpilot/tools/cabana/ui/tools/routeinfo.cc new file mode 100644 index 0000000000..92b77cf2a8 --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/routeinfo.cc @@ -0,0 +1,52 @@ +#include "tools/cabana/ui/tools/routeinfo.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/util.h" + +RouteInfoDlg::RouteInfoDlg() { + replay_ = dynamic_cast(can)->getReplay(); + setTitle("Route: " + replay_->route().name()); +} + +bool RouteInfoDlg::draw() { + static const char *headers[] = {"", "rlog", "narrow road", "wide road", "driver", "qlog", "qcam"}; + auto yn = [](const std::string &s) { return s.empty() ? "--" : "Yes"; }; + const auto &segments = replay_->route().segments(); + // minimum size: fingerprint, header, and min(rowCount, 13) rows + float row_h = ImGui::GetTextLineHeightWithSpacing(); + float min_h = row_h * (std::min((int)segments.size(), 13) + 2) + ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().WindowPadding.y * 2; + if (begin(ImVec2(520, min_h))) { + const std::string fingerprint = replay_->carFingerprint(); + ImGui::TextWrapped("Fingerprint: %s", fingerprint.empty() ? "Unknown" : fingerprint.c_str()); + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingFixedFit; + if (ImGui::BeginTable("table", 7, flags, ImVec2(0, 0))) { + ImGui::TableSetupScrollFreeze(0, 1); + for (int c = 0; c < 7; ++c) ImGui::TableSetupColumn(headers[c]); + tableHeadersRow(); + int row = 0; + for (const auto &[seg_num, seg] : segments) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(row); + if (ImGui::Selectable(std::to_string(seg_num).c_str(), false, ImGuiSelectableFlags_SpanAllColumns)) { + can->seekTo(row * 60.0); + } + ImGui::SetItemTooltip("Click on a row to seek to the corresponding segment."); + ImGui::PopID(); + const char *cells[] = {yn(seg.rlog), yn(seg.narrow_road_cam), yn(seg.wide_road_cam), + yn(seg.cabin_cam), yn(seg.qlog), yn(seg.qcamera)}; + for (int c = 1; c < 7; ++c) { + ImGui::TableSetColumnIndex(c); + ImGui::TextUnformatted(cells[c - 1]); + } + ++row; + } + ImGui::EndTable(); + } + } + return end(); +} diff --git a/openpilot/tools/cabana/ui/tools/routeinfo.h b/openpilot/tools/cabana/ui/tools/routeinfo.h new file mode 100644 index 0000000000..e3d4e11b3d --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/routeinfo.h @@ -0,0 +1,14 @@ +#pragma once + +#include "tools/cabana/ui/tools/tooldialog.h" + +class Replay; + +class RouteInfoDlg : public ToolDialog { +public: + RouteInfoDlg(); + bool draw() override; + +private: + Replay *replay_ = nullptr; // destroyed with the stream, which destroys this dialog first +}; diff --git a/openpilot/tools/cabana/ui/tools/tooldialog.h b/openpilot/tools/cabana/ui/tools/tooldialog.h new file mode 100644 index 0000000000..4a8aeab7b2 --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/tooldialog.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/ui/util.h" + +// non-modal dialogs drawn by MainWindow every frame until closed +class ToolDialog { +public: + virtual ~ToolDialog() = default; + virtual bool draw() = 0; // false once the dialog was closed + + Connections connections_; // dies with the dialog + +protected: + void setTitle(const std::string &name) { + char buf[32]; + snprintf(buf, sizeof(buf), "###tooldialog%p", (void *)this); + title_ = name + buf; + } + + // draw() body: `if (begin(size)) { content } return end();` + bool begin(const ImVec2 &size) { + if (!open_) return false; + ImGui::SetNextWindowSize(size, ImGuiCond_Appearing); + setNextWindowFloatsOut(); + began_ = true; + return visible_ = ImGui::Begin(title_.c_str(), &open_, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoCollapse); + } + + bool end() { + if (!began_) return false; + // Escape closes the dialog like QDialog, but not while a popup is open above it + if (visible_ && ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && + ImGui::IsKeyPressed(ImGuiKey_Escape, false) && + !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) { + open_ = false; + } + ImGui::End(); + began_ = false; + return open_; + } + + std::string title_; + bool open_ = true; + +private: + bool began_ = false, visible_ = false; +}; diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc new file mode 100644 index 0000000000..2252355144 --- /dev/null +++ b/openpilot/tools/cabana/ui/util.cc @@ -0,0 +1,675 @@ +#include "tools/cabana/ui/util.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#ifdef __APPLE__ +#include +#include +extern "C" { +struct objc_object; +struct objc_selector; +objc_object *glfwGetCocoaWindow(GLFWwindow *window); +objc_selector *sel_registerName(const char *name); +void objc_msgSend(void); +} +#endif + +#include "tools/cabana/ui/icons.h" + +namespace { +ImU32 u32(const ImVec4 &c) { return ImGui::ColorConvertFloat4ToU32(c); } +} // namespace + +int inputCallback(ImGuiInputTextCallbackData *data) { + auto *ctx = static_cast(data->UserData); + if (data->EventFlag == ImGuiInputTextFlags_CallbackCharFilter) { + return ctx->validator ? ctx->validator(data) : 0; + } + if (data->EventFlag == ImGuiInputTextFlags_CallbackEdit) { + if (ctx->validate(std::string(data->Buf, data->BufTextLen)) == ValidState::Invalid) { + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, ctx->last_valid->c_str()); + } + return 0; + } + if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) { + ctx->str->resize(data->BufTextLen); + data->Buf = ctx->str->data(); + } + return 0; +} + +bool validatedInput(const char *label, std::string *s, ImGuiInputTextCallback validator, const char *hint, + ImGuiInputTextFlags flags) { + InputContext ctx{s, validator}; + flags |= ImGuiInputTextFlags_CallbackResize; + if (validator) flags |= ImGuiInputTextFlags_CallbackCharFilter; + return ImGui::InputTextWithHint(label, hint, s->data(), s->capacity() + 1, flags, inputCallback, &ctx); +} + +bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, ImGuiInputTextFlags flags) { + InputContext ctx{s, nullptr}; + return ImGui::InputTextMultiline(label, s->data(), s->capacity() + 1, size, flags | ImGuiInputTextFlags_CallbackResize, + inputCallback, &ctx); +} + +bool beginControlChild(const char *id, const ImVec2 &size, ImGuiWindowFlags flags) { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(CONTROL_OUTLINE_PADDING, CONTROL_OUTLINE_PADDING)); + const bool visible = ImGui::BeginChild(id, size, ImGuiChildFlags_AlwaysUseWindowPadding, flags); + ImGui::PopStyleVar(); + return visible; +} + +bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiInputTextCallback validator) { + const float width = ImGui::CalcItemWidth(); + const float clear_width = iconButtonWidth() + ImGui::GetStyle().ItemSpacing.x; + const bool show_clear = !s->empty() && width >= clear_width + ImGui::GetFrameHeight(); + ImGui::SetNextItemWidth(show_clear ? width - clear_width : width); + ImGui::BeginGroup(); + bool changed = validatedInput(label, s, validator, hint); + if (show_clear) { + ImGui::SameLine(); + ImGui::PushID(label); + if (iconButton("clear", icon::X_LG)) { + s->clear(); + changed = true; + } + ImGui::PopID(); + } + ImGui::EndGroup(); + return changed; +} + +bool selectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size) { + if (selected) { + ImGui::PushStyleColor(ImGuiCol_Text, palette().text_selected); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, palette().header); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, palette().header); + } + const bool clicked = ImGui::Selectable(label, selected, flags, size); + if (selected) ImGui::PopStyleColor(3); + return clicked; +} + +bool comboBox(const char *label, int *index, const std::vector &items) { + bool changed = false; + const int count = (int)items.size(); + if (dropdown::BeginCombo(label, *index >= 0 && *index < count ? items[*index].c_str() : "")) { + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + if (dropdown::Item(items[i].c_str(), nullptr, i == *index) && *index != i) { + *index = i; + changed = true; + } + if (i == *index) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + dropdown::EndCombo(); + } + return changed; +} + +bool validatedText(const char *label, std::string *s, ValidState (*validate)(const std::string &), + const char *hint, ImGuiInputTextCallback filter) { + const std::string last_valid = *s; // a refused edit never reaches *s + InputContext ctx{s, filter, validate, &last_valid}; + ImGuiInputTextFlags flags = ImGuiInputTextFlags_CallbackResize | ImGuiInputTextFlags_CallbackEdit; + if (filter) flags |= ImGuiInputTextFlags_CallbackCharFilter; + ImGui::InputTextWithHint(label, hint, s->data(), s->capacity() + 1, flags, inputCallback, &ctx); + return *s != last_valid; +} + +int nameValidator(ImGuiInputTextCallbackData *data) { + // [A-Za-z0-9_], spaces rewritten to '_' + if (data->EventChar == ' ') { + data->EventChar = '_'; + return 0; + } + return (data->EventChar < 128 && (std::isalnum((int)data->EventChar) || data->EventChar == '_')) ? 0 : 1; +} + +int nodeValidator(ImGuiInputTextCallbackData *data) { + // \w+(,\w+)* + return (data->EventChar < 128 && (std::isalnum((int)data->EventChar) || data->EventChar == '_' || data->EventChar == ',')) ? 0 : 1; +} + +int doubleValidator(ImGuiInputTextCallbackData *data) { + // C-locale floating-point + const ImWchar c = data->EventChar; + return (c < 128 && (std::isdigit((int)c) || c == '+' || c == '-' || c == '.' || c == 'e' || c == 'E')) ? 0 : 1; +} + +int ipValidator(ImGuiInputTextCallbackData *data) { + // [0-9.] + const ImWchar c = data->EventChar; + return ((c >= '0' && c <= '9') || c == '.') ? 0 : 1; +} + +int nonWhitespaceValidator(ImGuiInputTextCallbackData *data) { + // \S+ + return (data->EventChar < 128 && std::isspace((int)data->EventChar)) ? 1 : 0; +} + +float iconButtonWidth() { return ImGui::GetFrameHeight(); } + +namespace { +constexpr float ICON_BUTTON_GLYPH_SCALE = 0.8f; + +// Exclude rasterization padding when centering icons; it varies between glyphs. +struct GlyphInk { float x0, y0, x1, y1; }; +GlyphInk glyphInk(const ImFontGlyph *g) { + ImTextureData *tex = ImGui::GetIO().Fonts->TexData; + const int px0 = (int)std::lround(g->U0 * tex->Width), px1 = (int)std::lround(g->U1 * tex->Width); + const int py0 = (int)std::lround(g->V0 * tex->Height), py1 = (int)std::lround(g->V1 * tex->Height); + int ix0 = px1, iy0 = py1, ix1 = px0, iy1 = py0; + for (int y = py0; y < py1; ++y) { + for (int x = px0; x < px1; ++x) { + const unsigned char *p = (const unsigned char *)tex->GetPixelsAt(x, y); + const unsigned char alpha = tex->Format == ImTextureFormat_Alpha8 ? p[0] : p[3]; + if (alpha < 64) continue; + ix0 = std::min(ix0, x); ix1 = std::max(ix1, x + 1); + iy0 = std::min(iy0, y); iy1 = std::max(iy1, y + 1); + } + } + if (ix0 >= ix1 || py1 <= py0 || px1 <= px0) return {g->X0, g->Y0, g->X1, g->Y1}; + const float sx = (g->X1 - g->X0) / (px1 - px0), sy = (g->Y1 - g->Y0) / (py1 - py0); + return {g->X0 + (ix0 - px0) * sx, g->Y0 + (iy0 - py0) * sy, g->X0 + (ix1 - px0) * sx, g->Y0 + (iy1 - py0) * sy}; +} + +// The scan reads the atlas pixels: do it once per icon, and again when the atlas repacked the glyph. +const GlyphInk &cachedGlyphInk(const ImFontGlyph *g, float size, unsigned int codepoint) { + struct Entry { ImVec4 uv; GlyphInk ink; }; + static std::unordered_map cache; + const uint64_t key = ((uint64_t)(uint32_t)size << 32) | codepoint; + const ImVec4 uv(g->U0, g->V0, g->U1, g->V1); + auto it = cache.find(key); + if (it == cache.end() || memcmp(&it->second.uv, &uv, sizeof(uv)) != 0) it = cache.insert_or_assign(key, Entry{uv, glyphInk(g)}).first; + return it->second.ink; +} + +bool squareIconButton(const char *id, const char *icon) { + const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(iconButtonWidth(), 0.0f)); + const ImRect r(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + unsigned int codepoint = 0; + ImTextCharFromUtf8(&codepoint, icon, nullptr); + // Leave a margin even for icons that fill the glyph bounds. + const float size = std::round(ImGui::GetFontSize() * ICON_BUTTON_GLYPH_SCALE); + ImFontBaked *baked = ImGui::GetFont()->GetFontBaked(size); + if (const ImFontGlyph *g = baked->FindGlyph((ImWchar)codepoint)) { + const GlyphInk ink = cachedGlyphInk(g, size, codepoint); + // Keep the exact ink center, including half pixels when glyph and button sizes + // have different parity. Rounding the origin shifts small icons off center. + const ImVec2 pos(r.GetCenter().x - (ink.x0 + ink.x1) * 0.5f, r.GetCenter().y - (ink.y0 + ink.y1) * 0.5f); + // AddText truncates the origin, so draw the atlas glyph directly. + ImGui::GetWindowDrawList()->AddImage(ImGui::GetIO().Fonts->TexRef, ImVec2(pos.x + g->X0, pos.y + g->Y0), + ImVec2(pos.x + g->X1, pos.y + g->Y1), ImVec2(g->U0, g->V0), ImVec2(g->U1, g->V1), + ImGui::GetColorU32(ImGuiCol_Text)); + } + return clicked; +} +} // namespace + +bool iconButton(const char *id, const char *icon, const char *tooltip) { + const bool clicked = squareIconButton(id, icon); + if (tooltip && *tooltip) ImGui::SetItemTooltip("%s", tooltip); + return clicked; +} + +float iconTextButtonWidth(const char *icon, const std::string &text, const IconTextButtonOptions &options) { + const ImGuiStyle &style = ImGui::GetStyle(); + const float gap = *icon ? (options.icon_gap >= 0.0f ? options.icon_gap : style.ItemInnerSpacing.x) : 0.0f; + return ImGui::CalcTextSize(icon).x + gap + std::max(ImGui::CalcTextSize(text.c_str(), nullptr, true).x, options.label_width) + + style.FramePadding.x * 2; +} + +bool iconTextButton(const char *id, const char *icon, const std::string &text, float width, const IconTextButtonOptions &options) { + const ImGuiStyle &style = ImGui::GetStyle(); + const float icon_width = ImGui::CalcTextSize(icon).x; + const float gap = *icon ? (options.icon_gap >= 0.0f ? options.icon_gap : style.ItemInnerSpacing.x) : 0.0f; + const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; + const float label_width = std::max(text_width, options.label_width); + if (width <= 0.0f) width = icon_width + gap + label_width + style.FramePadding.x * 2; + if (options.rounding >= 0.0f) ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, options.rounding); + const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(width, options.height)); + if (options.rounding >= 0.0f) ImGui::PopStyleVar(); + const ImVec2 min = ImGui::GetItemRectMin(), max = ImGui::GetItemRectMax(); + const float top = min.y + (max.y - min.y - ImGui::GetFontSize()) * 0.5f; + const float left = min.x + (options.center_content ? std::max(style.FramePadding.x, (max.x - min.x - icon_width - gap - label_width) * 0.5f) + : style.FramePadding.x); + const float text_left = left + icon_width + gap; + const float slack = options.center_content ? 0.0f : std::max(0.0f, (max.x - style.FramePadding.x - text_left - text_width) * 0.5f); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); + auto *draw_list = ImGui::GetWindowDrawList(); + draw_list->AddText(ImVec2(left, top), color, icon); + draw_list->AddText(ImVec2(text_left + slack, top), color, text.c_str()); + return clicked; +} + +void disabledItemTooltip(const char *text) { + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip | ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", text); +} + +bool PopupOwner::begin(const char *id) { + ImGuiWindow *window = ImGui::GetCurrentWindowRead(); // GetCurrentWindow() would mark the fallback window as used + if (popup_id == 0) { + // a pending popup may only be opened from the call nested in the top-most modal, or from any call + // when there is no modal at all + ImGuiWindow *modal = ImGui::GetTopMostPopupModal(); + if (modal != nullptr && modal != window) return false; + ImGui::OpenPopup(id); + popup_id = window->GetID(id); + owner_id = window->ID; + } else if (owner_id != window->ID) { + return false; + } else if (!ImGui::IsPopupOpen(popup_id, ImGuiPopupFlags_AnyPopupLevel)) { + // reopen if imgui closed the popup underneath us (host window change) + ImGui::OpenPopup(id); + } + return true; +} + +ImGuiWindow *topPopupWindow() { + ImGuiContext &g = *GImGui; + return g.OpenPopupStack.Size > 0 ? g.OpenPopupStack.back().Window : nullptr; +} + +bool dialogEscapePressed() { + return ImGui::IsKeyPressed(ImGuiKey_Escape, false) && topPopupWindow() == ImGui::GetCurrentWindow(); +} + +bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, bool accept_enabled, + const char *reject_label) { + const float button_width = std::max({spacing::DIALOG_BUTTON_MIN_WIDTH, toolbarButtonWidth(accept_label), + reject_label ? toolbarButtonWidth(reject_label) : 0.0f}); + const int count = reject_label ? 2 : 1; + const float total = button_width * count + ImGui::GetStyle().ItemSpacing.x * (count - 1); + const float avail = ImGui::GetContentRegionAvail().x; + if (avail > total) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - total); + bool pressed = false; + if (reject_label) { + if (ImGui::Button(reject_label, ImVec2(button_width, 0.0f))) { + if (rejected) *rejected = true; + pressed = true; + } + ImGui::SameLine(); + } + ImGui::BeginDisabled(!accept_enabled); + if (ImGui::Button(accept_label, ImVec2(button_width, 0.0f))) { + if (accepted) *accepted = true; + pressed = true; + } + ImGui::EndDisabled(); + if (rejected && dialogEscapePressed()) { + *rejected = true; + pressed = true; + } + return pressed; +} + +float inputIntWidth(int digits) { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::CalcTextSize(std::string(digits, '0').c_str()).x + style.FramePadding.x * 2 + + (ImGui::GetFrameHeight() + style.ItemSpacing.x) * 2; +} + +bool stepButton(const char *id, bool increment, const char *tooltip) { + return iconButton(id, increment ? icon::PLUS_LG : icon::DASH_LG, tooltip); +} + +bool inputInt(const char *label, int *value, int step, int step_fast, ImGuiInputTextFlags flags) { + if (step <= 0) return ImGui::InputInt(label, value, 0, 0, flags); + + const float spacing = ImGui::GetStyle().ItemSpacing.x; + const float width = ImGui::CalcItemWidth(); + ImGui::BeginGroup(); + ImGui::PushID(label); + ImGui::SetNextItemWidth(std::max(1.0f, width - 2 * (iconButtonWidth() + spacing))); + bool changed = ImGui::InputInt("##value", value, 0, 0, flags); + ImGui::BeginDisabled(flags & ImGuiInputTextFlags_ReadOnly); + ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); + for (bool increment : {false, true}) { + ImGui::SameLine(0.0f, spacing); + if (stepButton(increment ? "increment" : "decrement", increment)) { + const int amount = ImGui::GetIO().KeyCtrl && step_fast > 0 ? step_fast : step; + const int next = std::clamp(int64_t(*value) + (increment ? int64_t(amount) : -int64_t(amount)), + std::numeric_limits::min(), std::numeric_limits::max()); + if (next != *value) { + *value = next; + changed = true; + } + } + } + ImGui::PopItemFlag(); + ImGui::EndDisabled(); + const char *label_end = ImGui::FindRenderedTextEnd(label); + if (label != label_end) { + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + ImGui::TextUnformatted(label, label_end); + } + ImGui::PopID(); + ImGui::EndGroup(); + if (changed) ImGui::MarkItemEdited(ImGui::GetItemID()); + return changed; +} + +int tableHeadersRow() { + int clicked = -1; + ImGui::TableNextRow(ImGuiTableRowFlags_Headers); + for (int c = 0, count = ImGui::TableGetColumnCount(); c < count; ++c) { + if (!ImGui::TableSetColumnIndex(c)) continue; + const char *name = ImGui::TableGetColumnName(c); + if (!name) name = ""; + const float offset = (ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize(name).x) * 0.5f; + if (offset > 0) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + offset); + ImGui::PushID(c); + ImGui::TableHeader(name); + // same timing as TableHeader's own TableOpenContextMenu, so a menu opened by the caller is opened last + // and replaces the (disabled) table context menu in the popup stack + if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) clicked = c; + ImGui::PopID(); + } + return clicked; +} + +bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size) { + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, selected ? ImGui::GetColorU32(ImGuiCol_Header) : IM_COL32(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, selected ? palette().header : palette().header_active); + const bool clicked = selectable(label, selected, flags, size); + ImGui::PopStyleColor(2); + return clicked; +} + +bool checkBox(const char *label, bool *v) { + const float box = CHECKBOX_SIZE; + ImGuiWindow *window = ImGui::GetCurrentWindow(); + if (window->SkipItems) return false; + const ImGuiStyle &style = ImGui::GetStyle(); + const ImGuiID id = window->GetID(label); + const ImVec2 label_size = ImGui::CalcTextSize(label, nullptr, true); + const float frame_h = ImGui::GetFrameHeight(); + const ImVec2 pos = window->DC.CursorPos; + const ImRect total_bb(pos, ImVec2(pos.x + box + (label_size.x > 0.0f ? style.ItemInnerSpacing.x + label_size.x : 0.0f), pos.y + frame_h)); + ImGui::ItemSize(total_bb, style.FramePadding.y); + if (!ImGui::ItemAdd(total_bb, id)) return false; + bool hovered, held; + const bool pressed = ImGui::ButtonBehavior(total_bb, id, &hovered, &held); + if (pressed) { + *v = !*v; + ImGui::MarkItemEdited(id); + } + const float y = pos.y + IM_TRUNC((frame_h - box) * 0.5f); + const ImRect check_bb(ImVec2(pos.x, y), ImVec2(pos.x + box, y + box)); + ImGui::RenderNavCursor(total_bb, id); + const ImU32 bg = ImGui::GetColorU32((held && hovered) ? ImGuiCol_FrameBgActive : hovered ? ImGuiCol_FrameBgHovered : ImGuiCol_FrameBg); + ImGui::RenderFrame(check_bb.Min, check_bb.Max, bg, true, style.FrameRounding); + if (*v) { + const float pad = ImMax(1.0f, IM_TRUNC(box / 6.0f)); + ImGui::RenderCheckMark(window->DrawList, ImVec2(check_bb.Min.x + pad, check_bb.Min.y + pad), ImGui::GetColorU32(ImGuiCol_CheckMark), box - pad * 2.0f); + } + if (label_size.x > 0.0f) ImGui::RenderText(ImVec2(check_bb.Max.x + style.ItemInnerSpacing.x, pos.y + style.FramePadding.y), label); + return pressed; +} + +void alignRight(float width) { + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, ImGui::GetContentRegionAvail().x - width)); +} + +void drawText(ImDrawList *dl, const ImRect &rect, const char *text, ImU32 col, ImFont *font, float font_size, const ImVec2 &align) { + if (font == nullptr) font = ImGui::GetFont(); + if (font_size <= 0.0f) font_size = ImGui::GetFontSize(); + const ImVec2 size = font->CalcTextSizeA(font_size, FLT_MAX, 0.0f, text); + const ImVec2 pos(rect.Min.x + (rect.GetWidth() - size.x) * align.x, rect.Min.y + (rect.GetHeight() - size.y) * align.y); + dl->AddText(font, font_size, pos, col, text); +} + +void drawElidedText(ImDrawList *dl, const ImRect &rect, const std::string &text, ImU32 col, bool align_right) { + const ImVec2 size = ImGui::CalcTextSize(text.c_str()); + const float y = rect.Min.y + std::max(0.0f, (rect.GetHeight() - size.y) * 0.5f); + if (size.x <= rect.GetWidth()) { + dl->AddText(ImVec2(align_right ? rect.Max.x - size.x : rect.Min.x, y), col, text.c_str()); + } else { + ImGui::PushStyleColor(ImGuiCol_Text, col); + ImGui::RenderTextEllipsis(dl, ImVec2(rect.Min.x, y), ImVec2(rect.Max.x, y + size.y), rect.Max.x, text.c_str(), nullptr, &size); + ImGui::PopStyleColor(); + } +} + +float markerSize() { return ImGui::GetTextLineHeight() - 4; } + +void drawColorMarker(ImDrawList *dl, const ImVec2 &pos, ImU32 col) { + const float size = markerSize(); + dl->AddRectFilled(ImVec2(pos.x, pos.y + 2), ImVec2(pos.x + size, pos.y + 2 + size), col); +} + +#ifdef __APPLE__ +void setMacAppName(const char *name) { + auto info = (CFMutableDictionaryRef)CFBundleGetInfoDictionary(CFBundleGetMainBundle()); + if (info == nullptr) return; + CFStringRef value = CFStringCreateWithCString(kCFAllocatorDefault, name, kCFStringEncodingUTF8); + CFDictionarySetValue(info, CFSTR("CFBundleName"), value); + CFRelease(value); +} + +bool isNativeFullScreen(GLFWwindow *window) { + constexpr unsigned long NS_WINDOW_STYLE_MASK_FULL_SCREEN = 1ul << 14; + objc_object *ns_window = glfwGetCocoaWindow(window); + if (ns_window == nullptr) return false; + auto styleMask = (unsigned long (*)(objc_object *, objc_selector *))objc_msgSend; + return (styleMask(ns_window, sel_registerName("styleMask")) & NS_WINDOW_STYLE_MASK_FULL_SCREEN) != 0; +} + +void toggleNativeFullScreen(GLFWwindow *window) { + auto toggle = (void (*)(objc_object *, objc_selector *, objc_object *))objc_msgSend; + toggle(glfwGetCocoaWindow(window), sel_registerName("toggleFullScreen:"), nullptr); +} +#endif + +void setNextWindowFloatsOut() { + ImGuiWindowClass window_class; + window_class.ViewportFlagsOverrideSet = ImGuiViewportFlags_NoAutoMerge; + ImGui::SetNextWindowClass(&window_class); +} + +void setNextDialogWindow(const ImVec2 &size) { + if (size.x > 0.0f || size.y > 0.0f) ImGui::SetNextWindowSize(size, ImGuiCond_Appearing); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + setNextWindowFloatsOut(); +} + +bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWindowFlags flags) { + if (!owner->begin(id)) return false; + setNextDialogWindow(size); + return ImGui::BeginPopupModal(id, nullptr, flags | ImGuiWindowFlags_NoSavedSettings); +} + +// tool bar + +ToolbarItem toolbarAction(const char *id, const char *icon, const char *label, std::function trigger, bool enabled) { + return {iconButtonWidth(), [=]() { + ImGui::BeginDisabled(!enabled); + if (iconButton(id, icon)) trigger(); + ImGui::EndDisabled(); + disabledItemTooltip(label); + }, label, trigger, enabled, true}; +} + +ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *label, std::function items, bool bold, float width) { + if (width <= 0.0f) width = menuButtonWidth(text, bold); + ToolbarItem item{width, [id, text, items, bold, width]() { + const std::string popup_id = std::string(id) + "_menu"; + menuButton(id, text, popup_id.c_str(), bold, width); + if (dropdown::BeginPopup(popup_id.c_str())) { + items(); + dropdown::EndPopup(); + } + }, label}; + item.submenu = std::move(items); + return item; +} + +float toolbarButtonWidth(const std::string &label) { + return ImGui::CalcTextSize(label.c_str(), nullptr, true).x + ImGui::GetStyle().FramePadding.x * 2; +} + +static float toolbarGroupWidth(const std::vector &items, size_t begin, size_t end) { + float w = 0; + for (size_t i = begin; i < end; ++i) w += items[i].width + (i > begin ? ImGui::GetStyle().ItemSpacing.x : 0); + return w; +} + +float toolbarWidth(const std::vector &items, size_t spacer_index) { + spacer_index = std::min(spacer_index, items.size()); + float w = toolbarGroupWidth(items, 0, spacer_index) + toolbarGroupWidth(items, spacer_index, items.size()); + if (spacer_index > 0 && spacer_index < items.size()) w += ImGui::GetStyle().ItemSpacing.x; + return w; +} + +void drawToolbar(const std::vector &items, size_t spacer_index, float width) { + const ImGuiStyle &style = ImGui::GetStyle(); + spacer_index = std::min(spacer_index, items.size()); + const float right_width = toolbarGroupWidth(items, spacer_index, items.size()); + const float start_x = ImGui::GetCursorPosX(); + const float avail = width < 0.0f ? ImGui::GetContentRegionAvail().x : width; + const float right_edge = start_x + avail; + const float extension_width = iconButtonWidth(); + + // when everything fits the spacer takes the slack, otherwise the extension button is reserved at the + // right edge and the items are packed from the left until the next one does not fit + // a caller may size a flexible item from the same available width: allow for the float error of the round trip + const bool fits = toolbarWidth(items, spacer_index) <= avail + 0.5f; + size_t visible = items.size(); + if (!fits) { + const float usable = avail - (extension_width + style.ItemSpacing.x); + float used = 0; + for (visible = 0; visible < items.size(); ++visible) { + const float w = items[visible].width + (visible ? style.ItemSpacing.x : 0); + if (used + w > usable) break; + used += w; + } + } + + for (size_t i = 0; i < visible; ++i) { + if (i == 0) ImGui::SetCursorPosX(start_x); + else if (fits && i == spacer_index) ImGui::SameLine(right_edge - right_width); + else ImGui::SameLine(0.0f, ImGui::GetStyle().ItemSpacing.x); + items[i].draw(); + } + + if (visible < items.size()) { + // the extension button sits fully inside the toolbar: its right edge is the content region right edge + const float extension_x = std::max(start_x, right_edge - extension_width); + visible == 0 ? ImGui::SetCursorPosX(extension_x) : ImGui::SameLine(extension_x); + const bool extension_open = ImGui::IsPopupOpen("toolbar_extension_menu"); + const bool extension_clicked = iconButton("toolbar_extension", icon::CHEVRON_DOUBLE_RIGHT, "More"); + if (!extension_open && (extension_clicked || + (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)))) { + ImGui::OpenPopup("toolbar_extension_menu"); + } + dropdown::PositionBelowItem("toolbar_extension_menu", true); + if (dropdown::BeginPopup("toolbar_extension_menu")) { + for (size_t i = visible; i < items.size(); ++i) { + if (!items[i].in_menu) continue; + if (items[i].menu_label.empty()) { + items[i].draw(); + } else if (items[i].submenu) { + if (dropdown::BeginMenu(items[i].menu_label.c_str(), items[i].enabled)) { + items[i].submenu(); + dropdown::EndMenu(); + } + } else if (dropdown::Item(items[i].menu_label.c_str(), nullptr, false, items[i].enabled)) { + items[i].trigger(); + } + } + dropdown::EndPopup(); + } + } +} + +const float MENU_ARROW_SIZE = 6.0f; // dropdown arrow on a menu button + +float menuButtonWidth(const std::string &text, bool bold) { + if (bold) pushBoldFont(); + const float w = ImGui::CalcTextSize(text.c_str(), nullptr, true).x + ImGui::GetStyle().ItemInnerSpacing.x + MENU_ARROW_SIZE + + ImGui::GetStyle().FramePadding.x * 2; + if (bold) popBoldFont(); + return w; +} + +bool menuButton(const char *id, const std::string &text, const char *popup_id, bool bold, float width) { + const ImGuiStyle &style = ImGui::GetStyle(); + const bool popup_open = ImGui::IsPopupOpen(popup_id); + if (width <= 0.0f) width = menuButtonWidth(text, bold); + // ImGui closes popups at frame end on outside clicks. Only open a closed popup so a second press toggles it off. + if (bold) pushBoldFont(); + const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; + const float ascent = ImGui::GetFontBaked()->Ascent; + // the text and the arrow are centered as a group in the button + const float padding_x = std::max(style.FramePadding.x, (width - (text_width + ImGui::GetStyle().ItemInnerSpacing.x + MENU_ARROW_SIZE)) * 0.5f); + ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : style.Colors[ImGuiCol_Button]); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(padding_x, style.FramePadding.y)); + ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); + const bool pressed = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); + const bool clicked = pressed || (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup) && + ImGui::IsMouseClicked(ImGuiMouseButton_Left)); + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(); + if (bold) popBoldFont(); + // a 6 px arrow right after the text, sitting on the text baseline + const ImVec2 min = ImGui::GetItemRectMin(); + const float x = min.x + padding_x + text_width + ImGui::GetStyle().ItemInnerSpacing.x; + const float baseline = min.y + style.FramePadding.y + ascent; + ImGui::GetWindowDrawList()->AddTriangleFilled(ImVec2(x, baseline - MENU_ARROW_SIZE * 0.5f), + ImVec2(x + MENU_ARROW_SIZE, baseline - MENU_ARROW_SIZE * 0.5f), + ImVec2(x + MENU_ARROW_SIZE * 0.5f, baseline), + ImGui::GetColorU32(ImGuiCol_TextDisabled)); + if (clicked && !popup_open) ImGui::OpenPopup(popup_id); + dropdown::PositionBelowItem(popup_id); + return clicked; +} + +void drawSliderHandle(ImDrawList *p, const ImRect &r) { + const Palette &pal = palette(); + p->AddRectFilled(r.Min, r.Max, u32(pal.button_hovered), 2.0f); + p->AddRectFilled(ImVec2(r.Min.x, r.GetCenter().y), r.Max, u32(pal.button), 2.0f, ImDrawFlags_RoundCornersBottom); + p->AddRect(r.Min, r.Max, u32(pal.border), 2.0f, 0, 1.0f); +} + +bool fusionSliderInt(const char *label, int *v, int min, int max, float width) { + // Keep ImGui slider input handling, but replace its frame and grab with custom drawing. + ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_SliderGrab, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_SliderGrabActive, IM_COL32_BLACK_TRANS); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::SetNextItemWidth(width); + bool changed = ImGui::SliderInt(label, v, min, max, "", ImGuiSliderFlags_NoInput); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(5); + + const ImVec2 bb_min = ImGui::GetItemRectMin(), bb_max = ImGui::GetItemRectMax(); + const float cy = (bb_min.y + bb_max.y) * 0.5f; + const float groove_h = SLIDER_THICKNESS * 0.5f; + const float handle_h = std::min(SLIDER_THICKNESS, bb_max.y - bb_min.y); + const float x0 = bb_min.x + SLIDER_LENGTH * 0.5f, x1 = bb_max.x - SLIDER_LENGTH * 0.5f; + const float t = max > min ? (float)(*v - min) / (float)(max - min) : 0.0f; + const float hx = x0 + (x1 - x0) * t; + ImDrawList *dl = ImGui::GetWindowDrawList(); + const float groove_y0 = cy - groove_h * 0.5f, groove_y1 = cy + groove_h * 0.5f; + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(bb_max.x, groove_y1), u32(palette().slider_track), groove_h * 0.5f); + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(hx, groove_y1), u32(palette().accent), groove_h * 0.5f); + drawSliderHandle(dl, ImRect(ImVec2(hx - SLIDER_LENGTH * 0.5f, cy - handle_h * 0.5f), + ImVec2(hx + SLIDER_LENGTH * 0.5f, cy + handle_h * 0.5f))); + return changed; +} diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h new file mode 100644 index 0000000000..d8f11700a7 --- /dev/null +++ b/openpilot/tools/cabana/ui/util.h @@ -0,0 +1,204 @@ +#pragma once + +#include +#include +#include + +#include "tools/cabana/ui/dropdown.h" +#include "tools/cabana/utils/util.h" + +struct GLFWwindow; + +// the dock window identity of the messages panel (the visible title changes, the part after ### is the id) +constexpr const char *MESSAGES_PANEL_ID = "###MessagesPanel"; + +struct InputContext { + std::string *str; + ImGuiInputTextCallback validator; + ValidState (*validate)(const std::string &) = nullptr; + const std::string *last_valid = nullptr; +}; + +int inputCallback(ImGuiInputTextCallbackData *data); + +// text input with an optional validator; `s` grows through the resize callback +bool validatedInput(const char *label, std::string *s, ImGuiInputTextCallback validator, const char *hint = "", + ImGuiInputTextFlags flags = 0); + +inline bool inputText(const char *label, std::string *s, const char *hint = "", ImGuiInputTextFlags flags = 0) { + return validatedInput(label, s, nullptr, hint, flags); +} + +bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, ImGuiInputTextFlags flags = 0); + +constexpr float CONTROL_OUTLINE_PADDING = 1.0f; +// Always pair with ImGui::EndChild(), even when false is returned. +bool beginControlChild(const char *id, const ImVec2 &size, ImGuiWindowFlags flags = 0); + +// SetNextItemWidth includes the field and clear button. Returns true when text changes. +bool clearableInput(const char *label, std::string *s, const char *hint = "", ImGuiInputTextCallback validator = nullptr); + +bool selectable(const char *label, bool selected, ImGuiSelectableFlags flags = 0, const ImVec2 &size = ImVec2(0, 0)); + +bool comboBox(const char *label, int *index, const std::vector &items); + +// numeric items (bus ids, bus speeds) are formatted as they are drawn +template +inline bool comboBox(const char *label, int *index, const T *values, int count) { + bool changed = false; + const std::string preview = *index >= 0 && *index < count ? std::to_string(values[*index]) : ""; + if (dropdown::BeginCombo(label, preview.c_str())) { + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + if (dropdown::Item(std::to_string(values[i]).c_str(), nullptr, i == *index) && *index != i) { + *index = i; + changed = true; + } + if (i == *index) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + dropdown::EndCombo(); + } + return changed; +} + +// an edit that makes the text Invalid is refused inside the imgui buffer, like a validated line edit +bool validatedText(const char *label, std::string *s, ValidState (*validate)(const std::string &), + const char *hint = "", ImGuiInputTextCallback filter = nullptr); + +// InputText char filters; the std::string validators in utils/util.h are run again when the edit is committed +int nameValidator(ImGuiInputTextCallbackData *data); +int nodeValidator(ImGuiInputTextCallbackData *data); +int doubleValidator(ImGuiInputTextCallbackData *data); +int ipValidator(ImGuiInputTextCallbackData *data); +int nonWhitespaceValidator(ImGuiInputTextCallbackData *data); + +#ifdef __APPLE__ +constexpr const char *MOD_KEY = "Cmd"; +#else +constexpr const char *MOD_KEY = "Ctrl"; +#endif +inline std::string shortcut(const char *keys) { return std::string(MOD_KEY) + "+" + keys; } + +// Use ItemInnerSpacing between related buttons and ItemSpacing between groups. +bool iconButton(const char *id, const char *icon, const char *tooltip = nullptr); +float iconButtonWidth(); +bool stepButton(const char *id, bool increment, const char *tooltip = nullptr); +struct IconTextButtonOptions { + float height = 0.0f; + float rounding = -1.0f; // Negative uses the theme default. + float icon_gap = -1.0f; // Negative uses the theme default. + bool center_content = false; + float label_width = 0.0f; // Shared width aligns labels in a group of centered buttons. +}; +bool iconTextButton(const char *id, const char *icon, const std::string &text, float width = 0.0f, + const IconTextButtonOptions &options = {}); +float iconTextButtonWidth(const char *icon, const std::string &text, const IconTextButtonOptions &options = {}); + +// tooltip for the last item that also shows while the item is disabled +void disabledItemTooltip(const char *text); + +// A queued modal popup submitted from whichever call site is nested in the top-most modal. draw() is called +// both nested in a modal dialog and at the root level; only the level that opened the popup may submit it +// (opening at level 0 would make imgui close the parent modal). +struct PopupOwner { + ImGuiID popup_id = 0, owner_id = 0; + + // false: this call site must skip the popup this frame + bool begin(const char *id); + + void reset() { popup_id = owner_id = 0; } +}; + +// Escape closes a dialog only when nothing is open above it: a combo drops its list first. +bool dialogEscapePressed(); + +// the window of the top-most open popup, nullptr when none is open +ImGuiWindow *topPopupWindow(); + +// [Cancel] [Accept], right aligned. reject_label = nullptr for an accept-only box. Escape rejects. +bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, bool accept_enabled = true, + const char *reject_label = "Cancel"); + +// Numeric inputs keep the same external gaps as other button rows. Width includes +// a readable value plus both step buttons; use for compact fixed-width fields. +float inputIntWidth(int digits); +bool inputInt(const char *label, int *value, int step = 1, int step_fast = 100, + ImGuiInputTextFlags flags = ImGuiInputTextFlags_None); + +// horizontal header labels are centered. Returns the column a right click was released on, or -1. +int tableHeadersRow(); + +// no hover highlight, only the selection background. Selectable() prefers HeaderHovered over Header +// whenever the row is hovered, even when it is selected, so a selected row has to keep the selection color +// as its hover color or it looks unselected. +bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size); + +// a 16px box vertically centered in the frame height so rows keep their layout; ImGui::Checkbox draws a +// frame height (22 px) square. +bool checkBox(const char *label, bool *v); +constexpr float CHECKBOX_SIZE = 16.0f; + +// the next items on the line are right aligned as a block `width` wide +void alignRight(float width); + +// text drawn inside a rect; align 0 = left/top, 0.5 = center, 1 = right/bottom. font_size 0: the current size +void drawText(ImDrawList *dl, const ImRect &rect, const char *text, ImU32 col, ImFont *font = nullptr, + float font_size = 0.0f, const ImVec2 &align = ImVec2(0.5f, 0.5f)); +// elided to the rect width, vertically centered +void drawElidedText(ImDrawList *dl, const ImRect &rect, const std::string &text, ImU32 col, bool align_right = false); +// the colored square in front of a signal name: a text line minus 4 px, drawn 2 px below `pos` +float markerSize(); +void drawColorMarker(ImDrawList *dl, const ImVec2 &pos, ImU32 col); + +// the next window is a real OS window instead of being drawn inside the main one +void setNextWindowFloatsOut(); +#ifdef __APPLE__ +// the app menu takes its name from the main bundle, and a bare binary gets an info dictionary with its +// file name in it. That dictionary is mutable, so the name is set before glfw brings up cocoa +void setMacAppName(const char *name); +// the native Cocoa full screen toggle (glfw's monitor switch is not full screen on macOS) +bool isNativeFullScreen(GLFWwindow *window); +void toggleNativeFullScreen(GLFWwindow *window); +#endif + +// centered, floating out, sized on first appearance +void setNextDialogWindow(const ImVec2 &size); +// centered modal dialog. false when the popup is not submitted this frame. +bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize); + +const float SLIDER_LENGTH = 13.0f; +const float SLIDER_THICKNESS = 13.0f; + +// a tool bar item: `draw` submits it. Items that do not fit go into a ">>" menu, where an item with a +// menu_label becomes a MenuItem that runs `trigger`, and an item without one draws itself. +struct ToolbarItem { + float width; + std::function draw; + std::string menu_label; + std::function trigger; + bool enabled = true; + bool in_menu = true; // false: left out of the ">>" menu (a separator) + std::function submenu; // set: the ">>" entry is a submenu with these items instead of an action +}; +ToolbarItem toolbarAction(const char *id, const char *icon, const char *label, std::function trigger, + bool enabled = true); +// A drop-down button that opens `items` in a popup; in the overflow menu they become a submenu. +// width 0: sized to the text. +ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *label, std::function items, + bool bold = false, float width = 0.0f); +float toolbarButtonWidth(const std::string &label); +// the width of every item plus the spacing between neighbors and the two groups +float toolbarWidth(const std::vector &items, size_t spacer_index); +// items before spacer_index sit at the left, the rest are right aligned; the overflow goes into the ">>" menu +// width < 0 uses the available content width. +void drawToolbar(const std::vector &items, size_t spacer_index, float width = -1.0f); + +// Opens `popup_id` below the button on press. width 0: +// sized to the text, otherwise the text and the arrow are centered in the button +float menuButtonWidth(const std::string &text, bool bold = false); +bool menuButton(const char *id, const std::string &text, const char *popup_id, bool bold = false, float width = 0.0f); + +void drawSliderHandle(ImDrawList *p, const ImRect &r); + +bool fusionSliderInt(const char *label, int *v, int min, int max, float width); diff --git a/openpilot/tools/cabana/ui/widgets/binarysignals.h b/openpilot/tools/cabana/ui/widgets/binarysignals.h new file mode 100644 index 0000000000..1ccdd16c1c --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/binarysignals.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +#include "tools/cabana/dbc/dbc.h" + +// Only the current multiplexed branch owns the payload bits. Use this same +// signal set for painting, endpoint markers, and mouse interaction. +inline std::vector binaryViewSignals(const cabana::Msg *msg, const uint8_t *data, size_t size) { + if (!msg) return {}; + std::optional multiplex_value; + if (msg->multiplexor) { + auto selector = *msg->multiplexor; + if (std::max(selector.msb, selector.lsb) / 8 < size) { + // Multiplex values are raw identifiers, independent of display scaling. + selector.factor = 1; + selector.offset = 0; + selector.is_signed = false; + multiplex_value = get_raw_value(data, size, selector); + } + } + std::vector signals; + for (auto sig : msg->getSignals()) { + if (sig->type != cabana::Signal::Type::Multiplexed || + (multiplex_value && *multiplex_value == sig->multiplex_value)) { + signals.push_back(sig); + } + } + return signals; +} diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc new file mode 100644 index 0000000000..818f8b42a8 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -0,0 +1,556 @@ +#include "tools/cabana/ui/widgets/binaryview.h" + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/widgets/binarysignals.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { + +const int CELL_HEIGHT = 36; +const float CELL_FONT_SIZE = UI_FONT_SIZE + 2.0f; +const float SMALL_FONT_SIZE = 10.0f; // Inter needs 10 px for a 7 px cap height +const int GRID_COLUMN_COUNT = BinaryView::COLUMN_COUNT + 1; +inline int get_bit_pos(const BinaryIndex &index) { return flipBitPos(index.row * 8 + index.column); } + +inline ImU32 paletteBase() { return ImGui::GetColorU32(ImGuiCol_ChildBg); } +inline ImU32 paletteText() { return ImGui::GetColorU32(ImGuiCol_Text); } + +// JetBrains Mono ships no bold variant, so emulate one by drawing the glyphs again a fraction of a +// pixel to the right. Keeps the monospace advance, unlike switching to the proportional bold face. +void drawBoldText(ImDrawList *p, const ImRect &r, const char *text, ImU32 col, ImFont *font, float font_size) { + drawText(p, r, text, col, font, font_size); + drawText(p, ImRect(ImVec2(r.Min.x + 0.6f, r.Min.y), ImVec2(r.Max.x + 0.6f, r.Max.y)), text, col, font, font_size); +} + +// sparse dots +void fillDense7Pattern(ImDrawList *p, const ImRect &r, ImU32 col) { + p->PushClipRect(r.Min, r.Max, true); + for (float y = r.Min.y; y < r.Max.y; y += 4.0f) { + for (float x = r.Min.x + (static_cast((y - r.Min.y) / 4.0f) % 2) * 2.0f; x < r.Max.x; x += 4.0f) { + p->AddRectFilled(ImVec2(x, y), ImVec2(x + 1.0f, y + 1.0f), col); + } + } + p->PopClipRect(); +} + +// backward diagonal lines +void fillBDiagPattern(ImDrawList *p, const ImRect &r, ImU32 col) { + p->PushClipRect(r.Min, r.Max, true); + const float h = r.GetHeight(); + for (float x = r.Min.x - h; x < r.Max.x; x += 8.0f) { + p->AddLine(ImVec2(x, r.Max.y), ImVec2(x + h, r.Min.y), col, 1.0f); + } + p->PopClipRect(); +} + +} // namespace + +BinaryView::BinaryView() { + heatmap_live_mode_ = can->liveStreaming(); + connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &events) { + if (events.count(msg_id_)) { + bit_flip_tracker_.valid = false; + if (!heatmap_live_mode_) updateState(); + } + })); + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); +} + +std::string BinaryView::whatsThis() const { + return R"( + Binary View
+ All / selected range: brighter bits and bytes changed more often; uncolored cells did not change.
+ Live: accumulated bit flips and fading byte changes during playback.
+ Shortcuts
+ Delete Signal: +  x , +  Backspace , +  Delete 
+ Change endianness:  e 
+ Change signedness:  s 
+ Open chart: +  c , +  p , +  g  + )"; +} + +void BinaryView::addShortcuts() { + const ImGuiIO &io = ImGui::GetIO(); + if (io.WantTextInput || io.KeyCtrl || io.KeySuper) return; + if (ImGui::GetTopMostPopupModal() != nullptr) return; // a modal dialog blocks the shortcuts + + if (ImGui::IsKeyPressed(ImGuiKey_X, false) || ImGui::IsKeyPressed(ImGuiKey_Backspace, false) || ImGui::IsKeyPressed(ImGuiKey_Delete, false)) { + if (hovered_sig_ != nullptr) { + UndoStack::instance()->push(new RemoveSigCommand(msg_id_, hovered_sig_)); + hovered_sig_ = nullptr; + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_E, false)) { + if (hovered_sig_ != nullptr) { + cabana::Signal s = *hovered_sig_; + s.is_little_endian = !s.is_little_endian; + editSignal(hovered_sig_, s); + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_S, false)) { + if (hovered_sig_ != nullptr) { + cabana::Signal s = *hovered_sig_; + s.is_signed = !s.is_signed; + editSignal(hovered_sig_, s); + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_P, false) || ImGui::IsKeyPressed(ImGuiKey_G, false) || ImGui::IsKeyPressed(ImGuiKey_C, false)) { + if (hovered_sig_ != nullptr) { + showChart(msg_id_, hovered_sig_, true, false); + } + } +} + +ImVec2 BinaryView::minimumSizeHint() const { + // Match the enlarged hex font when reserving space for narrow panels. + pushMonoFont(CELL_FONT_SIZE); + const float min_section_size = std::ceil(ImGui::CalcTextSize("FF").x) + 10.0f; + popMonoFont(); + return {min_section_size * GRID_COLUMN_COUNT + 2, + static_cast(CELL_HEIGHT * std::min(row_count_, 10) + 2)}; +} + +void BinaryView::highlight(const cabana::Signal *sig) { + if (sig != hovered_sig_) { + hovered_sig_ = sig; + signalHovered(hovered_sig_); + } +} + +void BinaryView::setSelection() { + auto index = indexAt(last_mouse_pos_); + if (!anchor_index_.isValid() || !index.isValid()) + return; + + std::set selection; + auto [start, size, is_lb] = getSelection(index); + for (int i = 0; i < size; ++i) { + int pos = is_lb ? flipBitPos(start + i) : flipBitPos(start) + i; + selection.insert({pos / 8, pos % 8}); + } + selection_ = std::move(selection); +} + +void BinaryView::handleMousePress(const ImVec2 &pos) { + resize_sig_ = nullptr; + if (auto index = indexAt(last_mouse_pos_ = pos); index.isValid() && index.column != HEX_COLUMN) { + anchor_index_ = index; + auto item = &cellAt(anchor_index_); + int bit_pos = get_bit_pos(anchor_index_); + for (auto s : item->sigs) { + if (bit_pos == s->lsb || bit_pos == s->msb) { + int idx = flipBitPos(bit_pos == s->lsb ? s->msb : s->lsb); + anchor_index_ = {idx / 8, idx % 8}; + resize_sig_ = s; + break; + } + } + } +} + +void BinaryView::highlightPosition(const ImVec2 &pos) { + if (auto index = indexAt(pos); index.isValid()) { + auto item = &cellAt(index); + const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); + highlight(sig); + } else { + highlight(nullptr); + } +} + +void BinaryView::handleMouseMove(const ImVec2 &pos) { + highlightPosition(last_mouse_pos_ = pos); + // drag selecting while the left button is down; the hex column is not selectable + if (ImGui::IsMouseDown(ImGuiMouseButton_Left) && indexAt(pos).column != HEX_COLUMN) setSelection(); +} + +void BinaryView::handleMouseRelease(const ImVec2 &pos) { + auto release_index = indexAt(pos); + if (release_index.isValid() && anchor_index_.isValid()) { + if (hasSelection()) { + auto sig = resize_sig_ ? *resize_sig_ : cabana::Signal{}; + std::tie(sig.start_bit, sig.size, sig.is_little_endian) = getSelection(release_index); + resize_sig_ ? editSignal(resize_sig_, sig) + : UndoStack::instance()->push(new AddSigCommand(msg_id_, sig)); + } else { + auto item = &cellAt(anchor_index_); + if (item->sigs.size() > 0) + signalClicked(item->sigs.back()); + } + } + selection_.clear(); + anchor_index_ = BinaryIndex(); + resize_sig_ = nullptr; +} + +void BinaryView::setMessage(const MessageId &message_id) { + msg_id_ = message_id; + scroll_to_top_ = true; + refresh(); +} + +void BinaryView::refresh() { + selection_.clear(); + anchor_index_ = BinaryIndex(); + resize_sig_ = nullptr; + hovered_sig_ = nullptr; + bit_flip_tracker_ = {}; + cells_.clear(); + visible_signals_.clear(); + const auto *msg = dbc()->msg(msg_id_); + row_count_ = msg ? msg->size : can->lastMessage(msg_id_).dat.size(); + cells_.resize(row_count_ * COLUMN_COUNT); + updateState(); + if (under_mouse_) highlightPosition(last_mouse_pos_); +} + +void BinaryView::updateSignals() { + const auto &data = can->lastMessage(msg_id_).dat; + auto signals = binaryViewSignals(dbc()->msg(msg_id_), data.data(), data.size()); + if (signals == visible_signals_) return; + visible_signals_ = std::move(signals); + + // A branch switch invalidates the old hover and any in-progress resize. + selection_.clear(); + anchor_index_ = {}; + resize_sig_ = nullptr; + highlight(nullptr); + for (auto &cell : cells_) { + cell.sigs.clear(); + cell.is_msb = cell.is_lsb = false; + } + for (auto sig : visible_signals_) { + for (int j = 0; j < sig->size; ++j) { + 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 < 0 || idx >= cells_.size()) break; + if (j == 0) sig->is_little_endian ? cells_[idx].is_lsb = true : cells_[idx].is_msb = true; + if (j == sig->size - 1) sig->is_little_endian ? cells_[idx].is_msb = true : cells_[idx].is_lsb = true; + cells_[idx].sigs.push_back(sig); + } + } + for (auto &cell : cells_) { + std::stable_sort(cell.sigs.begin(), cell.sigs.end(), [](auto l, auto r) { return l->size > r->size; }); + } + if (under_mouse_) highlightPosition(last_mouse_pos_); + signalsChanged(); +} + + +std::set BinaryView::getOverlappingSignals() const { + std::set overlapping; + for (const auto &item : cells_) { + if (item.sigs.size() > 1) { + for (auto s : item.sigs) { + if (s->type == cabana::Signal::Type::Normal) overlapping.insert(s); + } + } + } + return overlapping; +} + +std::tuple BinaryView::getSelection(BinaryIndex index) { + if (index.column == HEX_COLUMN) { + index = {index.row, 7}; + } + bool is_lb = true; + if (resize_sig_) { + is_lb = resize_sig_->is_little_endian; + } else if (settings.drag_direction == Settings::DragDirection::MsbFirst) { + is_lb = index < anchor_index_; + } else if (settings.drag_direction == Settings::DragDirection::LsbFirst) { + is_lb = !(index < anchor_index_); + } else if (settings.drag_direction == Settings::DragDirection::AlwaysLE) { + is_lb = true; + } else if (settings.drag_direction == Settings::DragDirection::AlwaysBE) { + is_lb = false; + } + + int cur_bit_pos = get_bit_pos(index); + int anchor_bit_pos = get_bit_pos(anchor_index_); + int start_bit = is_lb ? std::min(cur_bit_pos, anchor_bit_pos) : get_bit_pos(std::min(index, anchor_index_)); + int size = is_lb ? std::abs(cur_bit_pos - anchor_bit_pos) + 1 : std::abs(flipBitPos(cur_bit_pos) - flipBitPos(anchor_bit_pos)) + 1; + return {start_bit, size, is_lb}; +} + +BinaryIndex BinaryView::indexAt(const ImVec2 &pos) const { + if (column_width_ <= 0 || pos.x < grid_pos_.x + IM_ROUND(column_width_) || pos.y < grid_pos_.y) return {}; + int column = 0; + while (column < COLUMN_COUNT && pos.x >= grid_pos_.x + IM_ROUND((column + 2) * column_width_)) ++column; + int row = static_cast((pos.y - grid_pos_.y) / CELL_HEIGHT); + if (column >= COLUMN_COUNT || row >= row_count_) return {}; + return {row, column}; +} + +ImRect BinaryView::visualRect(const BinaryIndex &index) const { + // Round shared edges consistently across the byte index, bits, and hex column. + const float x0 = grid_pos_.x + IM_ROUND((index.column + 1) * column_width_); + const float x1 = grid_pos_.x + IM_ROUND((index.column + 2) * column_width_); + const float y = grid_pos_.y + index.row * CELL_HEIGHT; + return ImRect(x0, y, x1, y + CELL_HEIGHT); +} + +void BinaryView::draw() { + is_message_active_ = can->isMessageActive(msg_id_); + if (scroll_to_top_) { + ImGui::SetScrollY(0.0f); + scroll_to_top_ = false; + } + + const int rows = row_count_; + // Keep hex bytes readable in narrow panels by scrolling instead of shrinking further. + pushMonoFont(CELL_FONT_SIZE); + const float min_column_width = std::ceil(ImGui::CalcTextSize("FF").x) + 10.0f; + popMonoFont(); + const float width = std::max(ImGui::GetContentRegionAvail().x, min_column_width * GRID_COLUMN_COUNT); + column_width_ = std::max(min_column_width, width / GRID_COLUMN_COUNT); + grid_pos_ = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton("##binary_view", ImVec2(std::max(width, 1.0f), std::max(static_cast(rows * CELL_HEIGHT), 1.0f))); + ImDrawList *painter = ImGui::GetWindowDrawList(); + painter->AddRectFilled(grid_pos_, ImVec2(grid_pos_.x + width, grid_pos_.y + rows * CELL_HEIGHT), paletteBase()); + + for (int row = 0; row < rows; ++row) { + const ImRect r(grid_pos_.x, grid_pos_.y + row * CELL_HEIGHT, grid_pos_.x + IM_ROUND(column_width_), grid_pos_.y + (row + 1) * CELL_HEIGHT); + drawText(painter, r, std::to_string(row).c_str(), paletteText(), nullptr, CELL_FONT_SIZE); + } + for (int row = 0; row < rows; ++row) { + for (int column = 0; column < COLUMN_COUNT; ++column) { + const BinaryIndex index = {row, column}; + paintCell(painter, visualRect(index), index); + } + } + + // One consistent grid over every cell; hover never changes its geometry. + const ImU32 grid_color = ImGui::GetColorU32(ImGuiCol_Border, 0.35f); + const float right = grid_pos_.x + IM_ROUND(GRID_COLUMN_COUNT * column_width_); + const float bottom = grid_pos_.y + rows * CELL_HEIGHT; + for (int column = 0; column <= GRID_COLUMN_COUNT; ++column) { + const float x = std::clamp(grid_pos_.x + IM_ROUND(column * column_width_), grid_pos_.x + 0.5f, right - 0.5f); + painter->AddLine(ImVec2(x, grid_pos_.y), ImVec2(x, bottom), grid_color); + } + for (int row = 0; rows > 0 && row <= rows; ++row) { + const float y = std::clamp(grid_pos_.y + row * CELL_HEIGHT, grid_pos_.y + 0.5f, bottom - 0.5f); + painter->AddLine(ImVec2(grid_pos_.x, y), ImVec2(right, y), grid_color); + } + + // Signal definitions are a separate layer from heatmap activity. + for (int row = 0; row < rows; ++row) { + for (int column = 0; column < HEX_COLUMN; ++column) { + const BinaryIndex index{row, column}; + for (const auto *sig : cellAt(index).sigs) { + if (!hasSelection() || sig != resize_sig_) drawSignalOutline(painter, visualRect(index), index, sig); + } + } + } + + const ImVec2 mouse = ImGui::GetMousePos(); + const bool hovered = ImGui::IsItemHovered(); + const bool active = ImGui::IsItemActive(); + const bool under_mouse = (hovered || active) && ImGui::IsMouseHoveringRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), false); + if (hovered || active) { + if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) handleMousePress(mouse); + const ImVec2 delta = ImGui::GetIO().MouseDelta; + if (delta.x != 0.0f || delta.y != 0.0f) { + handleMouseMove(mouse); + } else { + // imgui only reports a delta on the frames the mouse actually moves, so recompute the hovered + // signal every frame the mouse is inside the widget, or the shortcuts stay inert after a click + highlightPosition(last_mouse_pos_ = mouse); + } + } + // the mouse left the widget rect, also while dragging + if (std::exchange(under_mouse_, under_mouse) && !under_mouse) highlight(nullptr); + if (ImGui::IsItemDeactivated()) handleMouseRelease(mouse); + + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip)) { + if (auto index = indexAt(mouse); index.isValid() && !cellAt(index).sigs.empty()) { + ImGui::SetTooltip("%s", utils::stripHtml(utils::signalToolTip(cellAt(index).sigs.back())).c_str()); + } + } + + addShortcuts(); +} + +void BinaryView::setCell(int row, int col, uint8_t val, const CabanaColor &color) { + auto &item = cells_[row * COLUMN_COUNT + col]; + item.valid = true; + item.val = val; + item.bg_color = color; +} + +void BinaryView::updateState() { + const auto &last_msg = can->lastMessage(msg_id_); + const auto &binary = last_msg.dat; + if (binary.size() > row_count_) { + row_count_ = binary.size(); + cells_.resize(row_count_ * COLUMN_COUNT); + } + + for (auto &cell : cells_) cell.valid = false; + updateSignals(); + + auto &bit_flips = heatmap_live_mode_ ? last_msg.bit_flip_counts : bitFlipChanges(binary.size()); + uint32_t max_bit_flip_count = 1; // 1 to avoid division by zero + for (const auto &row : bit_flips) { + for (uint32_t count : row) { + max_bit_flip_count = std::max(max_bit_flip_count, count); + } + } + + uint32_t max_byte_flip_count = 1; + for (auto count : bit_flip_tracker_.counts.bytes) max_byte_flip_count = std::max(max_byte_flip_count, count); + + // Use the same logarithmic intensity mapping in both themes. + const double max_alpha = 255.0; + const double min_alpha_with_signal = 25.0; // Base alpha for small flip counts + const double min_alpha_no_signal = 10.0; // Base alpha for small flip counts for no signal bits + const double log_factor = 1.0 + 0.2; + const double log_scaler = max_alpha / log2(1.0 + log_factor * max_bit_flip_count); + + for (size_t i = 0; i < binary.size(); ++i) { + for (int j = 0; j < 8; ++j) { + auto &item = cells_[i * COLUMN_COUNT + j]; + int bit_val = (binary[i] >> (7 - j)) & 1; + + double alpha = item.sigs.empty() ? 0 : min_alpha_with_signal; + uint32_t flip_count = bit_flips[i][j]; + if (flip_count > 0) { + double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler; + double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal; + alpha = std::clamp(normalized_alpha, min_alpha, max_alpha); + } + + auto color = item.bg_color; + color.a = static_cast(alpha); + setCell(i, j, bit_val, color); + } + auto byte_color = last_msg.colors[i]; + if (!heatmap_live_mode_) { + const auto count = bit_flip_tracker_.counts.bytes[i]; + const double intensity = std::log2(1.0 + count * log_factor) / std::log2(1.0 + max_byte_flip_count * log_factor); + byte_color = CabanaColor(102, 86, 169, static_cast(max_alpha * intensity)); + } + setCell(i, HEX_COLUMN, binary[i], byte_color); + } +} + +const std::vector> &BinaryView::bitFlipChanges(size_t msg_size) { + auto time_range = can->timeRange(); + if (bit_flip_tracker_.valid && bit_flip_tracker_.time_range == time_range && + bit_flip_tracker_.counts.bits.size() == msg_size) return bit_flip_tracker_.counts.bits; + + bit_flip_tracker_.time_range = time_range; + bit_flip_tracker_.counts = HeatmapCounts(msg_size); + auto [first, last] = can->eventsInRange(msg_id_, time_range); + for (auto it = first; it != last; ++it) bit_flip_tracker_.counts.add((*it)->dat, (*it)->size); + bit_flip_tracker_.valid = true; + return bit_flip_tracker_.counts.bits; +} + +void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const { + auto item = &cellAt(index); + ImFont *font = ImGui::GetFont(); + float font_size = CELL_FONT_SIZE; + ImU32 pen = paletteText(); + const bool hovered = hovered_sig_ && std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); + + if (index.column == HEX_COLUMN) { + if (item->valid) { + pushMonoFont(CELL_FONT_SIZE); + font = ImGui::GetFont(); + font_size = ImGui::GetFontSize(); + popMonoFont(); + painter->AddRectFilled(rect.Min, rect.Max, toImU32(byteColor(item->bg_color))); + } + } else if (isSelected(index)) { + painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalHighlight(resize_sig_ ? resize_sig_->color : fromImVec4(palette().header)))); + pen = IM_COL32_WHITE; + } else if (!hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), resize_sig_) == item->sigs.end()) { // not resizing + if (item->sigs.size() > 0) { + for (auto &s : item->sigs) { + drawSignalCell(painter, rect, index, s); + } + // Hover covers the entire signal, including bits shared with another definition. + if (hovered) painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalHighlight(hovered_sig_->color))); + } else if (item->valid) { + if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalFill(item->bg_color, false))); + } + pen = hovered ? IM_COL32_WHITE : paletteText(); + } + + const auto text = fromImVec4(ImGui::ColorConvertU32ToFloat4(pen)); + const ImU32 pattern = toImU32(contrastColor({128, 128, 128}, text)); + if (item->sigs.size() > 1) { + fillDense7Pattern(painter, rect, pattern); + } else if (!item->valid) { + fillBDiagPattern(painter, rect, pattern); + } + + if (item->valid) { + if (index.column == HEX_COLUMN) { + drawBoldText(painter, rect, utils::hexByte(item->val), pen, font, font_size); + } else { + drawText(painter, rect, item->val ? "1" : "0", pen, font, font_size); + } + } + if (item->is_msb || item->is_lsb) { + const ImRect marker_rect(rect.Min, ImVec2(rect.Max.x - 8, rect.Max.y - 3)); + drawText(painter, marker_rect, item->is_msb ? "M" : "L", pen, nullptr, SMALL_FONT_SIZE, ImVec2(1.0f, 1.0f)); + } +} + +bool BinaryView::hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const { + const int row = index.row + dy; + const int column = index.column + dx; + if (row < 0 || row >= row_count_ || column < 0 || column >= HEX_COLUMN) return false; + const auto &signals = cellAt({row, column}).sigs; + return std::find(signals.begin(), signals.end(), sig) != signals.end(); +} + +void BinaryView::drawSignalOutline(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, + const cabana::Signal *sig) const { + // Definition boundaries remain visible even at zero activity. Keep them above the + // fills and grid, at the same fixed position before and during hover. + const ImU32 edge = toImU32(signalOutline(sig->color, sig == hovered_sig_)); + const bool left = !hasSignal(index, -1, 0, sig); + const bool right = !hasSignal(index, 1, 0, sig); + const bool top = !hasSignal(index, 0, -1, sig); + const bool bottom = !hasSignal(index, 0, 1, sig); + // Extend one pixel into adjacent cells to join inward corners. + auto offset = [&](bool boundary, int dx, int dy) { + return boundary ? 1.0f : hasSignal(index, dx, dy, sig) ? -1.0f : 0.0f; + }; + const ImVec2 min = rect.Min, max = rect.Max; + if (left) painter->AddLine(ImVec2(min.x + 1, min.y + offset(top, -1, -1)), ImVec2(min.x + 1, max.y - offset(bottom, -1, 1)), edge); + if (right) painter->AddLine(ImVec2(max.x - 1, min.y + offset(top, 1, -1)), ImVec2(max.x - 1, max.y - offset(bottom, 1, 1)), edge); + if (top) painter->AddLine(ImVec2(min.x + offset(left, -1, -1), min.y + 1), ImVec2(max.x - offset(right, 1, -1), min.y + 1), edge); + if (bottom) painter->AddLine(ImVec2(min.x + offset(left, -1, 1), max.y - 1), ImVec2(max.x - offset(right, 1, 1), max.y - 1), edge); +} + +// Signal intensity and hover share the same full-cell geometry. +void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const { + CabanaColor color = sig->color; + color.a = 96 + cellAt(index).bg_color.alpha() * (255 - 96) / 255; + painter->AddRectFilled(rect.Min, rect.Max, paletteBase()); + painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalFill(color))); +} diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.h b/openpilot/tools/cabana/ui/widgets/binaryview.h new file mode 100644 index 0000000000..4f1afa1032 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/binaryview.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/heatmap.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + +// a (row, column) of the bit grid: 8 bit columns and the hex column +struct BinaryIndex { + int row = -1; + int column = -1; + bool isValid() const { return row >= 0 && column >= 0; } + bool operator==(const BinaryIndex &o) const { return row == o.row && column == o.column; } + bool operator<(const BinaryIndex &o) const { return std::tie(row, column) < std::tie(o.row, o.column); } +}; + +class BinaryView { +public: + static constexpr int COLUMN_COUNT = 9; + static constexpr int HEX_COLUMN = 8; + + BinaryView(); + void setMessage(const MessageId &message_id); + void highlight(const cabana::Signal *sig); + std::set getOverlappingSignals() const; + void updateState(); + // draws inline into the current (scrollable) window and handles the mouse/keyboard + void draw(); + ImVec2 minimumSizeHint() const; + void setHeatmapLiveMode(bool live) { heatmap_live_mode_ = live; updateState(); } + std::string whatsThis() const; + + Observable signalClicked; + Observable<> signalsChanged; + Observable signalHovered; + Observable editSignal; + Observable showChart; + +private: + struct Cell { + CabanaColor bg_color = CabanaColor(102, 86, 169, 255); + bool is_msb = false; + bool is_lsb = false; + uint8_t val; + std::vector sigs; + bool valid = false; + }; + + void updateSignals(); + void refresh(); // rebuilds the grid from the DBC message + void setCell(int row, int col, uint8_t val, const CabanaColor &color); + const std::vector> &bitFlipChanges(size_t msg_size); + Cell &cellAt(const BinaryIndex &index) { return cells_[index.row * COLUMN_COUNT + index.column]; } + const Cell &cellAt(const BinaryIndex &index) const { return cells_[index.row * COLUMN_COUNT + index.column]; } + + void addShortcuts(); // polled every frame from draw() + std::tuple getSelection(BinaryIndex index); + void setSelection(); + void handleMousePress(const ImVec2 &pos); + void handleMouseMove(const ImVec2 &pos); + void handleMouseRelease(const ImVec2 &pos); + void highlightPosition(const ImVec2 &pt); + BinaryIndex indexAt(const ImVec2 &pos) const; + ImRect visualRect(const BinaryIndex &index) const; + bool hasSelection() const { return !selection_.empty(); } + bool isSelected(const BinaryIndex &index) const { return selection_.count(index) > 0; } + + void paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const; + bool hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const; + void drawSignalOutline(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const; + void drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const; + + MessageId msg_id_; + std::vector cells_; + std::vector visible_signals_; + int row_count_ = 0; + bool heatmap_live_mode_ = true; + struct BitFlipTracker { + std::optional> time_range; + HeatmapCounts counts; + bool valid = false; + } bit_flip_tracker_; + + BinaryIndex anchor_index_; + ImVec2 last_mouse_pos_{-1, -1}; + bool is_message_active_ = false; + const cabana::Signal *resize_sig_ = nullptr; + const cabana::Signal *hovered_sig_ = nullptr; + std::set selection_; + ImVec2 grid_pos_; // viewport origin of the current frame + float column_width_ = 0; // stretched section size + bool under_mouse_ = false; + bool scroll_to_top_ = false; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.cc b/openpilot/tools/cabana/ui/widgets/cameraview.cc new file mode 100644 index 0000000000..b771717c22 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/cameraview.cc @@ -0,0 +1,189 @@ +#include "tools/cabana/ui/widgets/cameraview.h" + +#include +#include +#include +#include + +#include +#include "imgui_impl_opengl3_loader.h" + +#include "common/yuv.h" +#include "tools/cabana/utils/util.h" +#include "tools/cabana/settings.h" + +namespace { +constexpr GLenum GL_LINEAR_MIPMAP_LINEAR_ = 0x2703; +// glGenerateMipmap is not part of the imgui GL loader +void generateMipmap() { + static auto fn = (void (*)(GLenum))glfwGetProcAddress("glGenerateMipmap"); + if (fn) fn(GL_TEXTURE_2D); +} +} // namespace + +void drawVideoFrame(ImDrawList *draw_list, ImTextureRef texture, const ImRect &rect, const VideoPlacement &placement) { + const ImVec2 size(placement.max.x - placement.min.x, placement.max.y - placement.min.y); + if (size.x <= 0 || size.y <= 0) return; + + // Round the full frame, then clip to the square video bounds so letterboxing + // doesn't introduce a second set of rounded corners around the image. + const ImVec2 uv_scale((placement.uv1.x - placement.uv0.x) / size.x, + (placement.uv1.y - placement.uv0.y) / size.y); + const ImVec2 uv0(placement.uv0.x + (rect.Min.x - placement.min.x) * uv_scale.x, + placement.uv0.y + (rect.Min.y - placement.min.y) * uv_scale.y); + const ImVec2 uv1(placement.uv1.x + (rect.Max.x - placement.max.x) * uv_scale.x, + placement.uv1.y + (rect.Max.y - placement.max.y) * uv_scale.y); + draw_list->PushClipRect(placement.min, placement.max, true); + draw_list->AddImageRounded(texture, rect.Min, rect.Max, uv0, uv1, IM_COL32_WHITE, ImGui::GetStyle().ChildRounding); + draw_list->PopClipRect(); +} + +void GlTexture::upload(const RgbImage &image) { + if (id == 0) { + glGenTextures(1, &id); + glBindTexture(GL_TEXTURE_2D, id); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, mipmap ? GL_LINEAR_MIPMAP_LINEAR_ : 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); + } else { + glBindTexture(GL_TEXTURE_2D, id); + } + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + if (width != image.width || height != image.height) { + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image.width, image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); + width = image.width; + height = image.height; + } else { + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); + } + if (mipmap) generateMipmap(); + glBindTexture(GL_TEXTURE_2D, 0); +} + +void GlTexture::destroy() { + if (id != 0) { + glDeleteTextures(1, &id); + } + id = 0; + width = height = 0; + key = 0; +} + +CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type) + : stream_name_(stream_name), active_stream_type_(type), requested_stream_type_(type) {} + +CameraWidget::~CameraWidget() { + stopVipcThread(); +} + +void CameraWidget::startVipcThread() { + if (!vipc_thread_.joinable()) { + // Preserve the last frame when restoring a collapsed video; paused replay sends no replacement. + vipc_exit_ = false; + vipc_thread_ = std::thread(&CameraWidget::vipcThread, this); + } +} + +void CameraWidget::stopVipcThread() { + vipc_exit_ = true; + if (vipc_thread_.joinable()) { + vipc_thread_.join(); + } +} + +void CameraWidget::setVisible(bool visible) { + if (visible == visible_) return; + visible_ = visible; + visible ? startVipcThread() : stopVipcThread(); +} + +void CameraWidget::draw(const ImVec2 &size) { + setVisible(true); + ImGui::InvisibleButton("##camera", ImVec2(std::max(1.0f, size.x), std::max(1.0f, size.y)), + ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight | ImGuiButtonFlags_MouseButtonMiddle); + rect_ = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + paint(); + if (ImGui::IsItemDeactivated()) clicked(); +} + +float CameraWidget::frameAspectRatio() const { + if (frame_texture_.width > 0 && frame_texture_.height > 0) { + return (float)frame_texture_.width / frame_texture_.height; + } + return DEFAULT_CAMERA_ASPECT_RATIO; // the road camera, until the first frame arrives +} + +void CameraWidget::paint() { + ImDrawList *p = ImGui::GetWindowDrawList(); + p->AddRectFilled(rect_.Min, rect_.Max, bg_, ImGui::GetStyle().ChildRounding); + + std::lock_guard lk(frame_lock_); + if (rgb_frame_.isNull()) return; + if (frame_updated_) { + frame_texture_.upload(rgb_frame_); + frame_updated_ = false; + } + + VideoPlacement placement = videoPlacement(rect_, frameAspectRatio(), settings.crop_video); + if (active_stream_type_ == VISION_STREAM_CABIN) { + // mirror cabin camera horizontally + std::swap(placement.uv0.x, placement.uv1.x); + } + drawVideoFrame(p, frame_texture_.ref(), rect_, placement); +} + +void CameraWidget::vipcThread() { + VisionStreamType cur_stream = requested_stream_type_; + std::unique_ptr vipc_client; + VisionIpcBufExtra frame_meta = {}; + bool was_connected = false; + + while (!vipc_exit_) { + if (!vipc_client || cur_stream != requested_stream_type_) { + if (cur_stream != requested_stream_type_) clearFrames(); + cur_stream = requested_stream_type_; + vipc_client.reset(new VisionIpcClient(stream_name_, cur_stream, false)); + } + active_stream_type_ = cur_stream; + + if (!vipc_client->connected) { + // the server changed (a new route): the last frame is stale. A fresh thread keeps it, see startVipcThread(). + if (std::exchange(was_connected, false)) clearFrames(); + auto streams = VisionIpcClient::getAvailableStreams(stream_name_, false); + if (streams.empty()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + utils::runOnMainThread(utils::guarded(alive_, [this, streams]() { availableStreamsUpdated(streams); })); + + if (!vipc_client->connect(false)) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + was_connected = true; + } + + 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_.resize(buf->width, buf->height); + } + yuv::nv12_to_rgba(buf->y, buf->stride, buf->uv, buf->stride, + rgb_back_.data.data(), rgb_back_.bytesPerLine(), buf->width, buf->height); + { + std::lock_guard lk(frame_lock_); + rgb_frame_.swap(rgb_back_); + frame_updated_ = true; + } + } + } +} + +void CameraWidget::clearFrames() { + std::lock_guard lk(frame_lock_); + rgb_frame_.reset(); + rgb_back_.reset(); + frame_updated_ = false; +} diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.h b/openpilot/tools/cabana/ui/widgets/cameraview.h new file mode 100644 index 0000000000..db4e04f63c --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/cameraview.h @@ -0,0 +1,124 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "openpilot/cereal/visionstream.h" +#include "tools/cabana/core/observable.h" +#include "msgq/visionipc/visionipc_client.h" + +// OS04C10 output, also used by its 1344x760 downscaled video. +constexpr float DEFAULT_CAMERA_ASPECT_RATIO = 2688.0f / 1520.0f; + +// Center-crop the source to fill the destination without stretching. +inline ImVec2 videoFillUv(const ImVec2 &size, float aspect_ratio) { + const float ratio = size.x / size.y / aspect_ratio; + return ImVec2(0.5f * (1.0f - std::min(ratio, 1.0f)), + 0.5f * (1.0f - std::min(1.0f / ratio, 1.0f))); +} + +struct VideoPlacement { + ImVec2 min; + ImVec2 max; + ImVec2 uv0; + ImVec2 uv1; +}; + +inline VideoPlacement videoPlacement(const ImRect &rect, float source_aspect_ratio, bool crop) { + VideoPlacement placement{rect.Min, rect.Max, ImVec2(0, 0), ImVec2(1, 1)}; + if (crop) { + placement.uv0 = videoFillUv(rect.GetSize(), source_aspect_ratio); + placement.uv1 = ImVec2(1.0f - placement.uv0.x, 1.0f - placement.uv0.y); + } else { + const float widget_aspect_ratio = rect.GetWidth() / rect.GetHeight(); + const int width = std::lround(rect.GetWidth() * std::min(source_aspect_ratio / widget_aspect_ratio, 1.0f)); + const int height = std::lround(rect.GetHeight() * std::min(widget_aspect_ratio / source_aspect_ratio, 1.0f)); + placement.min = ImVec2(rect.Min.x + (int)(rect.GetWidth() - width) / 2, rect.Min.y + (int)(rect.GetHeight() - height) / 2); + placement.max = ImVec2(placement.min.x + width, placement.min.y + height); + } + return placement; +} + +void drawVideoFrame(ImDrawList *draw_list, ImTextureRef texture, const ImRect &rect, const VideoPlacement &placement); + +// tightly packed RGBA pixels +struct RgbImage { + int width = 0; + int height = 0; + std::vector data; + bool isNull() const { return data.empty(); } + void reset() { width = height = 0; data.clear(); } + void resize(int w, int h) { width = w; height = h; data.resize(size_t(w) * h * 4); } + int bytesPerLine() const { return width * 4; } + void swap(RgbImage &other) { std::swap(width, other.width); std::swap(height, other.height); data.swap(other.data); } +}; + +// GL texture holding an RgbImage. Created/uploaded/freed on the GUI thread only (the GL context is current there). +struct GlTexture { + GlTexture() = default; + GlTexture(const GlTexture &) = delete; + GlTexture &operator=(const GlTexture &) = delete; + ~GlTexture() { destroy(); } + void upload(const RgbImage &image); // (re)allocates when the size changes + void destroy(); + ImTextureRef ref() const { return ImTextureRef((ImTextureID)(uintptr_t)id); } + + unsigned int id = 0; + int width = 0; + int height = 0; + uint64_t key = 0; // caller-defined identity of the uploaded image + bool mipmap = false; // set before the first upload: a mip chain for images drawn downscaled +}; + +class CameraWidget { +public: + explicit CameraWidget(std::string stream_name, VisionStreamType stream_type); + ~CameraWidget(); + void setStreamType(VisionStreamType type) { requested_stream_type_ = type; } + void stopVipcThread(); + // draw() implies visible; the owner calls setVisible(false) when the widget is no longer drawn, which + // stops the vipc thread. + void setVisible(bool visible); + // draws an item of `size` into the current window + void draw(const ImVec2 &size); + const ImRect &rect() const { return rect_; } + float frameAspectRatio() const; + float width() const { return rect_.GetWidth(); } + float height() const { return rect_.GetHeight(); } + + Observable<> clicked; + Observable> availableStreamsUpdated; // invoked on the main thread + +private: + void paint(); + void startVipcThread(); + void vipcThread(); + void clearFrames(); + + ImU32 bg_ = IM_COL32(0, 0, 0, 255); + RgbImage rgb_frame_; // written by vipc thread, drawn by GUI thread; guarded by frame_lock_ + RgbImage rgb_back_; // vipc thread only + bool frame_updated_ = false; // rgb_frame_ changed since the last upload; guarded by frame_lock_ + GlTexture frame_texture_; // GUI thread only + ImRect rect_; + bool visible_ = false; + + std::string stream_name_; + std::atomic active_stream_type_; + std::atomic requested_stream_type_; + std::thread vipc_thread_; + std::atomic vipc_exit_ = false; + std::mutex frame_lock_; + std::shared_ptr alive_ = std::make_shared(true); +}; diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc new file mode 100644 index 0000000000..6378d2f8ca --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -0,0 +1,437 @@ +#include "tools/cabana/ui/widgets/detailwidget.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { + +bool iequals(const std::string &a, const std::string &b) { + return a.size() == b.size() && + std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) { return std::tolower((unsigned char)x) == std::tolower((unsigned char)y); }); +} + +} // namespace + +ElidedLabel::ElidedLabel(const std::string &text) : text_(utils::trimmed(text)) {} + +void ElidedLabel::draw(float width) { + ImGuiWindow *window = ImGui::GetCurrentWindow(); + const ImVec2 pos(window->DC.CursorPos.x, window->DC.CursorPos.y + window->DC.CurrLineTextBaseOffset); + const ImRect bb(pos, ImVec2(pos.x + width, pos.y + ImGui::GetTextLineHeight())); + ImGui::ItemSize(bb.GetSize(), 0.0f); + if (ImGui::ItemAdd(bb, 0)) { + ImGui::RenderTextEllipsis(window->DrawList, bb.Min, bb.Max, bb.Max.x, text_.c_str(), nullptr, nullptr); + } + if (!tooltip_.empty()) ImGui::SetItemTooltip("%s", tooltip_.c_str()); + if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + clicked(); + } +} + +DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { + tabbar_.setUsesScrollButtons(true); + tabbar_.setAutoHide(true); + tabbar_.setTabsClosable(true); + connections_.push_back(tabbar_.currentChanged.connect([this](int index) { + if (index >= 0) setMessage(MessageId::fromString(tabbar_.tabText(index))); + })); + connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { tabbar_.removeTab(index); })); + connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { showTabBarContextMenu(index); })); + heatmap_live_ = can->liveStreaming(); + binary_view_ = std::make_unique(); + signal_view_ = std::make_unique(charts); + + history_log_ = std::make_unique(); + page_tabs_.addTab("Messages"); + page_tabs_.addTab("Logs"); + connections_.push_back(page_tabs_.currentChanged.connect([this](int index) { + tab_widget_index_ = index; + if (index == 1) history_log_->onShown(); + updateState(); + })); + + connections_.push_back(binary_view_->signalsChanged.connect([this]() { refresh(); })); + connections_.push_back(binary_view_->signalHovered.connect([this](const cabana::Signal *s) { signal_view_->signalHovered(s); })); + connections_.push_back(binary_view_->signalClicked.connect([this](const cabana::Signal *s) { signal_view_->selectSignal(s, true); })); + connections_.push_back(binary_view_->editSignal.connect([this](const cabana::Signal *origin_s, cabana::Signal &s) { signal_view_->saveSignal(origin_s, s); })); + connections_.push_back(binary_view_->showChart.connect([this](const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { charts_->showChart(id, sig, show, merge); })); + connections_.push_back(signal_view_->showChart.connect([this](const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { charts_->showChart(id, sig, show, merge); })); + connections_.push_back(signal_view_->highlight.connect([this](const cabana::Signal *sig) { binary_view_->highlight(sig); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); + connections_.push_back(charts->seriesChanged.connect([this]() { signal_view_->updateChartState(); })); + connections_.push_back(can->timeRangeChanged.connect([this](const std::optional> &range) { + char text[64]; + if (range) snprintf(text, sizeof(text), "%.3f - %.3f", range->first, range->second); + heatmap_all_text_ = range ? text : "All"; + heatmap_live_ = !range && can->liveStreaming(); + binary_view_->setHeatmapLiveMode(heatmap_live_); + })); +} + +void DetailWidget::drawToolBar() { + const ImGuiStyle &style = ImGui::GetStyle(); + std::vector items; + float name_width = 0.0f; + items.push_back({0.0f, [this, &name_width]() { + ImGui::AlignTextToFramePadding(); + pushBoldFont(); + name_label_.draw(name_width); + popBoldFont(); + }}); + items.back().in_menu = false; + const size_t spacer_index = items.size(); + const std::string heatmap_text = "Heatmap: " + (!heatmap_visible_ ? std::string("Hidden") : heatmap_live_ ? std::string("Live") : heatmap_all_text_); + auto heatmap_items = [this]() { + if (dropdown::Item("Hidden", nullptr, !heatmap_visible_)) heatmap_visible_ = false; + if (dropdown::Item("Live", nullptr, heatmap_visible_ && heatmap_live_)) { + heatmap_visible_ = true; + heatmap_live_ = true; + binary_view_->setHeatmapLiveMode(true); + } + if (dropdown::Item(heatmap_all_text_.c_str(), nullptr, heatmap_visible_ && !heatmap_live_)) { + heatmap_visible_ = true; + heatmap_live_ = false; + binary_view_->setHeatmapLiveMode(false); + } + }; + items.push_back(toolbarMenu("heatmap", heatmap_text, "Heatmap", heatmap_items)); + items.push_back({1.0f, []() { ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); }}); + items.back().in_menu = false; + // Capture the panel width before the action can run inside the overflow popup. + const float panel_width = ImGui::GetWindowWidth(); + items.push_back(toolbarAction("edit_msg", icon::PENCIL, "Edit Message", [this, panel_width]() { editMsg(panel_width); })); + items.push_back(toolbarAction("remove_msg", icon::TRASH, "Remove Message", + [this]() { UndoStack::instance()->push(new RemoveMsgCommand(msg_id_)); }, action_remove_msg_enabled_)); + + const float right_width = toolbarWidth(items, spacer_index) - style.ItemSpacing.x; + name_width = std::max(ImGui::CalcTextSize("MMMMMM").x, ImGui::GetContentRegionAvail().x - right_width - style.ItemSpacing.x); + items[0].width = name_width; + drawToolbar(items, spacer_index); +} + +void DetailWidget::showTabBarContextMenu(int index) { + if (dropdown::BeginPopupContextItem()) { + if (dropdown::Item("Close Other Tabs")) { + tabbar_.moveTab(index, 0); + tabbar_.setCurrentIndex(0); + while (tabbar_.count() > 1) tabbar_.removeTab(1); + } + dropdown::EndPopup(); + } +} + +int DetailWidget::findOrAddTab(const MessageId &message_id) { + const std::string text = message_id.toString(); + int index = tabbar_.count() - 1; + for (/**/; index >= 0; --index) { + if (tabbar_.tabText(index) == text) break; + } + if (index == -1) { + index = tabbar_.addTab(text); + tabbar_.setTabToolTip(index, msgName(message_id)); + } + return index; +} + +void DetailWidget::setMessage(const MessageId &message_id) { + if (std::exchange(msg_id_, message_id) == message_id) return; + + tabbar_.setCurrentIndex(findOrAddTab(message_id)); + + signal_view_->setMessage(msg_id_); + binary_view_->setMessage(msg_id_); + history_log_->setMessage(msg_id_); + refresh(); +} + +std::pair> DetailWidget::serializeMessageIds() const { + std::vector msgs; + for (int i = 0; i < tabbar_.count(); ++i) msgs.push_back(tabbar_.tabText(i)); + return std::make_pair(msg_id_.toString(), msgs); +} + +void DetailWidget::restoreTabs(const std::string &active_msg_id, const std::vector& msg_ids) { + for (const auto& str_id : msg_ids) { + MessageId id = MessageId::fromString(str_id); + if (dbc()->msg(id) != nullptr) + findOrAddTab(id); + } + + auto active_id = MessageId::fromString(active_msg_id); + if (dbc()->msg(active_id) != nullptr) + setMessage(active_id); +} + +void DetailWidget::refresh() { + auto msg = dbc()->msg(msg_id_); + std::string msg_name = msg ? msg->name + " (" + msg->transmitter + ")" : msgName(msg_id_); + name_label_.setText(msg_name); + name_label_.setToolTip(msg_name); + action_remove_msg_enabled_ = msg != nullptr; + updateWarnings(); +} + +void DetailWidget::updateWarnings() { + std::vector warnings; + auto msg = dbc()->msg(msg_id_); + if (msg) { + // A restored tab or a seek can refer to a bus with no sample at the current time. + // lastMessage() returns empty data in that case, not a received zero-byte frame. + if (msg_id_.source == INVALID_SOURCE || !can->lastMessages().count(msg_id_)) { + warnings.push_back("No messages received."); + } else if (msg->size != can->lastMessage(msg_id_).dat.size()) { + warnings.push_back("Message size mismatch: DBC defines " + std::to_string(msg->size) + + " bytes, received " + std::to_string(can->lastMessage(msg_id_).dat.size()) + " bytes."); + } + for (auto s : binary_view_->getOverlappingSignals()) { + warnings.push_back(s->name + " has overlapping bits."); + } + } + if (!warnings.empty()) { + warning_label_.clear(); + for (size_t i = 0; i < warnings.size(); ++i) { + if (i) warning_label_ += '\n'; + warning_label_ += warnings[i]; + } + warning_icon_ = msg ? icon::EXCLAMATION_TRIANGLE : icon::INFO_CIRCLE; + } + warning_widget_visible_ = !warnings.empty(); +} + +void DetailWidget::updateState(const std::set *msgs) { + if ((msgs && !msgs->count(msg_id_))) + return; + + if (tab_widget_index_ == 0) + binary_view_->updateState(); + else + history_log_->updateState(); + updateWarnings(); +} + +void DetailWidget::editMsg(float parent_width) { + auto msg = dbc()->msg(msg_id_); + int size = msg ? msg->size : can->lastMessage(msg_id_).dat.size(); + edit_dlg_ = std::make_unique(msg_id_, msgName(msg_id_), size, parent_width); +} + +void DetailWidget::drawTabWidget() { + const ImGuiStyle &style = ImGui::GetStyle(); + page_tabs_.draw(); + if (tab_widget_index_ == 0) { + if (heatmap_visible_) { + // Allow the heatmap to grow while reserving room for signal rows. + const float handle_height = style.DockingSeparatorSize; + const float avail = ImGui::GetContentRegionAvail().y; + const float max_height = std::max(avail - handle_height - style.ItemSpacing.y * 2 - SignalView::minimumHeight(), 1.0f); + const float min_height = std::min(ImGui::GetFrameHeight(), max_height); + float height = std::clamp(heatmap_height_ < 0 ? binary_view_->minimumSizeHint().y : heatmap_height_, min_height, max_height); + ImGui::BeginChild("binary_view", ImVec2(0, height), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); + binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); + binary_view_->draw(); + ImGui::EndChild(); + + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const ImVec2 size(std::max(ImGui::GetContentRegionAvail().x, 1.0f), handle_height); + const ImRect splitter(pos, ImVec2(pos.x + size.x, pos.y + size.y)); + float signal_height = std::max(avail - height - handle_height - style.ItemSpacing.y * 2, SignalView::minimumHeight()); + if (ImGui::SplitterBehavior(splitter, ImGui::GetID("##heatmap_resize"), ImGuiAxis_Y, + &height, &signal_height, min_height, SignalView::minimumHeight(), 4.0f)) { + heatmap_height_ = height; + } + ImGui::Dummy(size); + } + const float signal_height = std::max(ImGui::GetContentRegionAvail().y, SignalView::minimumHeight()); + ImGui::BeginChild("signal_view", ImVec2(0, signal_height), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); + signal_view_->draw(); + ImGui::EndChild(); + } else { + history_log_->draw(); + } +} + +void DetailWidget::draw() { + tabbar_.draw(); + ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding); + drawToolBar(); + + if (warning_widget_visible_) { + ImGui::TextUnformatted(warning_icon_); + ImGui::SameLine(); + ImGui::TextUnformatted(warning_label_.c_str()); + } + + drawTabWidget(); + ImGui::EndChild(); + + if (edit_dlg_ && !edit_dlg_->draw()) { + if (edit_dlg_->accepted()) { + const auto r = edit_dlg_->result(); + UndoStack::instance()->push(new EditMsgCommand(r.msg_id, r.name, r.size, r.node, r.comment)); + } + edit_dlg_.reset(); + } +} + +// HelpOverlay: the whatsThis text and last drawn rect of the binary view and the signal view +std::vector> DetailWidget::helpRects() const { + std::vector> rects; + if (tab_widget_index_ == 0) { + if (heatmap_visible_) rects.emplace_back(binary_view_->whatsThis(), binary_view_rect_); + rects.emplace_back(signal_view_->whatsThis(), signal_view_rect_); + } + return rects; +} + +EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const std::string &title, int size, float parent_width) + : msg_id_(msg_id), original_name_(title), name_edit_(title), size_spin_(size), width_(parent_width * 0.9f) { + window_title_ = "Edit Message: " + msg_id.toString(); + + if (auto msg = dbc()->msg(msg_id)) { + node_ = msg->transmitter; + comment_edit_ = msg->comment; + } + validateName(name_edit_); +} + +EditMessageDialog::Result EditMessageDialog::result() const { + return {msg_id_, utils::trimmed(name_edit_), utils::trimmed(node_), utils::trimmed(comment_edit_), size_spin_}; +} + +bool EditMessageDialog::draw() { + if (closed_) return false; + if (!opened_) { + ImGui::OpenPopup(window_title_.c_str()); + opened_ = true; + } + // The form needs room for message names and comments even when its panel is narrow. + const float max_width = std::max(1.0f, ImGui::GetMainViewport()->WorkSize.x - ImGui::GetStyle().WindowPadding.x * 2); + const float min_width = std::min(600.0f, max_width); + ImGui::SetNextWindowSizeConstraints(ImVec2(min_width, 0.0f), ImVec2(max_width, FLT_MAX)); + setNextDialogWindow(ImVec2(std::clamp(width_, min_width, max_width), 0.0f)); + bool open = true; + if (ImGui::BeginPopupModal(window_title_.c_str(), &open)) { + const float label_width = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Comment").x + ImGui::GetStyle().ItemSpacing.x; + auto row = [&](const char *label) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + ImGui::SameLine(label_width); + ImGui::SetNextItemWidth(-FLT_MIN); + }; + + if (!error_label_.empty()) { + row(""); + ImGui::TextUnformatted(error_label_.c_str()); + } + row("Name"); + if (validatedInput("##name", &name_edit_, nameValidator)) { + validateName(name_edit_); + } + + row("Size"); + if (inputInt("##size", &size_spin_)) size_spin_ = std::clamp(size_spin_, 1, CAN_MAX_DATA_BYTES); + + row("Node"); + validatedInput("##node", &node_, nameValidator); + row("Comment"); + inputTextMultiline("##comment", &comment_edit_, ImVec2(-FLT_MIN, 192.0f)); + const bool comment_active = ImGui::IsItemActive(); + + bool accept = false, reject = false; + if (dialogButtons("OK", &accept, &reject, ok_enabled_)) { + accepted_ = accept; + closed_ = true; + } + // Enter triggers the default (OK) button + if (!closed_ && ok_enabled_ && !comment_active && ImGui::IsKeyPressed(ImGuiKey_Enter, false)) { + accepted_ = true; + closed_ = true; + } + if (!open) closed_ = true; + if (closed_) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } else { + closed_ = true; // closed from outside + } + return !closed_; +} + +void EditMessageDialog::validateName(const std::string &text) { + bool valid = !iequals(text, UNTITLED); + error_label_.clear(); + if (!text.empty() && valid && text != original_name_) { + valid = dbc()->msg(msg_id_.source, text) == nullptr; + if (!valid) error_label_ = "Name already exists"; + } + ok_enabled_ = valid; +} + +DetailWidget* CenterWidget::ensureDetailWidget() { + if (!detail_widget) { + detail_widget = std::make_unique(charts_); + } + return detail_widget.get(); +} + +void CenterWidget::clear() { + detail_widget.reset(); + charts_ = nullptr; // MainWindow recreates the ChartsWidget after startStream +} + +void CenterWidget::draw() { + if (detail_widget) { + detail_widget->draw(); + } else { + drawWelcomeWidget(); + } +} + +void CenterWidget::drawWelcomeWidget() { + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const ImVec2 origin = ImGui::GetCursorPos(); + auto centered = [&](const char *text, float y) { + const ImVec2 size = ImGui::CalcTextSize(text); + ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - size.x) * 0.5f, y)); + ImGui::TextUnformatted(text); + }; + ImGui::PushStyleColor(ImGuiCol_Text, palette().text_disabled); + float y = origin.y + avail.y * 0.5f - 90.0f; + pushLargeFont(); + centered("CABANA", y); + y += ImGui::GetTextLineHeightWithSpacing(); + popLargeFont(); + + auto newShortcutRow = [&](const char *title, const char *key) { + const float w = ImGui::CalcTextSize(title).x + ImGui::CalcTextSize(key).x + 40.0f; + ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - w) * 0.5f, y)); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(title); + ImGui::SameLine(); + ImGui::BeginDisabled(); + ImGui::SmallButton(key); + ImGui::EndDisabled(); + y += ImGui::GetFrameHeightWithSpacing(); + }; + + centered("<- Select a message to view details", y); + y += ImGui::GetTextLineHeightWithSpacing(); + newShortcutRow("Pause", "Space"); + newShortcutRow("Help", "F1"); + newShortcutRow("What's This?", "Shift+F1"); + ImGui::PopStyleColor(); +} diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.h b/openpilot/tools/cabana/ui/widgets/detailwidget.h new file mode 100644 index 0000000000..f752488ee1 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.h @@ -0,0 +1,116 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "tools/cabana/ui/widgets/binaryview.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/widgets/historylog.h" +#include "tools/cabana/ui/widgets/signalview.h" +#include "tools/cabana/ui/widgets/tabbar.h" + +// a label that elides its text to the available width +class ElidedLabel { +public: + explicit ElidedLabel(const std::string &text = {}); + void setText(const std::string &text) { text_ = text; } + void setToolTip(const std::string &tip) { tooltip_ = tip; } + void draw(float width); + + Observable<> clicked; + +private: + std::string text_, tooltip_; +}; + +// modal, non-blocking: DetailWidget::draw() polls draw() and applies the result once it returns false +class EditMessageDialog { +public: + struct Result { + MessageId msg_id; + std::string name, node, comment; // trimmed + int size; + }; + + EditMessageDialog(const MessageId &msg_id, const std::string &title, int size, float parent_width); + bool draw(); // false once closed + bool accepted() const { return accepted_; } + Result result() const; + +private: + void validateName(const std::string &text); + + MessageId msg_id_; + std::string original_name_; + std::string name_edit_; + std::string node_; + std::string comment_edit_; + std::string error_label_; // empty when the name is valid + int size_spin_; + bool ok_enabled_ = true; + std::string window_title_; + float width_; + bool opened_ = false; + bool accepted_ = false; + bool closed_ = false; +}; + +class DetailWidget { +public: + DetailWidget(ChartsWidget *charts); + void setMessage(const MessageId &message_id); + void refresh(); + void draw(); // tab bar of message ids, toolbar, warning, Messages/Logs tabs + std::pair> serializeMessageIds() const; + void restoreTabs(const std::string &active_msg_id, const std::vector &msg_ids); + std::vector> helpRects() const; // HelpOverlay: (whatsThis, rect) of the binary and signal views + +private: + void drawToolBar(); + void drawTabWidget(); + int findOrAddTab(const MessageId& message_id); + void showTabBarContextMenu(int index); + void editMsg(float parent_width); + void updateState(const std::set *msgs = nullptr); + void updateWarnings(); + + MessageId msg_id_; + const char *warning_icon_ = nullptr; + std::string warning_label_; + ElidedLabel name_label_; + bool warning_widget_visible_ = false; + TabBar tabbar_; + TabBar page_tabs_; + int tab_widget_index_ = 0; + bool action_remove_msg_enabled_ = false; + bool heatmap_live_ = true; + bool heatmap_visible_ = true; + float heatmap_height_ = -1.0f; + std::string heatmap_all_text_ = "All"; + ImRect binary_view_rect_, signal_view_rect_; // child window rects of the last drawTabWidget + std::unique_ptr history_log_; + std::unique_ptr binary_view_; + std::unique_ptr signal_view_; + ChartsWidget *charts_; + std::unique_ptr edit_dlg_; + Connections connections_; +}; + +class CenterWidget { +public: + CenterWidget() = default; + void setChartsWidget(ChartsWidget *charts) { charts_ = charts; } + void setMessage(const MessageId &message_id) { ensureDetailWidget()->setMessage(message_id); } + DetailWidget* getDetailWidget() { return detail_widget.get(); } + DetailWidget* ensureDetailWidget(); + void clear(); + void draw(); // the welcome widget until a message is selected, then the DetailWidget + +private: + void drawWelcomeWidget(); + std::unique_ptr detail_widget; + ChartsWidget *charts_ = nullptr; +}; diff --git a/openpilot/tools/cabana/ui/widgets/historylog.cc b/openpilot/tools/cabana/ui/widgets/historylog.cc new file mode 100644 index 0000000000..92ed043ea7 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/historylog.cc @@ -0,0 +1,311 @@ +#include "tools/cabana/ui/widgets/historylog.h" + +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/widgets/messagebytes.h" +#include "tools/cabana/utils/export.h" +#include "tools/cabana/utils/strings.h" + +namespace { + +constexpr int BATCH_SIZE = 50; +constexpr float DISPLAY_TYPE_WIDTH = 90.0f; +constexpr float SIGNALS_WIDTH = 160.0f; +constexpr float COMPARE_WIDTH = 50.0f; + +std::string formatTime(uint64_t mono_time) { + char buf[32] = {}; + snprintf(buf, sizeof(buf), "%.2f", can->toSeconds(mono_time)); + return buf; +} + +} // namespace + +LogsWidget::LogsWidget() { + connections_.push_back(can->seekedTo.connect([this](double) { reset(); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { reset(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { reset(); })); +} + +void LogsWidget::setMessage(const MessageId &message_id) { + msg_id_ = message_id; + reset(); +} + +void LogsWidget::reset() { + sigs_.clear(); + if (auto dbc_msg = dbc()->msg(msg_id_)) sigs_ = dbc_msg->getSignals(); + messages_.clear(); + hex_colors_ = {}; + signals_cb_ = comp_box_ = 0; + value_edit_.clear(); + value_edit_modified_ = false; + export_btn_enabled_ = false; + selected_row_ = selected_col_ = -1; + setFilter(0, "", nullptr); +} + +void LogsWidget::setFilter(int sig_idx, const std::string &value, std::function cmp) { + filter_sig_idx_ = sig_idx; + filter_value_ = utils::toDouble(value); + filter_cmp_ = value.empty() ? nullptr : cmp; + load(true); +} + +void LogsWidget::load(bool clear) { + if (clear && !messages_.empty()) { + messages_.clear(); + selected_row_ = selected_col_ = -1; + } + const uint64_t current_time = can->toMonoTime(can->lastMessage(msg_id_).ts) + 1; + fetch(messages_.begin(), current_time, messages_.empty() ? 0 : messages_.front().mono_time); +} + +bool LogsWidget::canFetchMore() const { + const auto &events = can->events(msg_id_); + return !events.empty() && !messages_.empty() && messages_.back().mono_time > events.front()->mono_time; +} + +void LogsWidget::fetch(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time) { + const auto &events = can->events(msg_id_); + auto first = std::upper_bound(events.rbegin(), events.rend(), from_time, [](uint64_t ts, auto e) { return ts > e->mono_time; }); + + std::vector msgs; + std::vector values(sigs_.size()); + msgs.reserve(BATCH_SIZE); + for (; first != events.rend() && (*first)->mono_time > min_time; ++first) { + const CanEvent *e = *first; + for (int i = 0; i < sigs_.size(); ++i) { + sigs_[i]->getValue(e->dat, e->size, &values[i]); + } + if (!filter_cmp_ || filter_cmp_(values[filter_sig_idx_], filter_value_)) { + msgs.emplace_back(Message{e->mono_time, values, {e->dat, e->dat + e->size}}); + if (msgs.size() >= BATCH_SIZE && min_time == 0) break; + } + } + if (msgs.empty()) return; + + if (hexMode() && (min_time > 0 || messages_.empty())) { + const auto freq = can->lastMessage(msg_id_).freq; + const std::vector no_mask; + for (auto &m : msgs) { + hex_colors_.compute(msg_id_, m.data.data(), m.data.size(), m.mono_time / (double)1e9, can->getSpeed(), no_mask, freq); + m.colors = hex_colors_.colors; + } + } + const int pos = std::distance(messages_.begin(), insert_pos); + messages_.insert(insert_pos, std::move_iterator(msgs.begin()), std::move_iterator(msgs.end())); + export_btn_enabled_ = true; + // the selection follows the message it was made on + if (selected_row_ >= pos) selected_row_ += msgs.size(); +} + +void LogsWidget::filterChanged() { + if (value_edit_.empty() && !value_edit_modified_) return; + + std::function cmp = nullptr; + switch (comp_box_) { + case 0: cmp = std::greater{}; break; + case 1: cmp = std::equal_to{}; break; + case 2: cmp = [](double l, double r) { return l != r; }; break; + case 3: cmp = std::less{}; break; + } + setFilter(signals_cb_, value_edit_, cmp); +} + +void LogsWidget::exportToCSV() { + std::string dir = settings.last_dir + "/" + can->routeName() + "_" + msgName(msg_id_) + ".csv"; + FileDialog::getSaveFileName("Export " + msgName(msg_id_) + " to CSV file", dir, ".csv", [this](const std::string &fn) { + if (!fn.empty()) { + hexMode() ? utils::exportToCSV(fn, msg_id_) : utils::exportSignalsToCSV(fn, msg_id_); + } + }); +} + +void LogsWidget::draw() { + const ImGuiStyle &style = ImGui::GetStyle(); + + beginControlChild("toolbar", ImVec2(0, ImGui::GetFrameHeight() + CONTROL_OUTLINE_PADDING * 2), + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + + // toolbar: the export button is right aligned and never clipped, the value input shrinks first + const float export_w = iconButtonWidth(); + if (!sigs_.empty()) { + const float fixed = DISPLAY_TYPE_WIDTH + SIGNALS_WIDTH + COMPARE_WIDTH + style.ItemSpacing.x * 4 + export_w; + const float value_w = std::clamp(ImGui::GetContentRegionAvail().x - fixed, 30.0f, 120.0f); + + ImGui::SetNextItemWidth(DISPLAY_TYPE_WIDTH); + if (dropdown::Combo("##display_type", &display_type_cb_, "Signal\0Hex\0")) { + hex_mode_ = display_type_cb_; + reset(); + } + ImGui::SetItemTooltip("Display signal value or raw hex value"); + ImGui::SameLine(); + std::string sig_items; + for (auto s : sigs_) { + sig_items += s->name; + sig_items += '\0'; + } + sig_items += '\0'; + ImGui::SetNextItemWidth(SIGNALS_WIDTH); + if (dropdown::Combo("##signals", &signals_cb_, sig_items.c_str())) filterChanged(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(COMPARE_WIDTH); + if (dropdown::Combo("##comp", &comp_box_, ">\0=\0!=\0<\0")) filterChanged(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(value_w); + if (clearableInput("##value", &value_edit_, "", doubleValidator)) { + value_edit_modified_ = true; // clearing the field still counts as modified + filterChanged(); + } + } + alignRight(iconButtonWidth()); + ImGui::BeginDisabled(!export_btn_enabled_); + if (iconButton("export_csv", icon::FILETYPE_CSV)) exportToCSV(); + ImGui::EndDisabled(); + disabledItemTooltip("Export to CSV file..."); + ImGui::EndChild(); + + ImGui::Separator(); + drawTable(); +} + +std::string LogsWidget::headerText(int column) const { + if (column == 0) return "Time"; + if (hexMode()) return "Data"; + std::string text = sigs_[column - 1]->name; + if (!sigs_[column - 1]->unit.empty()) text += " (" + sigs_[column - 1]->unit + ")"; + std::replace(text.begin(), text.end(), '_', ' '); + return text; +} + +ImVec2 LogsWidget::headerSize(int column, float viewport_width) const { + const ImVec2 time_text_size = ImGui::CalcTextSize("000000.00"); + const ImVec2 time_col_size(time_text_size.x + 10, time_text_size.y + 6); + if (column == 0) return time_col_size; + const int default_size = std::max(100, (int)((viewport_width - time_col_size.x) / (columnCount() - 1))); + const ImVec2 rect = ImGui::CalcTextSize(headerText(column).c_str(), nullptr, false, default_size); + return ImVec2{std::max(rect.x + 10, (float)default_size), rect.y + 6}; +} + +void LogsWidget::drawHeaderCell(ImDrawList *dl, const ImRect &rect, int column) const { + if (column > 0 && !hexMode()) { + CabanaColor bg = sigs_[column - 1]->color; + bg.a = 128; + dl->AddRectFilled(rect.Min, rect.Max, toImU32(bg), ImGui::GetStyle().FrameRounding); + } + const std::string text = headerText(column); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); + // right aligned and word wrapped, one line at a time + const ImRect r(rect.Min.x + 5, rect.Min.y + 3, rect.Max.x - 5, rect.Max.y - 3); + ImFont *font = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + const float wrap_width = std::max(1.0f, r.GetWidth()); + const char *s = text.c_str(); + const char *end = s + text.size(); + float y = r.Min.y; + dl->PushClipRect(rect.Min, rect.Max, true); + while (s < end) { + const char *line_end = font->CalcWordWrapPosition(font_size, s, end, wrap_width); + if (line_end == s) line_end = s + 1; + const float w = ImGui::CalcTextSize(s, line_end).x; + dl->AddText(font, font_size, ImVec2(r.Max.x - w, y), color, s, line_end); + y += ImGui::GetTextLineHeight(); + s = line_end; + while (s < end && ImCharIsBlankA(*s)) s++; + if (s < end && *s == '\n') s++; + } + dl->PopClipRect(); +} + +void LogsWidget::drawTable() { + const ImGuiStyle &style = ImGui::GetStyle(); + const int cols = columnCount(); + // the header viewport excludes the table's cell padding and the vertical scrollbar (the latter is one + // frame behind, it is only known once shown) + const float header_width = ImGui::GetContentRegionAvail().x - style.CellPadding.x * 2 * cols - + (vscrollbar_visible_ ? style.ScrollbarSize : 0.0f); + + std::vector sizes(cols); + float header_height = 0; + for (int i = 0; i < cols; ++i) { + sizes[i] = headerSize(i, header_width); + header_height = std::max(header_height, sizes[i].y); + } + if (hexMode() && !messages_.empty()) { + sizes[1].x = std::max(sizes[1].x, bytesCellSize(messages_.front().data.size(), false).x); + } + const float row_height = bytesCellSize(8, false).y; + + // fixed section sizes and a horizontal scrollbar, no alternating row colors; the grid is drawn between + // rows and columns + ImGuiTableFlags flags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_ScrollX | ImGuiTableFlags_Borders | + ImGuiTableFlags_SizingFixedFit; + // an empty viewport draws no grid + if (messages_.empty()) flags &= ~ImGuiTableFlags_BordersInnerV; + // the sum of the fixed section sizes, so the columns keep their size and the table scrolls + float inner_width = 0; + for (int i = 0; i < cols; ++i) inner_width += sizes[i].x + style.CellPadding.x * 2; + + bool fetch_more = false; + if (ImGui::BeginTable("logs", cols, flags, ImVec2(0, 0), inner_width)) { + ImGui::TableSetupScrollFreeze(0, 1); + for (int i = 0; i < cols; ++i) { + ImGui::TableSetupColumn(headerText(i).c_str(), ImGuiTableColumnFlags_WidthFixed, sizes[i].x); + } + ImGuiTable *table = ImGui::GetCurrentTable(); + ImDrawList *painter = ImGui::GetWindowDrawList(); + + ImGui::TableNextRow(ImGuiTableRowFlags_Headers, header_height); + for (int i = 0; i < cols; ++i) { + if (!ImGui::TableSetColumnIndex(i)) continue; + drawHeaderCell(painter, ImGui::TableGetCellBgRect(table, i), i); + ImGui::Dummy(ImVec2(0, header_height - style.CellPadding.y * 2)); + } + + ImGuiListClipper clipper; + clipper.Begin(messages_.size(), row_height); + while (clipper.Step()) { + for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) { + const auto &m = messages_[row]; + ImGui::TableNextRow(0, row_height); + // rows are prepended while the stream plays, so the id is the message, not the row index + ImGui::PushID((void *)(uintptr_t)m.mono_time); + for (int col = 0; col < cols; ++col) { + if (!ImGui::TableSetColumnIndex(col)) continue; + // cells are selected, not rows; there is no hover highlight, only the selection background + const bool cell_selected = selected_row_ == row && selected_col_ == col; + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const ImRect rect(pos, ImVec2(pos.x + ImGui::GetContentRegionAvail().x, pos.y + row_height - style.CellPadding.y * 2)); + ImGui::PushID(col); + if (viewSelectable("##cell", cell_selected, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, rect.GetHeight()))) { + selected_row_ = row; + selected_col_ = col; + } + ImGui::PopID(); + if (col == 0) { + drawTextCell(painter, rect, formatTime(m.mono_time), cell_selected, false); + } else if (hexMode()) { + drawBytesCell(painter, rect, m.data, &m.colors, cell_selected, false, false); + } else { + drawTextCell(painter, rect, sigs_[col - 1]->formatValue(m.sig_values[col - 1], false), cell_selected, false); + } + } + ImGui::PopID(); + } + // fetch more when the last row is visible or the scrollbar is at its maximum + if (clipper.DisplayEnd >= (int)messages_.size()) fetch_more = true; + } + if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) fetch_more = true; + vscrollbar_visible_ = table->InnerWindow->ScrollbarY; + ImGui::EndTable(); + } + if (fetch_more && canFetchMore()) fetch(messages_.end(), messages_.back().mono_time, 0); +} diff --git a/openpilot/tools/cabana/ui/widgets/historylog.h b/openpilot/tools/cabana/ui/widgets/historylog.h new file mode 100644 index 0000000000..d8e68a4829 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/historylog.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + +// the Logs tab: the messages of one id, newest first, as signal values or hex bytes +class LogsWidget { +public: + LogsWidget(); + void setMessage(const MessageId &message_id); + void updateState() { load(false); } // appends what arrived since the last call + void onShown() { load(true); } // reloads the log when the Logs tab becomes visible + void draw(); + +private: + struct Message { + uint64_t mono_time = 0; + std::vector sig_values; + std::vector data; + std::vector colors; + }; + + bool hexMode() const { return sigs_.empty() || hex_mode_; } + int columnCount() const { return hexMode() ? 2 : (int)sigs_.size() + 1; } + void reset(); + void setFilter(int sig_idx, const std::string &value, std::function cmp); + void load(bool clear); + bool canFetchMore() const; + void fetch(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time); + void filterChanged(); + void exportToCSV(); + void drawTable(); + std::string headerText(int column) const; + ImVec2 headerSize(int column, float viewport_width) const; + void drawHeaderCell(ImDrawList *dl, const ImRect &rect, int column) const; + + MessageId msg_id_; + std::vector sigs_; + std::deque messages_; + CanData hex_colors_; + bool hex_mode_ = false; + int filter_sig_idx_ = -1; + double filter_value_ = 0; + std::function filter_cmp_; + int signals_cb_ = 0, comp_box_ = 0, display_type_cb_ = 0; // current combo box indices + std::string value_edit_; + bool value_edit_modified_ = false; + bool export_btn_enabled_ = false; + int selected_row_ = -1, selected_col_ = -1; + bool vscrollbar_visible_ = false; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.cc b/openpilot/tools/cabana/ui/widgets/messagebytes.cc new file mode 100644 index 0000000000..fb66726b70 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.cc @@ -0,0 +1,60 @@ +#include "tools/cabana/ui/widgets/messagebytes.h" + +#include +#include + +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +ImVec2 byteCellSize() { + pushMonoFont(); + // the width of "00 " by the line height (ascent + descent + 1), not the font size + const ImFontBaked *baked = ImGui::GetFontBaked(); + const ImVec2 size(ImGui::CalcTextSize("00 ").x, std::ceil(baked->Ascent) - std::floor(baked->Descent) + 1 + 2); + popMonoFont(); + return size; +} + +ImVec2 bytesCellSize(int n, bool multiple_lines) { + const ImVec2 byte_size = byteCellSize(); + const int rows = multiple_lines ? std::max(1, (n + 7) / 8) : 1; + const int columns = multiple_lines ? std::min(n, 8) : n; + const ImVec2 margin = ImGui::GetStyle().CellPadding; // the margin is one more than the table's cell padding + return {columns * byte_size.x + (margin.x + 1) * 2, rows * byte_size.y + (margin.y + 1) * 2}; +} + +ImU32 cellTextColor(bool selected, bool inactive) { + if (selected) { + const ImU32 text = ImGui::GetColorU32(palette().text_selected); + return text; + } + return ImGui::GetColorU32(inactive ? palette().text_inactive : palette().text); +} + +void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive, bool align_right) { + drawElidedText(dl, rect, text, cellTextColor(selected, inactive), align_right); +} + +void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vector &bytes, const std::vector *colors, + bool selected, bool inactive, bool multiple_lines) { + const ImU32 text_pen = cellTextColor(selected, inactive); + const ImVec2 byte_size = byteCellSize(); + pushMonoFont(); + ImFont *font = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + for (int i = 0; i < (int)bytes.size(); ++i) { + const int row = multiple_lines ? i / 8 : 0; + const int column = multiple_lines ? i % 8 : i; + const ImVec2 min(rect.Min.x + column * byte_size.x, rect.Min.y + row * byte_size.y); + const ImRect r(min, ImVec2(min.x + byte_size.x, min.y + byte_size.y)); + + ImU32 pen = text_pen; + if (colors && i < (int)colors->size() && (*colors)[i].alpha() > 0) { + // Activity backgrounds use the theme's text, including stale values. + pen = ImGui::GetColorU32(ImGuiCol_Text); + dl->AddRectFilled(r.Min, r.Max, toImU32(byteColor((*colors)[i]))); + } + drawText(dl, r, utils::hexByte(bytes[i]), pen, font, font_size); + } + popMonoFont(); +} diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.h b/openpilot/tools/cabana/ui/widgets/messagebytes.h new file mode 100644 index 0000000000..163a5805ae --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/color.h" + +// the cells of the messages and history log tables. Only valid inside a frame: the sizes come from the +// mono font. + +ImVec2 byteCellSize(); // one "00 " cell +ImVec2 bytesCellSize(int n, bool multiple_lines); // a cell of n bytes, the table's cell padding included +ImU32 cellTextColor(bool selected, bool inactive); // inactive rows gray the text and fade the highlighted text + +void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive, bool align_right = false); +void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vector &bytes, const std::vector *colors, + bool selected, bool inactive, bool multiple_lines); diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc new file mode 100644 index 0000000000..e648113526 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -0,0 +1,518 @@ +#include "tools/cabana/ui/widgets/messageswidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui_internal.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/widgets/messagebytes.h" +#include "tools/cabana/utils/strings.h" + +namespace { + +const char *COLUMN_TITLES[MessageList::COLUMN_COUNT] = {"Name", "Bus", "ID", "Node", "Freq", "Count", "Bytes"}; +constexpr float DEFAULT_SECTION_SIZE = 100.0f; + +// surrounding whitespace is ignored; no sign, optional 0x prefix for hex +unsigned int toUInt(const std::string &s, bool *ok, int base) { + const char *b = s.data(), *e = b + s.size(); + while (b < e && std::isspace((unsigned char)*b)) ++b; + while (e > b && std::isspace((unsigned char)e[-1])) --e; + if (base == 16 && e - b >= 2 && b[0] == '0' && (b[1] == 'x' || b[1] == 'X')) b += 2; + unsigned int v = 0; + auto [p, ec] = std::from_chars(b, e, v, base); + *ok = b < e && p == e && ec == std::errc(); + return *ok ? v : 0; +} + +bool parseRange(const std::string &filter, uint32_t value, int base = 10) { + // parse the filter string into a range: "1" -> {1, 1}, "1-3" -> {1, 3}, "1-" -> {1, inf} + unsigned int min = std::numeric_limits::min(); + unsigned int max = std::numeric_limits::max(); + auto s = utils::split(filter, '-'); + bool ok = s.size() >= 1 && s.size() <= 2; + if (ok && !s[0].empty()) min = toUInt(s[0], &ok, base); + if (ok && s.size() == 1) { + max = min; + } else if (ok && s.size() == 2 && !s[1].empty()) { + max = toUInt(s[1], &ok, base); + } + return ok && value >= min && value <= max; +} + +// imgui draws an up arrow for ImGuiSortDirection_Ascending, cabana wants a down pointing one. Feed imgui +// the opposite direction and flip it back before it reaches the list. +inline ImGuiSortDirection flipSortDirection(ImGuiSortDirection dir) { + return dir == ImGuiSortDirection_Ascending ? ImGuiSortDirection_Descending : ImGuiSortDirection_Ascending; +} + +std::string formatFreq(float freq) { + if (freq <= 0) return "--"; + char buf[32]; + snprintf(buf, sizeof(buf), freq >= 0.95 ? "%.0f" : "%.2f", freq >= 0.95 ? std::nearbyint(freq) : freq); + return buf; +} + +// the text of a cell; the DATA cell paints the bytes itself +std::string cellText(const MessageList::Item &item, int column) { + const bool seen = item.id.source != INVALID_SOURCE; + switch (column) { + case MessageList::NAME: return item.name; + case MessageList::SOURCE: return seen ? std::to_string(item.id.source) : "N/A"; + case MessageList::ADDRESS: return utils::toHexString(item.id.address); + case MessageList::NODE: return item.node; + case MessageList::FREQ: return seen ? formatFreq(can->lastMessage(item.id).freq) : "N/A"; + case MessageList::COUNT: return seen ? std::to_string(can->lastMessage(item.id).count) : "N/A"; + case MessageList::DATA: return seen ? "" : "N/A"; + } + return {}; +} + +} // namespace + +// MessageList + +MessageList::MessageList() { + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool has_new_ids) { msgsReceived(msgs, has_new_ids); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { dbcModified(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { dbcModified(); })); +} + +void MessageList::setFilters(const std::map &filters) { + filters_ = filters; + filterAndSort(); +} + +void MessageList::showInactiveMessages(bool show) { + show_inactive_messages = show; + filterAndSort(); +} + +void MessageList::dbcModified() { + dbc_messages_.clear(); + for (const auto &[_, m] : dbc()->getMessages(-1)) { + dbc_messages_.insert(MessageId{.source = INVALID_SOURCE, .address = m.address}); + } + filterAndSort(); +} + +void MessageList::sortItems(std::vector &list) { + auto compare = [this](const auto &l, const auto &r) { + switch (sort_column_) { + case NAME: return std::tie(l.name, l.id) < std::tie(r.name, r.id); + case SOURCE: return std::tie(l.id.source, l.id.address) < std::tie(r.id.source, r.id.address); + case ADDRESS: return std::tie(l.id.address, l.id.source) < std::tie(r.id.address, r.id.source); + case NODE: return std::tie(l.node, l.id) < std::tie(r.node, r.id); + case FREQ: return std::tie(can->lastMessage(l.id).freq, l.id) < std::tie(can->lastMessage(r.id).freq, r.id); + case COUNT: return std::tie(can->lastMessage(l.id).count, l.id) < std::tie(can->lastMessage(r.id).count, r.id); + default: return false; + } + }; + + if (sort_order_ == ImGuiSortDirection_Descending) + std::stable_sort(list.rbegin(), list.rend(), compare); + else + std::stable_sort(list.begin(), list.end(), compare); +} + +bool MessageList::match(const Item &item) { + 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 std::string &txt = it->second; + switch (it->first) { + case NAME: { + match = utils::containsCI(item.name, txt); + if (!match) { + const auto m = dbc()->msg(item.id); + match = m && std::any_of(m->sigs.cbegin(), m->sigs.cend(), + [&txt](const auto &s) { return utils::containsCI(s->name, txt); }); + } + break; + } + case SOURCE: + match = parseRange(txt, item.id.source); + break; + case ADDRESS: + match = utils::containsCI(utils::toHexString(item.id.address), txt); + match = match || parseRange(txt, item.id.address, 16); + break; + case NODE: + match = utils::containsCI(item.node, txt); + break; + case FREQ: + match = parseRange(txt, data.freq); + break; + case COUNT: + match = parseRange(txt, data.count); + break; + case DATA: + match = utils::containsCI(utils::toHex(data.dat), txt); + break; + } + } + return match; +} + +bool MessageList::filterAndSort() { + // merge CAN and DBC messages + std::vector all_messages; + all_messages.reserve(can->lastMessages().size() + dbc_messages_.size()); + auto dbc_msgs = dbc_messages_; + for (const auto &[id, m] : can->lastMessages()) { + all_messages.push_back(id); + dbc_msgs.erase(MessageId{.source = INVALID_SOURCE, .address = id.address}); + } + all_messages.insert(all_messages.end(), dbc_msgs.begin(), dbc_msgs.end()); + + std::vector new_items; + new_items.reserve(all_messages.size()); + for (const auto &id : all_messages) { + if (show_inactive_messages || can->isMessageActive(id)) { + auto msg = dbc()->msg(id); + Item item = {.id = id, .name = msg ? msg->name : UNTITLED, .node = msg ? msg->transmitter : std::string()}; + if (match(item)) new_items.emplace_back(item); + } + } + sortItems(new_items); + + if (items != new_items) { + items = std::move(new_items); + changed(); + return true; + } + return false; +} + +void MessageList::msgsReceived(const std::set *new_msgs, bool has_new_ids) { + if (has_new_ids || ((filters_.count(FREQ) || filters_.count(COUNT) || filters_.count(DATA)) && + ++sort_threshold_ == STREAM_UPDATE_FPS)) { + sort_threshold_ = 0; + filterAndSort(); + } +} + +void MessageList::sort(int column, ImGuiSortDirection order) { + if (column != DATA) { + sort_column_ = column; + sort_order_ = order; + filterAndSort(); + } +} + +// MessagesWidget + +MessagesWidget::MessagesWidget() { + std::iota(display_order_.begin(), display_order_.end(), 0); + list_.sort(MessageList::NAME, ImGuiSortDirection_Ascending); + + connections_.push_back(list_.changed.connect([this]() { + current_row_ = -1; // the rows moved + if (current_msg_id_) selectMessage(*current_msg_id_); + updateBytesSectionSize(); + updateTitle(); + })); + + suppressHighlighted(); +} + +std::string MessagesWidget::whatsThis() const { + return R"( + Message View
+ Byte color
+ ■ constantly changing
+ ■ increasing
+ ■ decreasing
+ Shortcuts
+ Horizontal Scrolling:  Shift+Wheel  + )"; +} + +void MessagesWidget::drawToolBar() { + const ImGuiStyle &style = ImGui::GetStyle(); + // Reserve space for View so it remains accessible when other controls overflow. + const std::string clear_label = suppress_clear_text_ + "##suppress_clear"; + std::vector items; + items.push_back({toolbarButtonWidth("Suppress Highlighted"), [this]() { + if (ImGui::Button("Suppress Highlighted")) suppressHighlighted(true); + }, "Suppress Highlighted", [this]() { suppressHighlighted(true); }}); + items.push_back({toolbarButtonWidth(suppress_clear_text_), [this, &clear_label]() { + ImGui::BeginDisabled(!suppress_clear_enabled_); + if (ImGui::Button(clear_label.c_str())) suppressHighlighted(false); + ImGui::EndDisabled(); + disabledItemTooltip("Clear suppressed"); + }, suppress_clear_text_, [this]() { suppressHighlighted(false); }, suppress_clear_enabled_}); + const size_t spacer_index = items.size(); + items.push_back({ImGui::CalcTextSize("Suppress Signals").x + CHECKBOX_SIZE + style.ItemInnerSpacing.x, []() { + bool suppress_defined_signals = settings.suppress_defined_signals; + if (checkBox("Suppress Signals", &suppress_defined_signals)) can->suppressDefinedSignals(suppress_defined_signals); + ImGui::SetItemTooltip("Suppress defined signals"); + }}); + + const float reserved = iconButtonWidth() + style.ItemSpacing.x; + drawToolbar(items, spacer_index, std::max(0.0f, ImGui::GetContentRegionAvail().x - reserved)); + ImGui::SameLine(); + if (iconButton("view_btn", icon::THREE_DOTS, "View...")) ImGui::OpenPopup("menu"); +} + +void MessagesWidget::updateTitle() { + auto stats = std::accumulate( + list_.items.begin(), list_.items.end(), std::pair(), + [](const auto &pair, const auto &item) { + auto m = dbc()->msg(item.id); + return m ? std::make_pair(pair.first + 1, pair.second + m->sigs.size()) : pair; + }); + char buf[128]; + snprintf(buf, sizeof(buf), "%zu Messages (%zu DBC Messages, %zu Signals)", list_.items.size(), stats.first, stats.second); + title_ = buf; +} + +void MessagesWidget::selectMessage(const MessageId &msg_id) { + auto it = std::find_if(list_.items.cbegin(), list_.items.cend(), [&msg_id](auto &item) { return item.id == msg_id; }); + if (it != list_.items.cend()) setCurrentRow(std::distance(list_.items.cbegin(), it)); +} + +void MessagesWidget::setCurrentRow(int row) { + if (row < 0 || row >= (int)list_.items.size()) return; + current_row_ = row; + scroll_to_current_ = true; + const auto &id = list_.items[row].id; + if (!current_msg_id_ || id != *current_msg_id_) { + current_msg_id_ = id; + msgSelectionChanged(*current_msg_id_); + } +} + +void MessagesWidget::suppressHighlighted(bool from_suppress_add) { + int n = from_suppress_add ? can->suppressHighlighted() : (can->clearSuppressed(), 0); + suppress_clear_text_ = n > 0 ? "Clear (" + std::to_string(n) + ")" : "Clear"; + suppress_clear_enabled_ = n > 0; +} + +void MessagesWidget::drawContextMenu() { + if (!dropdown::BeginPopup("menu")) return; + for (int i = 0; i < MessageList::COLUMN_COUNT; ++i) { + const int column = display_order_[i]; + // can't hide the name column + if (dropdown::Item(COLUMN_TITLES[column], nullptr, !hidden_[column], column > 0)) { + pending_hidden_.emplace_back(column, !hidden_[column]); + } + } + ImGui::Separator(); + if (dropdown::Item("Multiline Bytes", nullptr, settings.multiple_lines_hex)) { + setMultiLineBytes(!settings.multiple_lines_hex); + } + if (dropdown::Item("Show Inactive Messages", nullptr, list_.show_inactive_messages)) { + list_.showInactiveMessages(!list_.show_inactive_messages); + } + dropdown::EndPopup(); +} + +void MessagesWidget::setMultiLineBytes(bool multi) { + settings.multiple_lines_hex = multi; + updateBytesSectionSize(); +} + +void MessagesWidget::updateBytesSectionSize() { + int max_bytes = 8; + if (!settings.multiple_lines_hex) { + for (const auto &[_, m] : can->lastMessages()) { + max_bytes = std::max(max_bytes, m.dat.size()); + } + } + bytes_section_bytes_ = max_bytes; +} + +void MessagesWidget::draw() { + drawToolBar(); + drawTable(); + if (std::exchange(header_menu_requested_, false)) ImGui::OpenPopup("menu"); // at the mouse position + drawContextMenu(); +} + +void MessagesWidget::handleKeys() { + if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows) || ImGui::IsAnyItemActive()) return; + const int last = (int)list_.items.size() - 1; + if (last < 0) return; + if (ImGui::IsKeyPressed(ImGuiKey_UpArrow) && current_row_ > 0) { + setCurrentRow(current_row_ - 1); + } else if (ImGui::IsKeyPressed(ImGuiKey_DownArrow) && current_row_ < last) { + setCurrentRow(current_row_ + 1); + } else if (ImGui::IsKeyPressed(ImGuiKey_Home)) { + setCurrentRow(0); + } else if (ImGui::IsKeyPressed(ImGuiKey_End)) { + setCurrentRow(last); + } else if (ImGui::IsKeyPressed(ImGuiKey_PageUp)) { + setCurrentRow(std::max(current_row_ - visible_rows_, 0)); + } else if (ImGui::IsKeyPressed(ImGuiKey_PageDown)) { + setCurrentRow(std::min(current_row_ + visible_rows_, last)); + } +} + +void MessagesWidget::drawTable() { + handleKeys(); + const bool multiple_lines = settings.multiple_lines_hex; + + const ImGuiTableFlags flags = ImGuiTableFlags_Sortable | ImGuiTableFlags_Resizable | ImGuiTableFlags_Reorderable | + ImGuiTableFlags_ScrollX | ImGuiTableFlags_ScrollY | ImGuiTableFlags_BordersInner | + ImGuiTableFlags_Hideable | ImGuiTableFlags_PadOuterX; + // with ScrollX a stretch column needs an explicit inner width + const float bytes_width = bytesCellSize(bytes_section_bytes_, multiple_lines).x; + const float avail_width = ImGui::GetContentRegionAvail().x - (has_scrollbar_y_ ? ImGui::GetStyle().ScrollbarSize : 0); + const float inner_width = std::max(avail_width, fixed_columns_width_ + bytes_width); + if (!ImGui::BeginTable("messages", MessageList::COLUMN_COUNT, flags, ImVec2(0, 0), inner_width)) return; + + // no frozen column: only the header and the filter row stay put + ImGui::TableSetupScrollFreeze(0, 2); + // the widget shows its own header menu; imgui's default one is never drawn, so clear the flag + // TableHeader() leaves behind (it would keep the column header highlighted forever) + ImGuiTable *table = ImGui::GetCurrentTable(); + table->DisableDefaultContextMenu = true; + table->IsContextPopupOpen = false; + for (int i = 0; i < MessageList::COLUMN_COUNT; ++i) { + // with the flipped direction the first click on a section sorts ascending + ImGuiTableColumnFlags column_flags = ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_PreferSortDescending; + float width = DEFAULT_SECTION_SIZE; + if (i == MessageList::NAME) { + column_flags |= ImGuiTableColumnFlags_DefaultSort | ImGuiTableColumnFlags_NoHide; + } else if (i == MessageList::DATA) { + column_flags = ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_NoSort | ImGuiTableColumnFlags_NoResize; + width = 0; + } + ImGui::TableSetupColumn(COLUMN_TITLES[i], column_flags, width); + } + for (const auto &[column, hide] : pending_hidden_) ImGui::TableSetColumnEnabled(column, !hide); + pending_hidden_.clear(); + + if (ImGuiTableSortSpecs *specs = ImGui::TableGetSortSpecs(); specs && specs->SpecsDirty) { + if (specs->SpecsCount > 0) list_.sort(specs->Specs[0].ColumnIndex, flipSortDirection(specs->Specs[0].SortDirection)); + specs->SpecsDirty = false; + if (current_row_ >= 0) scroll_to_current_ = true; // keep the current row visible + } + + drawHeader(); + + const int rows = list_.items.size(); + if (!multiple_lines) { + ImGuiListClipper clipper; + clipper.Begin(rows); + if (scroll_to_current_ && current_row_ >= 0) clipper.IncludeItemByIndex(current_row_); + while (clipper.Step()) { + for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) drawRow(row); + } + } else { + // non-uniform row heights: no clipper + for (int row = 0; row < rows; ++row) drawRow(row); + } + + // for the next frame: the stretch inner width and the page size of handleKeys + const ImGuiTableColumn &data_column = table->Columns[MessageList::DATA]; + fixed_columns_width_ = data_column.IsEnabled ? table->ColumnsGivenWidth - data_column.WidthGiven : 0; + has_scrollbar_y_ = table->InnerWindow->ScrollbarY; + visible_rows_ = std::max(1, (int)(table->InnerWindow->InnerRect.GetHeight() / bytesCellSize(0, multiple_lines).y) - 2); + ImGui::EndTable(); +} + +void MessagesWidget::drawHeader() { + if (tableHeadersRow() >= 0) header_menu_requested_ = true; + // record the visual order and the visibility of the sections for the menu + ImGuiTable *table = ImGui::GetCurrentTable(); + for (int i = 0; i < MessageList::COLUMN_COUNT; i++) { + display_order_[i] = table->DisplayOrderToIndex[i]; + hidden_[i] = !(ImGui::TableGetColumnFlags(i) & ImGuiTableColumnFlags_IsEnabled); + } + + // the filter editors under the header + ImGui::TableNextRow(); + for (int i = 0; i < MessageList::COLUMN_COUNT; i++) { + if (!ImGui::TableSetColumnIndex(i)) continue; + ImGui::PushID(i); + ImGui::SetNextItemWidth(-FLT_MIN); + const std::string placeholder = std::string("Filter ") + COLUMN_TITLES[i]; + if (clearableInput("##filter", &filters_[i], placeholder.c_str())) { + std::map filters; + for (int c = 0; c < MessageList::COLUMN_COUNT; ++c) { + if (!filters_[c].empty()) filters[c] = filters_[c]; + } + list_.setFilters(filters); + } + ImGui::PopID(); + } +} + +void MessagesWidget::drawRow(int row) { + const auto &item = list_.items[row]; + const bool selected = row == current_row_; + const bool inactive = !can->isMessageActive(item.id); + const auto &m = can->lastMessage(item.id); + const bool seen = item.id.source != INVALID_SOURCE; + const bool multiple_lines = settings.multiple_lines_hex; + const float row_height = bytesCellSize(seen ? m.dat.size() : 0, multiple_lines).y - ImGui::GetStyle().CellPadding.y * 2; + ImGui::TableNextRow(); + ImGui::PushID(row); + + bool row_item_submitted = false; + for (int column = 0; column < MessageList::COLUMN_COUNT; ++column) { + if (!ImGui::TableSetColumnIndex(column)) continue; + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x; + const ImRect rect(pos, ImVec2(pos.x + width, pos.y + row_height)); + + const bool row_item = !row_item_submitted; + if (row_item) { + // the row selection spans all columns; submit it in the first visible column so that it is not + // clipped away when the table is scrolled horizontally + row_item_submitted = true; + // rows select on press + if (viewSelectable("##row", selected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_SelectOnClick, ImVec2(0, row_height))) { + setCurrentRow(row); + } + if (selected && scroll_to_current_) { + // only scroll when the row is outside the viewport, and only far enough + const ImGuiWindow *inner = ImGui::GetCurrentTable()->InnerWindow; + const float view_top = inner->InnerClipRect.Min.y + inner->DecoInnerSizeY1; + const float view_bottom = inner->InnerClipRect.Max.y; + if (ImGui::GetItemRectMin().y < view_top) { + ImGui::SetScrollHereY(0.0f); + } else if (ImGui::GetItemRectMax().y > view_bottom) { + ImGui::SetScrollHereY(1.0f); + } + scroll_to_current_ = false; + } + // the tooltip belongs to the name, so only show it while the mouse is over the Name column + const ImGuiTableColumn &name_col = ImGui::GetCurrentTable()->Columns[MessageList::NAME]; + const float mouse_x = ImGui::GetIO().MousePos.x; + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip) && mouse_x >= name_col.MinX && mouse_x < name_col.MaxX) { + auto msg = dbc()->msg(item.id); + ImGui::BeginTooltip(); + ImGui::TextUnformatted(item.name.c_str()); + if (msg && !msg->comment.empty()) ImGui::TextDisabled("%s", msg->comment.c_str()); + ImGui::EndTooltip(); + } + } + + if (column == MessageList::DATA && seen) { + drawBytesCell(ImGui::GetWindowDrawList(), rect, m.dat, &m.colors, selected, inactive, multiple_lines); + } else { + const bool align_right = seen && (column == MessageList::SOURCE || column == MessageList::COUNT || + (column == MessageList::FREQ && m.freq > 0)); + drawTextCell(ImGui::GetWindowDrawList(), rect, cellText(item, column), selected, inactive, align_right); + } + // the Selectable already sized its cell + if (!row_item) ImGui::Dummy(ImVec2(width, row_height)); + } + + ImGui::PopID(); +} diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.h b/openpilot/tools/cabana/ui/widgets/messageswidget.h new file mode 100644 index 0000000000..abfd43fb50 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.h @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + +// the rows of the messages table: every CAN message seen plus the DBC messages not seen yet, filtered +// and sorted. `changed` fires when the rows differ from the last time. +class MessageList { +public: + enum Column { NAME = 0, SOURCE, ADDRESS, NODE, FREQ, COUNT, DATA, COLUMN_COUNT }; + + struct Item { + MessageId id; + std::string name; + std::string node; + bool operator==(const Item &other) const { return id == other.id && name == other.name && node == other.node; } + }; + + MessageList(); + void sort(int column, ImGuiSortDirection order); + void setFilters(const std::map &filters); + void showInactiveMessages(bool show); + bool filterAndSort(); + + std::vector items; + bool show_inactive_messages = true; + Observable<> changed; + +private: + void msgsReceived(const std::set *new_msgs, bool has_new_ids); + void dbcModified(); + void sortItems(std::vector &list); + bool match(const Item &item); + + std::map filters_; + std::set dbc_messages_; + int sort_column_ = NAME; + ImGuiSortDirection sort_order_ = ImGuiSortDirection_Ascending; + int sort_threshold_ = 0; + Connections connections_; +}; + +class MessagesWidget { +public: + MessagesWidget(); + void draw(); // content only; MainWindow does ImGui::Begin/End with the dock title + void selectMessage(const MessageId &message_id); + void suppressHighlighted(bool from_suppress_add = false); + const std::string &title() const { return title_; } + std::string whatsThis() const; + + Observable msgSelectionChanged; + +private: + void drawToolBar(); + void drawTable(); + void drawHeader(); // the header row and the filter editors row, inside the table + void drawRow(int row); + void drawContextMenu(); + void handleKeys(); // up/down move the current row + void setCurrentRow(int row); // scrolls to the row + void updateBytesSectionSize(); + void updateTitle(); + void setMultiLineBytes(bool multi); + + MessageList list_; + std::optional current_msg_id_; + int current_row_ = -1; + bool scroll_to_current_ = false; + int bytes_section_bytes_ = 8; // the minimum width of the stretched DATA section + float fixed_columns_width_ = 0; // width of everything but the stretched DATA section + bool has_scrollbar_y_ = false; + int visible_rows_ = 1; + std::array filters_; + std::array hidden_ = {}; // mirror of the table's enabled columns, refreshed every frame + std::array display_order_; // visual index -> logical column + std::vector> pending_hidden_; // applied inside the table + bool header_menu_requested_ = false; + std::string suppress_clear_text_; + bool suppress_clear_enabled_ = false; + std::string title_ = "MESSAGES"; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc new file mode 100644 index 0000000000..c35e649148 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc @@ -0,0 +1,88 @@ +#include "tools/cabana/ui/widgets/scrollabletabbar.h" + +#include +#include +#include + +#include "imgui_internal.h" + +namespace { +float scrollButtonsWidth() { + const ImGuiStyle &style = ImGui::GetStyle(); + return (ImGui::GetFrameHeight() + style.ItemSpacing.x) * 2.0f; +} + +void drawScrollButtons(ImGuiTabBar *tab_bar) { + const ImGuiStyle &style = ImGui::GetStyle(); + const float size = ImGui::GetFrameHeight(); + const float max_scroll = std::max(0.0f, tab_bar->WidthAllTabs - tab_bar->BarRect.GetWidth()); + const float start_x = tab_bar->BarRect.Max.x + style.ItemSpacing.x; + const ImVec2 backup_pos = ImGui::GetCursorScreenPos(); + + ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); + for (int i = 0; i < 2; ++i) { + const bool left = i == 0; + ImGui::SetCursorScreenPos(ImVec2(start_x + i * (size + style.ItemSpacing.x), tab_bar->BarRect.Min.y)); + ImGui::BeginDisabled(left ? tab_bar->ScrollingTarget <= 0.0f : tab_bar->ScrollingTarget >= max_scroll); + if (ImGui::Button(left ? "###scroll_left" : "###scroll_right", ImVec2(size, size))) { + const float step = (left ? -4.0f : 4.0f) * ImGui::GetFontSize(); + tab_bar->ScrollingTarget = std::clamp(tab_bar->ScrollingTarget + step, 0.0f, max_scroll); + tab_bar->ScrollingAnim = tab_bar->ScrollingTarget; + } + // the icon font glyph sits off center in its padded advance, so the chevron is drawn in the rect + const ImVec2 c((ImGui::GetItemRectMin().x + ImGui::GetItemRectMax().x) * 0.5f, + (ImGui::GetItemRectMin().y + ImGui::GetItemRectMax().y) * 0.5f); + const float h = std::round(ImGui::GetFontSize() * 0.25f); + const float dx = left ? h * 0.5f : -h * 0.5f; + ImDrawList *painter = ImGui::GetWindowDrawList(); + painter->PathLineTo(ImVec2(c.x + dx, c.y - h)); + painter->PathLineTo(ImVec2(c.x - dx, c.y)); + painter->PathLineTo(ImVec2(c.x + dx, c.y + h)); + painter->PathStroke(ImGui::GetColorU32(ImGuiCol_Text), ImDrawFlags_None, 1.5f); + ImGui::EndDisabled(); + } + ImGui::PopItemFlag(); + ImGui::SetCursorScreenPos(backup_pos); +} + +struct ScrollableTabBar { ImGuiTabBar *tab_bar; bool overflowing; }; +std::vector scrollable_tab_bars; +} // namespace + +bool beginScrollableTabBar(const char *str_id, ImGuiTabBarFlags flags) { + // the buttons take their room from the bar when the tabs overflowed last frame + ImGuiWindow *window = ImGui::GetCurrentWindow(); + ImGuiTabBar *prev_tab_bar = ImGui::TabBarFindByID(window->GetID(str_id)); + const bool overflowing = prev_tab_bar && prev_tab_bar->WidthAllTabsIdeal > prev_tab_bar->BarRect.GetWidth() + 1.0f; + const float backup_work_max_x = window->WorkRect.Max.x; + if (overflowing) window->WorkRect.Max.x -= scrollButtonsWidth(); + const bool open = ImGui::BeginTabBar(str_id, flags | ImGuiTabBarFlags_FittingPolicyScroll | ImGuiTabBarFlags_NoTabListScrollingButtons); + window->WorkRect.Max.x = backup_work_max_x; + if (open) scrollable_tab_bars.push_back({ImGui::GetCurrentTabBar(), overflowing}); + return open; +} + +void endScrollableTabBar() { + ImGui::EndTabBar(); + const ScrollableTabBar bar = scrollable_tab_bars.back(); + scrollable_tab_bars.pop_back(); + if (!bar.overflowing) return; + drawScrollButtons(bar.tab_bar); + + // the wheel scrolls the tabs while the pointer is over them: a two finger swipe on a touchpad, or a + // mouse wheel like a window that only scrolls sideways. Owning the wheel keeps the window behind still + ImGuiTabBar *tab_bar = bar.tab_bar; + if (ImGui::IsWindowHovered() && ImGui::IsMouseHoveringRect(tab_bar->BarRect.Min, tab_bar->BarRect.Max)) { + ImGui::SetKeyOwner(ImGuiKey_MouseWheelX, tab_bar->ID); + ImGui::SetKeyOwner(ImGuiKey_MouseWheelY, tab_bar->ID); + const ImGuiIO &io = ImGui::GetIO(); + const float wheel = io.MouseWheelH + io.MouseWheel; + if (wheel != 0.0f) { + const float max_scroll = std::max(0.0f, tab_bar->WidthAllTabs - tab_bar->BarRect.GetWidth()); + const float step = std::floor(ImGui::GetFontSize() * 2.0f); + tab_bar->ScrollingTarget = std::clamp(tab_bar->ScrollingTarget - wheel * step, 0.0f, max_scroll); + tab_bar->ScrollingAnim = tab_bar->ScrollingTarget; + } + } +} + diff --git a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.h b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.h new file mode 100644 index 0000000000..6c119a7d7c --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.h @@ -0,0 +1,8 @@ +#pragma once + +#include "imgui.h" + +// a tab bar that scrolls with a pair of chevron buttons at its right end when the tabs overflow, in place of +// imgui's small arrows. Use like BeginTabBar/EndTabBar, the fitting policy is always scroll +bool beginScrollableTabBar(const char *str_id, ImGuiTabBarFlags flags = 0); +void endScrollableTabBar(); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc new file mode 100644 index 0000000000..6562903935 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -0,0 +1,1020 @@ +#include "tools/cabana/ui/widgets/signalview.h" + +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" +#include "tools/cabana/ui/icons.h" + +namespace { +constexpr float INDENTATION = 20.0f; +constexpr float H_MARGIN = 3.0f; +constexpr float V_MARGIN = 2.0f; +// signal rows are taller than a frame so the sparklines have room to read +constexpr float SIGNAL_ROW_EXTRA = 5.0f; // the tool button in the row makes it 27 px tall at the 16 px font +constexpr float SIGNAL_ROW_SCALE = 1.25f; +constexpr float FILTER_WIDTH = 160.0f; +constexpr float SPARKLINE_SLIDER_WIDTH = 120.0f; +// WARNING: increasing the maximum range can result in severe performance degradation. +// 30s is a reasonable value at present. +constexpr int SPARKLINE_RANGE_MAX = 30; +constexpr float LABEL_FONT = 12.0f; // Inter needs 12 px for 8 px tall digits +constexpr float MINMAX_FONT = 10.0f; +constexpr int COLOR_LABEL_WIDTH = 18; + +std::string signalTypeToString(cabana::Signal::Type type) { + if (type == cabana::Signal::Type::Multiplexor) return "Multiplexor Signal"; + else if (type == cabana::Signal::Type::Multiplexed) return "Multiplexed Signal"; + else return "Normal Signal"; +} + +std::string multiplexIndicator(const cabana::Signal *sig) { + return sig->type == cabana::Signal::Type::Multiplexor ? std::string(" M ") : " m" + std::to_string(sig->multiplex_value) + " "; +} + +std::string nameText(const SignalModel::Item *item) { + return item->type == SignalModel::Item::Sig ? item->sig->name : item->title; +} + +float rowHeight() { + return ImGui::GetFrameHeight(); +} + +// only the top level signal rows are taller; the expanded sub-rows keep the default row height +float signalRowHeight() { + return std::floor((ImGui::GetFrameHeight() + SIGNAL_ROW_EXTRA) * SIGNAL_ROW_SCALE); +} + +// column 0 has a double validator. Returns true when the cell was clicked (row selection); the two cells +// of a row share the row's id scope, so each editor needs its own column id +bool valueDescriptionEditor(int column, std::string *text) { + ImGui::PushID(column); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + validatedInput("##edit", text, column == 0 ? doubleValidator : nullptr); + ImGui::PopStyleVar(); + const bool clicked = ImGui::IsItemActivated() || ImGui::IsItemClicked(); + ImGui::PopID(); + return clicked; +} + +} // namespace + +SignalModel::SignalModel() : root_(new Item) { + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { handleMsgChanged(id); })); + connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { handleMsgChanged(id); })); + connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { handleSignalRemoved(sig); })); +} + +void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { + Item *parent_item = new Item{.type = Item::Sig, .parent = root_item, .sig = sig, .title = sig->name}; + root_item->children.insert(root_item->children.begin() + pos, parent_item); + std::string titles[]{"Name", "Size", "Receiver Nodes", "Little Endian", "Signed", "Offset", "Factor", "Type", + "Multiplex Value", "Extra Info", "Unit", "Comment", "Minimum Value", "Maximum Value", "Value Table"}; + for (int i = 0; i < std::size(titles); ++i) { + auto item = new Item{.type = (Item::Type)(i + Item::Name), .parent = parent_item, .sig = sig, .title = titles[i]}; + parent_item->children.push_back(item); + if (item->type == Item::ExtraInfo) { + parent_item = item; + } + } +} + +void SignalModel::setMessage(const MessageId &id) { + msg_id_ = id; + filter_str_ = ""; + refresh(); +} + +void SignalModel::setFilter(const std::string &txt) { + filter_str_ = txt; + refresh(); +} + +void SignalModel::refresh() { + root_.reset(new SignalModel::Item); + if (auto msg = dbc()->msg(msg_id_)) { + for (auto s : msg->getSignals()) { + if (filter_str_.empty() || utils::containsCI(s->name, filter_str_)) { + insertItem(root_.get(), root_->children.size(), s); + } + } + } + modelReset(); + rowsChanged(); +} + +bool SignalModel::isEnabled(const Item *item) { + return !(item->type == Item::MultiplexValue && item->sig->type != cabana::Signal::Type::Multiplexed); +} + +bool SignalModel::isCheckable(const Item *item) { + return item->type == Item::Endian || item->type == Item::Signed; +} + +bool SignalModel::isEditable(const Item *item) { + return item->children.empty() && !isCheckable(item); +} + +int SignalModel::signalRow(const cabana::Signal *sig) const { + for (int i = 0; i < root_->children.size(); ++i) { + if (root_->children[i]->sig == sig) return i; + } + return -1; +} + +std::string SignalModel::valueText(const Item *item) const { + switch (item->type) { + case Item::Sig: return item->sig_val; + case Item::Name: return item->sig->name; + case Item::Size: return std::to_string(item->sig->size); + case Item::Node: return item->sig->receiver_name; + case Item::SignalType: return signalTypeToString(item->sig->type); + case Item::MultiplexValue: return std::to_string(item->sig->multiplex_value); + case Item::Offset: return doubleToString(item->sig->offset); + case Item::Factor: return doubleToString(item->sig->factor); + case Item::Unit: return item->sig->unit; + case Item::Comment: return item->sig->comment; + case Item::Min: return doubleToString(item->sig->min); + case Item::Max: return doubleToString(item->sig->max); + case Item::Desc: { + std::string val_desc; + for (auto &[val, desc] : item->sig->val_desc) { + if (!val_desc.empty()) val_desc += " "; + val_desc += utils::toString(val) + " \"" + desc + "\""; + } + return val_desc; + } + default: return {}; + } +} + +bool SignalModel::setData(Item *item, const ItemValue &value) { + cabana::Signal s = *item->sig; + switch (item->type) { + case Item::Name: s.name = value.toString(); break; + case Item::Size: s.size = value.toInt(); break; + case Item::Node: s.receiver_name = utils::trimmed(value.toString()); break; + case Item::SignalType: s.type = (cabana::Signal::Type)value.toInt(); break; + case Item::MultiplexValue: s.multiplex_value = value.toInt(); break; + case Item::Endian: s.is_little_endian = value.toBool(); break; + case Item::Signed: s.is_signed = value.toBool(); break; + case Item::Offset: s.offset = value.toDouble(); break; + case Item::Factor: s.factor = value.toDouble(); break; + case Item::Unit: s.unit = value.toString(); break; + case Item::Comment: s.comment = value.toString(); break; + case Item::Min: s.min = value.toDouble(); break; + case Item::Max: s.max = value.toDouble(); break; + case Item::Desc: s.val_desc = value.toValueDescription(); break; + default: return false; + } + return saveSignal(item->sig, s); +} + +bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) { + auto msg = dbc()->msg(msg_id_); + if (s.name != origin_s->name && msg->sig(s.name) != nullptr) { + std::string text = "There is already a signal with the same name '" + s.name + "'"; + MessageBox::warning("Failed to save signal", text); + return false; + } + + if (s.is_little_endian != origin_s->is_little_endian) { + s.start_bit = flipBitPos(s.start_bit); + } + UndoStack::instance()->push(new EditSignalCommand(msg_id_, origin_s, s)); + return true; +} + +void SignalModel::handleMsgChanged(MessageId id) { + if (id.address == msg_id_.address) { + refresh(); + } +} + +void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) { + if (id == msg_id_) { + if (filter_str_.empty()) { + int i = dbc()->msg(msg_id_)->indexOf(sig); + insertItem(root_.get(), i, sig); + rowsChanged(); + } else if (utils::containsCI(sig->name, filter_str_)) { + refresh(); + } + } +} + +void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { + if (int row = signalRow(sig); row != -1) { + if (filter_str_.empty()) { + // move row when the order changes. + int to = dbc()->msg(msg_id_)->indexOf(sig); + if (to != row) { + auto item = root_->children[row]; + root_->children.erase(root_->children.begin() + row); + root_->children.insert(root_->children.begin() + to, item); + } + } + } +} + +void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { + if (int row = signalRow(sig); row != -1) { + delete root_->children[row]; + root_->children.erase(root_->children.begin() + row); + rowsChanged(); + } +} + +float SignalView::textWidth(const std::string &text, float font_size) { + ImFont *font = ImGui::GetFont(); + if (!font || ImGui::GetFontSize() <= 0) return 0; // no frame rendered yet + return font->CalcTextSizeA(font_size > 0 ? font_size : ImGui::GetFontSize(), FLT_MAX, 0.0f, text.c_str()).x; +} + +float SignalView::nameColumnWidth(const SignalModel::Item *item, float widget_width, const std::string &text) const { + float spacing = INDENTATION + COLOR_LABEL_WIDTH + H_MARGIN * 4; + std::string txt = text; + if (item->type == SignalModel::Item::Sig && item->sig->type != cabana::Signal::Type::Normal) { + txt += multiplexIndicator(item->sig); + spacing += H_MARGIN * 2; + } + const float preferred = ImGui::CalcTextSize(txt.c_str()).x + spacing; + return std::min(widget_width * (compact_ ? 0.6f : 0.4f), preferred); +} + +void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const SignalModel::Item *item, int column, + bool selected, const std::string &text, float viewport_x) const { + const float h_margin = H_MARGIN; + const float v_margin = V_MARGIN; + + ImRect rect(option_rect.Min.x + h_margin, option_rect.Min.y + v_margin, option_rect.Max.x - h_margin, option_rect.Max.y - v_margin); + // selection background is painted by the row's Selectable + const ImU32 text_color = ImGui::GetColorU32(selected ? palette().text_selected : palette().text); + + if (column == 0) { + if (item->type == SignalModel::Item::Sig) { + // color label + ImRect icon_rect(rect.Min.x, rect.Min.y, rect.Min.x + COLOR_LABEL_WIDTH, rect.Max.y); + painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(item->highlight ? signalHighlight(item->sig->color) : contrastColor(item->sig->color, {0, 0, 0})), ImGui::GetStyle().FrameRounding); + drawText(painter, icon_rect, std::to_string(item->row() + 1).c_str(), item->highlight ? IM_COL32_WHITE : IM_COL32_BLACK, + nullptr, LABEL_FONT); + + rect.Min.x = icon_rect.Max.x + h_margin * 2; + // multiplexer indicator + if (item->sig->type != cabana::Signal::Type::Normal) { + const std::string indicator = multiplexIndicator(item->sig); + ImRect indicator_rect(rect.Min.x, rect.Min.y, rect.Min.x + ImGui::CalcTextSize(indicator.c_str()).x, rect.Max.y); + painter->AddRectFilled(indicator_rect.Min, indicator_rect.Max, ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); + drawElidedText(painter, indicator_rect, indicator, IM_COL32_WHITE, false); + rect.Min.x = indicator_rect.Max.x + h_margin * 2; + } + } else { + rect.Min.x = viewport_x + INDENTATION + COLOR_LABEL_WIDTH + h_margin * 3; + } + + // name + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, false); + } else if (column == 1) { + if (!compact_ && !item->sparkline.isEmpty()) { + const ImVec2 sparkline_size = item->sparkline.size; + item->sparkline.draw(painter, rect.Min, selected ? text_color : 0); + // min-max value + rect.Min.x += sparkline_size.x + 1; + float value_adjust = 10; + if (item->highlight || selected) { + painter->AddLine(rect.Min, ImVec2(rect.Min.x, rect.Max.y), text_color); + rect.Min.x += 5; + rect.Min.y -= v_margin; + rect.Max.y += v_margin; + std::string min = utils::toString(item->sparkline.min_val); + std::string max = utils::toString(item->sparkline.max_val); + drawText(painter, rect, max.c_str(), text_color, nullptr, MINMAX_FONT, ImVec2(0.0f, 0.0f)); + drawText(painter, rect, min.c_str(), text_color, nullptr, MINMAX_FONT, ImVec2(0.0f, 1.0f)); + value_adjust = std::max(textWidth(min, MINMAX_FONT), textWidth(max, MINMAX_FONT)) + 5; + } else if (item->sig->type == cabana::Signal::Type::Multiplexed) { + // display freq of multiplexed signal + char freq[64]; + snprintf(freq, sizeof(freq), "%.2g hz", item->sparkline.freq()); + ImRect freq_rect(rect.Min.x + 5, rect.Min.y, rect.Max.x, rect.Max.y); + drawText(painter, freq_rect, freq, text_color, nullptr, LABEL_FONT, ImVec2(0.0f, 0.5f)); + value_adjust = textWidth(freq, LABEL_FONT) + 10; + } + // signal value + rect.Min.x += value_adjust; + } + // Monospaced digits prevent the value width from changing during playback. + rect.Max.x -= button_size_.x; + pushMonoFont(ImGui::GetFontSize()); + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, true); + popMonoFont(); + } +} + +void SignalView::drawEditor(SignalModel::Item *item) { + const bool take_focus = focus_item_ == item; + if (take_focus) focus_item_ = nullptr; + if (item->type == SignalModel::Item::Name || item->type == SignalModel::Item::Node || item->type == SignalModel::Item::Offset || + item->type == SignalModel::Item::Factor || item->type == SignalModel::Item::MultiplexValue || + item->type == SignalModel::Item::Min || item->type == SignalModel::Item::Max) { + ImGuiInputTextCallback validator = nullptr; + if (item->type == SignalModel::Item::Name) validator = nameValidator; + else if (item->type == SignalModel::Item::Node) validator = nodeValidator; + else validator = doubleValidator; + + drawLineEditor(item, validator, take_focus); + } else if (item->type == SignalModel::Item::Size) { + if (take_focus) { + edit_int_ = item->sig->size; + ImGui::SetKeyboardFocusHere(); + } + bool changed = inputInt("##editor", &edit_int_, 1, 100, ImGuiInputTextFlags_AutoSelectAll); + if (ImGui::IsItemDeactivated() && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + open_item_ = nullptr; // InputInt already reverted the value; only the commit has to be skipped + return; + } + if (ImGui::IsItemDeactivatedAfterEdit() || (changed && !ImGui::IsItemActive())) { + edit_int_ = std::clamp(edit_int_, 1, CAN_MAX_DATA_BYTES); + queueCommit(item, edit_int_); + } + // Enter, Escape and a click outside close the editor; the step buttons keep it open + if (ImGui::IsItemDeactivated() && (!ImGui::IsItemHovered() || ImGui::IsKeyPressed(ImGuiKey_Enter, false) || + ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false))) { + open_item_ = nullptr; + } + } else if (item->type == SignalModel::Item::SignalType) { + // the combo editor is closed by Enter and Escape; the cell is painted as text again next frame + if (combo_focused_ && (ImGui::IsKeyPressed(ImGuiKey_Escape, false) || ImGui::IsKeyPressed(ImGuiKey_Enter, false) || + ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) { + open_item_ = nullptr; + combo_focused_ = false; + return; + } + std::vector> items; + items.emplace_back(signalTypeToString(cabana::Signal::Type::Normal), (int)cabana::Signal::Type::Normal); + if (!dbc()->msg(model_.msgId())->multiplexor) { + items.emplace_back(signalTypeToString(cabana::Signal::Type::Multiplexor), (int)cabana::Signal::Type::Multiplexor); + } else if (item->sig->type != cabana::Signal::Type::Multiplexor) { + items.emplace_back(signalTypeToString(cabana::Signal::Type::Multiplexed), (int)cabana::Signal::Type::Multiplexed); + } + std::vector names; + int current = -1; // -1 when the current type is not an item (Multiplexor) + for (int i = 0; i < items.size(); ++i) { + names.push_back(items[i].first.c_str()); + if (items[i].second == (int)item->sig->type) current = i; + } + const ImGuiID popup_id = ImHashStr("##ComboPopup", 0, ImGui::GetID("##editor")); + if (take_focus) ImGui::SetKeyboardFocusHere(); + if (dropdown::Combo("##editor", ¤t, names.data(), names.size())) { + queueCommit(item, items[current].second); + open_item_ = nullptr; // commit and close the editor + } + combo_focused_ = ImGui::IsItemFocused() || ImGui::IsPopupOpen(popup_id, ImGuiPopupFlags_None); + if (!take_focus && !combo_focused_) open_item_ = nullptr; // the editor is closed when it loses the focus + } else if (item->type == SignalModel::Item::Desc) { + ImGui::PushStyleColor(ImGuiCol_Header, (ImU32)0); + const bool clicked = ImGui::Selectable("##editor", false, 0, ImVec2(0, rowHeight())); + ImGui::PopStyleColor(); + drawElidedText(ImGui::GetWindowDrawList(), ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()), model_.valueText(item), + ImGui::GetColorU32(ImGuiCol_Text), false); + if (clicked || take_focus) { + desc_dlg_ = std::make_unique(item->sig->val_desc); + desc_dlg_->title = item->sig->name; + desc_sig_ = item->sig; + } + } else { + // plain text input, no validator + drawLineEditor(item, nullptr, take_focus); + } +} + +void SignalView::commitEditor() { + SignalModel::Item *item = editing_item_; + std::string text = edit_text_; + closeEditor(); + if (item && validateEditor(item, text) == ValidState::Acceptable) { + queueCommit(item, text); + } +} + +void SignalView::closeEditor() { + editing_item_ = open_item_ = focus_item_ = nullptr; + editor_active_ = refocus_editor_ = enter_pressed_ = combo_focused_ = false; + pending_commit_ = nullptr; // the items it captured are deleted by the caller +} + +// validate the editor of `item`; mutates `text` like the name validator does (spaces -> '_') +ValidState SignalView::validateEditor(const SignalModel::Item *item, std::string &text) { + if (item->type == SignalModel::Item::Name) return validateName(text); + if (item->type == SignalModel::Item::Node) return validateNodes(text); + if (item->type == SignalModel::Item::Offset || item->type == SignalModel::Item::Factor || + item->type == SignalModel::Item::MultiplexValue || item->type == SignalModel::Item::Min || + item->type == SignalModel::Item::Max) { + return validateDouble(text); + } + return ValidState::Acceptable; // no validator +} + +// Enter and focus out only commit when the validator reports Acceptable (an Intermediate or Invalid value +// keeps the editor open with the typed text and commits nothing), Escape reverts to the value the editor +// was opened with. +void SignalView::drawLineEditor(SignalModel::Item *item, ImGuiInputTextCallback validator, bool take_focus) { + const bool editing = editing_item_ == item; + const bool was_active = editing && editor_active_; // the editor had the focus at the end of the last frame + + std::string text = editing ? edit_text_ : model_.valueText(item); + if (take_focus) ImGui::SetKeyboardFocusHere(); + if (editing && refocus_editor_) { + ImGui::SetKeyboardFocusHere(); // keep the focus when the input is not acceptable + refocus_editor_ = false; + } + validatedInput("##editor", &text, validator, "", ImGuiInputTextFlags_AutoSelectAll); + if (ImGui::IsItemActivated()) editing_item_ = item; + if (editing_item_ != item) return; + + edit_text_ = text; + editor_active_ = ImGui::IsItemActive(); + if (was_active) { + if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + // InputText already reverted the text; only the commit has to be skipped + editing_item_ = open_item_ = nullptr; + enter_pressed_ = false; + return; + } + if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) { + enter_pressed_ = true; + } + } + if (ImGui::IsItemDeactivated()) { + const bool by_enter = std::exchange(enter_pressed_, false); + if (!ImGui::IsItemDeactivatedAfterEdit()) { + editing_item_ = open_item_ = nullptr; // nothing was typed, nothing to commit + } else if (validateEditor(item, edit_text_) == ValidState::Acceptable) { + queueCommit(item, edit_text_); + editing_item_ = open_item_ = nullptr; + } else if (by_enter) { + refocus_editor_ = true; // the editor stays open with the text the user typed + } else { + editing_item_ = open_item_ = nullptr; + } + } +} + +void SignalView::queueCommit(SignalModel::Item *item, const ItemValue &value) { + pending_commit_ = [this, item, value]() { model_.setData(item, value); }; +} + +void SignalView::drawValueDescriptionDlg() { + if (!desc_dlg_) return; + if (desc_dlg_->draw()) return; + + if (desc_dlg_->accepted) { + // the dialog closed: apply to the Desc item of the edited signal + for (auto sig_item : model_.root()->children) { + if (sig_item->sig != desc_sig_) continue; + for (auto child : sig_item->children) { + if (child->type != SignalModel::Item::ExtraInfo) continue; + for (auto extra : child->children) { + if (extra->type == SignalModel::Item::Desc) queueCommit(extra, desc_dlg_->val_desc); + } + } + } + } + desc_dlg_.reset(); + desc_sig_ = nullptr; +} + +static ImVec2 indexButtonsSize(float button) { + const auto &style = ImGui::GetStyle(); + // Include the value-to-button gap; the extra padding balances the inset icon glyphs. + return ImVec2(button * 2 + style.ItemInnerSpacing.x * 2 + style.FramePadding.x, button); +} + +SignalView::SignalView(ChartsWidget *charts) : charts_(charts) { + settings.sparkline_range = std::clamp(settings.sparkline_range, 1, SPARKLINE_RANGE_MAX); + + // Reserve button space for updateState() calls before the first draw (no frame yet: derive the frame height). + button_size_ = indexButtonsSize(UI_FONT_SIZE + ImGui::GetStyle().FramePadding.y * 2.0f); + updateToolBar(); + + connections_.push_back(model_.rowsChanged.connect([this]() { rowsChanged(); })); + // a reset closes the open editors; the items they point at are deleted by refresh() + connections_.push_back(model_.modelReset.connect([this]() { + closeEditor(); + // the visible range is computed while drawing; reset it to the top so the sparklines are ready in the + // frame that paints them + if (first_visible_row_ != -1) { + last_visible_row_ = std::min(model_.rowCount() - 1, last_visible_row_ - first_visible_row_); + first_visible_row_ = 0; + } + })); + connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); + // the sig pointers die with the signal + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { + if (desc_sig_ == sig) desc_sig_ = nullptr; + if ((editing_item_ && editing_item_->sig == sig) || (open_item_ && open_item_->sig == sig) || + (focus_item_ && focus_item_->sig == sig)) closeEditor(); + handleSignalRemoved(sig); + })); + connections_.push_back(dbc()->fileChanged.connect([this]() { + desc_sig_ = nullptr; + closeEditor(); + handleSignalRemoved(nullptr); + })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); +} + +std::string SignalView::whatsThis() const { + return R"( + Signal view
+ )"; +} + +void SignalView::setMessage(const MessageId &id) { + filter_edit_.clear(); + model_.setMessage(id); +} + +void SignalView::rowsChanged() { + updateToolBar(); + updateChartState(); + updateState(); +} + +void SignalView::rowClicked(SignalModel::Item *item) { + if (item->type == SignalModel::Item::Sig || item->type == SignalModel::Item::ExtraInfo) { + item->expanded = !item->expanded; + } +} + +void SignalView::selectSignal(const cabana::Signal *sig, bool expand) { + if (int row = model_.signalRow(sig); row != -1) { + auto item = model_.root()->children[row]; + if (expand) { + item->expanded = !item->expanded; + } + scroll_to_sig_ = sig; // scroll the signal to the top + current_sig_ = sig; + current_type_ = SignalModel::Item::Sig; + } +} + +void SignalView::updateChartState() { + for (auto item : model_.root()->children) { + item->chart_opened = charts_->hasSignal(model_.msgId(), item->sig); + } +} + +void SignalView::signalHovered(const cabana::Signal *sig) { + auto &children = model_.root()->children; + for (int i = 0; i < children.size(); ++i) { + children[i]->highlight = children[i]->sig == sig; + } +} + +void SignalView::updateToolBar() { + signal_count_lb_ = "Signals: " + std::to_string(model_.rowCount()); + sparkline_label_ = utils::formatSeconds(settings.sparkline_range); +} + +void SignalView::setSparklineRange(int value) { + settings.sparkline_range = value; + updateToolBar(); + updateState(); +} + +void SignalView::handleSignalAdded(MessageId id, const cabana::Signal *sig) { + if (id.address == model_.msgId().address) { + selectSignal(sig); + } +} + +void SignalView::handleSignalUpdated(const cabana::Signal *sig) { + if (int row = model_.signalRow(sig); row != -1) + updateState(); +} + +void SignalView::handleSignalRemoved(const cabana::Signal *sig) { + if (!sig || current_sig_ == sig) { + // the current index moves to the row that took the removed one, or to the last row + current_sig_ = nullptr; + auto &children = model_.root()->children; + if (sig && !children.empty() && current_row_ >= 0) { + current_sig_ = children[std::min(current_row_, children.size() - 1)]->sig; + current_type_ = SignalModel::Item::Sig; + } + } + if (!sig || scroll_to_sig_ == sig) scroll_to_sig_ = nullptr; + if (!sig || hovered_sig_ == sig) hovered_sig_ = nullptr; +} + +float SignalView::widestValueWidth(const cabana::Signal *sig) { + const double raw_max = sig->is_signed ? std::ldexp(1.0, sig->size - 1) - 1 : std::ldexp(1.0, sig->size) - 1; + const double raw_min = sig->is_signed ? -std::ldexp(1.0, sig->size - 1) : 0.0; + float width = 0; + pushMonoFont(ImGui::GetFontSize()); + for (double raw : {raw_min, raw_max}) { + width = std::max(width, textWidth(sig->formatValue(raw * sig->factor + sig->offset))); + } + for (const auto &[_, desc] : sig->val_desc) { + width = std::max(width, textWidth(desc)); + } + popMonoFont(); + return width; +} + +void SignalView::updateState(const std::set *msgs) { + const auto &last_msg = can->lastMessage(model_.msgId()); + if (model_.rowCount() == 0 || (msgs && !msgs->count(model_.msgId())) || last_msg.dat.size() == 0) return; + + // sized for the widest value the signals can produce, not the widest one in the last message: sizing + // to the current values moved the sparklines every time a value changed length + float max_value_width = 0; + for (auto item : model_.root()->children) { + double value = 0; + if (item->sig->getValue(last_msg.dat.data(), last_msg.dat.size(), &value)) { + item->sig_val = item->sig->formatValue(value); + } + max_value_width = std::max(max_value_width, widestValueWidth(item->sig)); + } + + if (!compact_ && first_visible_row_ != -1 && last_visible_row_ != -1 && last_visible_row_ < model_.rowCount()) { + const float min_max_width = textWidth("-000.00", MINMAX_FONT) + 5; + float available_width = value_column_width_ - button_size_.x; + float value_width = std::min(max_value_width + min_max_width, available_width / 2); + ImVec2 size(std::floor(available_width - value_width), + std::floor(signalRowHeight() - V_MARGIN * 2)); + + // the window ends at the playback clock, not at the last message: its timestamp only moves when a + // message of this id arrives, so a slow message held the sparkline still for several updates and + // then jumped it, which reads as a hitch at the message rate + const double window_end = can->currentSec(); + // a little data from before the window: the sparkline clips it, so the oldest samples and their + // points slide off the left edge instead of disappearing the moment they age out + const double lead_in = settings.sparkline_range * 0.05; + // plain locals: capturing structured bindings in a lambda is C++20 + const auto range = can->eventsInRange(model_.msgId(), std::make_pair(window_end - settings.sparkline_range - lead_in, window_end)); + const CanEventIter first = range.first, last = range.second; + std::vector> futures; + for (int i = first_visible_row_; i <= last_visible_row_; ++i) { + auto item = model_.root()->children[i]; + futures.push_back(ThreadPool::instance().run([item, first, last, size, window_end]() { + item->sparkline.update(item->sig, first, last, settings.sparkline_range, size, window_end); + })); + } + for (auto &f : futures) f.get(); + } +} + +// the sparkline label, the range slider and the collapse button +float SignalView::toolBarRightWidth(const std::string &range_label) { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::CalcTextSize(range_label.c_str()).x + style.ItemSpacing.x + SPARKLINE_SLIDER_WIDTH + style.ItemSpacing.x + iconButtonWidth(); +} + +// the width at which the tool bar stops squishing: the signal count and the filter box on the left, the +// sparkline controls on the right. Padding is supplied by the message panel. +float SignalView::minimumWidth() { + const ImGuiStyle &style = ImGui::GetStyle(); + const float left_width = ImGui::CalcTextSize("Signals: 000").x + style.ItemSpacing.x + FILTER_WIDTH; + // formatSeconds is mm:ss for every value the range slider allows + return left_width + style.ItemSpacing.x + toolBarRightWidth("00:00") + style.WindowPadding.x * 2; +} + +float SignalView::minimumHeight() { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::GetFrameHeightWithSpacing() + signalRowHeight() * 4 + + (style.WindowPadding.y + style.ChildBorderSize + CONTROL_OUTLINE_PADDING) * 2; +} + +void SignalView::draw() { + ImGui::PushStyleColor(ImGuiCol_ChildBg, palette().surface); + if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + ImGui::EndChild(); + ImGui::PopStyleColor(); + return; + } + + const ImGuiStyle &style = ImGui::GetStyle(); + const float width = ImGui::GetContentRegionAvail().x; + compact_ = width < minimumWidth() - style.WindowPadding.x * 2; + const float count_width = ImGui::CalcTextSize(signal_count_lb_.c_str()).x; + // The standard overflow button keeps the range and collapse actions reachable on narrow panels. + const float filter_width = compact_ ? std::clamp(width - count_width - iconButtonWidth() - style.ItemSpacing.x * 2, + 1.0f, FILTER_WIDTH) : FILTER_WIDTH; + std::vector items; + items.push_back({count_width, [this]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(signal_count_lb_.c_str()); + }}); + items.back().in_menu = false; + items.push_back({filter_width, [this, filter_width]() { + ImGui::SetNextItemWidth(filter_width); + if (clearableInput("##filter_edit", &filter_edit_, "Filter Signal", nonWhitespaceValidator)) { + model_.setFilter(filter_edit_); + } + }}); + items.push_back({toolBarRightWidth(sparkline_label_) - iconButtonWidth() - style.ItemSpacing.x, [this]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(sparkline_label_.c_str()); + ImGui::SameLine(); + int range = settings.sparkline_range; + if (fusionSliderInt("##sparkline_range_slider", &range, 1, SPARKLINE_RANGE_MAX, SPARKLINE_SLIDER_WIDTH)) { + setSparklineRange(range); + } + ImGui::SetItemTooltip("Sparkline time range"); + }}); + items.push_back(toolbarAction("collapse_all", icon::ARROWS_COLLAPSE, "Collapse All", [this]() { collapseAll(); })); + drawToolbar(items, 2); + + drawTree(); + drawValueDescriptionDlg(); + // model changes run after the tree is drawn: dbc()->signalUpdated/signalRemoved reorder or delete the rows + if (pending_commit_) std::exchange(pending_commit_, nullptr)(); + if (pending_action_) std::exchange(pending_action_, nullptr)(); + current_row_ = model_.signalRow(current_sig_); // used when the row is removed + + ImGui::EndChild(); + ImGui::PopStyleColor(); +} + +void SignalView::collapseAll() { + commitEditor(); // the editor loses the focus, which commits it + for (auto item : model_.root()->children) { + item->expanded = false; + for (auto child : item->children) child->expanded = false; + } +} + +void SignalView::drawTree() { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + // Keep the toolbar fixed; only the signal rows scroll within the remaining space. + const bool visible = beginControlChild("tree", ImVec2(0, 0)); + if (visible) { + button_size_ = indexButtonsSize(iconButtonWidth()); + DrawContext ctx{ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos().x, ImGui::GetContentRegionAvail().x, rowHeight()}; + // the press that closes an open editor is consumed by the focus change, the index widgets never see it + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) editor_open_on_press_ = open_item_ != nullptr; + + int first_visible = -1, last_visible = -1; + auto &children = model_.root()->children; + // Measure before painting so resizing never leaves the old columns for a frame. + auto measure = [&](auto &&self, SignalModel::Item *item) -> void { + ctx.name_width = std::max(ctx.name_width, nameColumnWidth(item, ctx.width, nameText(item))); + if (item->expanded) for (auto child : item->children) self(self, child); + }; + for (auto item : children) measure(measure, item); + if (ctx.name_width > 0) name_column_width_ = ctx.name_width; + for (int i = 0; i < children.size(); ++i) { + ctx.any_visible = false; + const bool header_visible = drawItem(children[i], 0, ctx); + if (header_visible && first_visible == -1) first_visible = i; + if (ctx.any_visible) last_visible = i; + } + if (first_visible == -1 && last_visible != -1) last_visible = -1; + // the rows that just became visible have no sparkline yet + bool changed = first_visible != first_visible_row_ || last_visible != last_visible_row_; + first_visible_row_ = first_visible; + last_visible_row_ = last_visible; + scroll_to_sig_ = nullptr; + + if (ctx.name_width > 0) name_column_width_ = ctx.name_width; + if (ctx.value_column_width > 0 && ctx.value_column_width != value_column_width_) { + value_column_width_ = ctx.value_column_width; + changed = true; + } + if (changed) updateState(); + + // a press on the viewport that hits no row clears the selection and the current index; rowClicked() + // does not run + if (!ctx.mouse_on_row && ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + current_sig_ = nullptr; + current_type_ = SignalModel::Item::Root; + } + + if (ctx.hovered_sig != hovered_sig_) { + hovered_sig_ = ctx.hovered_sig; + highlight(hovered_sig_); + } + } + ImGui::EndChild(); + ImGui::PopStyleVar(); +} + +bool SignalView::drawItem(SignalModel::Item *item, int depth, DrawContext &ctx) { + const bool selected = item->sig == current_sig_ && item->type == current_type_; + const float row_height = item->type == SignalModel::Item::Sig ? signalRowHeight() : ctx.row_height; + const ImVec2 row_min = ImGui::GetCursorScreenPos(); + const ImVec2 row_max(row_min.x + ctx.width, row_min.y + row_height); + const bool row_visible = ImGui::IsRectVisible(row_min, row_max); + ctx.any_visible |= row_visible; + + ImGui::PushID(item); + ImGui::BeginDisabled(!SignalModel::isEnabled(item)); + const bool row_clicked = viewSelectable("##row", selected, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, row_height)); + // a press on the branch indicator only toggles the expansion; the current index does not change and + // rowClicked() does not run + const float branch_x = row_min.x + depth * INDENTATION; + const bool on_branch = !item->children.empty() && ImGui::GetMousePos().x >= branch_x && + ImGui::GetMousePos().x < branch_x + INDENTATION; + if (row_clicked && on_branch) { + item->expanded = !item->expanded; + } else if (row_clicked) { + current_sig_ = item->sig; + current_type_ = item->type; + // the new current item opens its editor. The name column and the non-editable cells (signal rows, + // check boxes) have no editor, so a click there only makes the cell current. + closeEditor(); + if (SignalModel::isEditable(item) && ImGui::GetMousePos().x >= row_min.x + name_column_width_) { + focus_item_ = open_item_ = item; + } + rowClicked(item); + } + if (item->type == SignalModel::Item::Sig && item->sig == scroll_to_sig_) { + ImGui::SetScrollHereY(0.0f); + scroll_to_sig_ = nullptr; + } + if (ImGui::IsMouseHoveringRect(row_min, row_max)) { + ctx.mouse_on_row = true; + if (ImGui::IsWindowHovered()) ctx.hovered_sig = item->sig; + } + + if (!item->children.empty()) { + const float arrow_size = ImGui::GetFontSize() * 0.7f; + ImGui::RenderArrow(ctx.draw_list, ImVec2(row_min.x + depth * INDENTATION + 4.0f, row_min.y + (row_height - arrow_size) * 0.5f), + ImGui::GetColorU32(selected ? palette().text_selected : palette().text), item->expanded ? ImGuiDir_Down : ImGuiDir_Right, 0.7f); + } + + // every row is measured, the header sizes column 0 to the contents of the whole tree + const std::string text0 = nameText(item); + ctx.name_width = std::max(ctx.name_width, nameColumnWidth(item, ctx.width, text0)); + const ImRect rect1(ImVec2(row_min.x + name_column_width_, row_min.y), row_max); + ctx.value_column_width = rect1.GetWidth(); + + // a row outside the viewport is not painted and has no index widget, like a QTreeView row. The row that + // holds the open editor is always submitted, so scrolling it out does not drop the edit. + const bool editor_open = selected && open_item_ == item; + if (row_visible || editor_open) { + const ImRect rect0(ImVec2(row_min.x + (depth + 1) * INDENTATION, row_min.y), ImVec2(row_min.x + name_column_width_, row_max.y)); + paintCell(ctx.draw_list, rect0, item, 0, selected, text0, ctx.viewport_x); + if (item->type == SignalModel::Item::Sig && ImGui::IsMouseHoveringRect(ImVec2(row_min.x, row_min.y), rect0.Max) && + ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip) && ImGui::BeginTooltip()) { + ImGui::TextUnformatted(utils::stripHtml(utils::signalToolTip(item->sig)).c_str()); + ImGui::EndTooltip(); + } + + if (item->type == SignalModel::Item::Sig) { + paintCell(ctx.draw_list, rect1, item, 1, selected, item->sig_val, ctx.viewport_x); + drawIndexWidget(item, rect1); + } else if (SignalModel::isCheckable(item)) { + bool checked = item->type == SignalModel::Item::Endian ? item->sig->is_little_endian : item->sig->is_signed; + ImGui::SetCursorScreenPos(ImVec2(rect1.Min.x + H_MARGIN, rect1.Min.y)); + if (checkBox("##check", &checked)) queueCommit(item, checked); + } else if (SignalModel::isEditable(item) && editor_open) { + // only the current item gets an editor; the others are painted as text + ImGui::SetCursorScreenPos(rect1.Min); + ImGui::SetNextItemWidth(rect1.GetWidth()); + drawEditor(item); + } else { + paintCell(ctx.draw_list, rect1, item, 1, selected, model_.valueText(item), ctx.viewport_x); + } + } + ImGui::EndDisabled(); + ImGui::PopID(); + ImGui::SetCursorScreenPos(ImVec2(row_min.x, row_max.y)); + + if (item->expanded) { + for (auto child : item->children) drawItem(child, depth + 1, ctx); + } + return row_visible; +} + +void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { + const float spacing = ImGui::GetStyle().ItemInnerSpacing.x; + const float button = iconButtonWidth(); + // Anchor fixed-size buttons inside the viewport, which excludes the scrollbar. + ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - H_MARGIN - button * 2 - spacing, + rect.Min.y + (rect.GetHeight() - button) * 0.5f)); + + const auto sig = item->sig; + const bool checked = item->chart_opened; + const bool selected = current_sig_ == sig && current_type_ == SignalModel::Item::Sig; + auto row_button = [selected, button](const char *id, const char *glyph, bool plotted = false) { + const auto &p = palette(); + const ImVec2 button_min = ImGui::GetCursorScreenPos(); + const bool hovered = ImGui::IsWindowHovered() && + ImGui::IsMouseHoveringRect(button_min, ImVec2(button_min.x + button, button_min.y + button)); + // Both actions use neutral hover fills, including on selected rows. The plot keeps its outline. + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, plotted ? 1.0f : 0.0f); + ImGui::PushStyleColor(ImGuiCol_Button, plotted ? p.button : ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, settings.theme == LIGHT_THEME ? ImLerp(p.button, p.text, 0.12f) : p.button_hovered); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, settings.theme == LIGHT_THEME ? ImLerp(p.button, p.text, 0.20f) : p.button_active); + ImGui::PushStyleColor(ImGuiCol_Border, ImLerp(p.button, p.text, 0.45f)); + ImGui::PushStyleColor(ImGuiCol_Text, selected && !plotted && !hovered ? p.text_selected : p.text); + const bool clicked = iconButton(id, glyph); + ImGui::PopStyleColor(5); + ImGui::PopStyleVar(); + return clicked; + }; + if (row_button("plot", icon::GRAPH_UP, checked) && !editor_open_on_press_) { + item->chart_opened = !checked; + showChart(model_.msgId(), sig, item->chart_opened, ImGui::GetIO().KeyShift); + } + ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nShift-click to add to the previously opened plot"); + ImGui::SameLine(0.0f, spacing); + if (row_button("remove", icon::X_LG) && !editor_open_on_press_) { + pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; + } + ImGui::SetItemTooltip("Remove signal"); +} + +ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions) { + for (auto &[val, desc] : descriptions) { + table_.emplace_back(utils::toString(val), desc); + } +} + +bool ValueDescriptionDlg::draw() { + const std::string popup_id = title + "###ValueDescriptionDlg"; + if (!opened_) { + ImGui::OpenPopup(popup_id.c_str()); + opened_ = true; + } + setNextDialogWindow(ImVec2(500.0f, 0.0f)); + bool open = true; + // not drawn while the dock is collapsed or another modal is on top; only closed once the popup is gone + if (!ImGui::BeginPopupModal(popup_id.c_str(), &open, ImGuiWindowFlags_NoSavedSettings)) return ImGui::IsPopupOpen(popup_id.c_str()); + + bool closing = false; + if (stepButton("add", true, "Add")) { + table_.emplace_back("", ""); + } + ImGui::SameLine(); + ImGui::BeginDisabled(current_row_ == -1); + if (stepButton("remove", false, "Remove") && current_row_ < table_.size()) { + table_.erase(table_.begin() + current_row_); + current_row_ = -1; + } + ImGui::EndDisabled(); + + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable("table", 3, flags, ImVec2(0.0f, 300.0f))) { + ImGui::TableSetupScrollFreeze(1, 1); + // vertical header: the 1 based row number + ImGui::TableSetupColumn("##row_number", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoHeaderLabel, + ImGui::CalcTextSize("000").x + ImGui::GetStyle().CellPadding.x * 2); + ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Description", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + for (int row = 0; row < table_.size(); ++row) { + ImGui::PushID(row); + ImGui::TableNextRow(); + if (row == current_row_) ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::GetColorU32(ImGuiCol_Header)); + ImGui::TableSetColumnIndex(0); + ImGui::AlignTextToFramePadding(); + ImGui::TextColored(row == current_row_ ? palette().text_selected : palette().text, "%d", row + 1); + ImGui::TableSetColumnIndex(1); + ImGui::SetNextItemWidth(-FLT_MIN); + if (valueDescriptionEditor(0, &table_[row].first)) current_row_ = row; + ImGui::TableSetColumnIndex(2); + ImGui::SetNextItemWidth(-FLT_MIN); + if (valueDescriptionEditor(1, &table_[row].second)) current_row_ = row; + ImGui::PopID(); + } + ImGui::EndTable(); + } + + bool accept = false, reject = false; + if (dialogButtons("OK", &accept, &reject)) { + if (accept) save(); + closing = true; + } + if (!open) closing = true; + if (closing) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + return !closing; +} + +void ValueDescriptionDlg::save() { + for (int i = 0; i < table_.size(); ++i) { + std::string val = utils::trimmed(table_[i].first); + std::string desc = utils::trimmed(table_[i].second); + if (!val.empty() && !desc.empty()) { + val_desc.push_back({utils::toDouble(val), desc}); + } + } + accepted = true; +} diff --git a/openpilot/tools/cabana/ui/widgets/signalview.h b/openpilot/tools/cabana/ui/widgets/signalview.h new file mode 100644 index 0000000000..1ef830ca50 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/signalview.h @@ -0,0 +1,204 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/chart/sparkline.h" +#include "tools/cabana/utils/strings.h" + +// the value SignalModel::setData takes: text, a number, a check state or a value table +class ItemValue { +public: + ItemValue(const std::string &s) : str_(s) {} + ItemValue(int v) : str_(std::to_string(v)) {} + ItemValue(bool v) : str_(v ? "1" : "0") {} + ItemValue(const ValueDescription &v) : val_desc_(v) {} + ItemValue(const char *) = delete; // a literal would bind to ItemValue(bool) + std::string toString() const { return str_; } + int toInt() const { return utils::toInt(str_); } + bool toBool() const { return str_ == "1"; } + double toDouble() const { return utils::toDouble(str_); } + const ValueDescription &toValueDescription() const { return val_desc_; } + +private: + std::string str_; + ValueDescription val_desc_; +}; + +class SignalModel { +public: + struct Item { + enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc }; + ~Item() { for (auto c : children) delete c; } + inline int row() const { + auto it = std::find(parent->children.begin(), parent->children.end(), this); + return it != parent->children.end() ? std::distance(parent->children.begin(), it) : -1; + } + + Type type = Type::Root; + Item *parent = nullptr; + std::vector children; + + const cabana::Signal *sig = nullptr; + std::string title; + bool highlight = false; + std::string sig_val = "-"; + Sparkline sparkline; + bool expanded = false; + bool chart_opened = false; // plot_btn checked state + }; + + SignalModel(); + Item *root() const { return root_.get(); } + const MessageId &msgId() const { return msg_id_; } + int rowCount() const { return root_->children.size(); } + static bool isEnabled(const Item *item); + static bool isEditable(const Item *item); // a leaf cell in the value column with an editor + static bool isCheckable(const Item *item); // Endian and Signed + std::string valueText(const Item *item) const; + bool setData(Item *item, const ItemValue &value); + void setMessage(const MessageId &id); + void setFilter(const std::string &txt); + bool saveSignal(const cabana::Signal *origin_s, cabana::Signal &s); + int signalRow(const cabana::Signal *sig) const; + + Observable<> rowsChanged; // rows were added, removed or reset + Observable<> modelReset; // the items are gone: the view closes its editors + +private: + void insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig); + void handleSignalAdded(MessageId id, const cabana::Signal *sig); + void handleSignalUpdated(const cabana::Signal *sig); + void handleSignalRemoved(const cabana::Signal *sig); + void handleMsgChanged(MessageId id); + void refresh(); + + MessageId msg_id_; + std::string filter_str_; + std::unique_ptr root_; + Connections connections_; +}; + +// non-blocking: draw() returns false once closed, `accepted` tells whether Ok was pressed +class ValueDescriptionDlg { +public: + ValueDescriptionDlg(const ValueDescription &descriptions); + bool draw(); + ValueDescription val_desc; + std::string title; + bool accepted = false; + +private: + void save(); + std::vector> table_; // rows of {Value, Description} + int current_row_ = -1; + bool opened_ = false; +}; + +class SignalView { +public: + SignalView(ChartsWidget *charts); + void setMessage(const MessageId &id); + void draw(); + static float minimumWidth(); + static float minimumHeight(); + void signalHovered(const cabana::Signal *sig); // handler for BinaryView::signalHovered + void updateChartState(); + void selectSignal(const cabana::Signal *sig, bool expand = false); + bool saveSignal(const cabana::Signal *origin, cabana::Signal &s) { return model_.saveSignal(origin, s); } + std::string whatsThis() const; + + Observable highlight; + Observable showChart; + +private: + void rowsChanged(); + void rowClicked(SignalModel::Item *item); + static float toolBarRightWidth(const std::string &range_label); + void updateToolBar(); + void setSparklineRange(int value); + void handleSignalAdded(MessageId id, const cabana::Signal *sig); + void handleSignalUpdated(const cabana::Signal *sig); + void handleSignalRemoved(const cabana::Signal *sig); // drops the row pointers to a removed signal (nullptr: all) + void updateState(const std::set *msgs = nullptr); + + struct DrawContext { + ImDrawList *draw_list; + float viewport_x; + float width; + float row_height; + float name_width = 0; + float value_column_width = 0; + bool any_visible = false; + bool mouse_on_row = false; + const cabana::Signal *hovered_sig = nullptr; + }; + void drawTree(); + bool drawItem(SignalModel::Item *item, int depth, DrawContext &ctx); // returns whether the row is visible + void drawIndexWidget(SignalModel::Item *item, const ImRect &rect); // the [plot][remove] widget + void collapseAll(); + static float widestValueWidth(const cabana::Signal *sig); + + // viewport_x: left edge of the tree viewport + void paintCell(ImDrawList *painter, const ImRect &rect, const SignalModel::Item *item, int column, bool selected, + const std::string &text, float viewport_x) const; + float nameColumnWidth(const SignalModel::Item *item, float widget_width, const std::string &text) const; + // draws the editor for `item` at the cursor; commits through queueCommit on focus out + void drawEditor(SignalModel::Item *item); + // queues the commit in pending_commit_: EditSignalCommand fires dbc()->signalUpdated synchronously, which reorders + // the rows, so the model is only changed after the tree is drawn (see draw) + void queueCommit(SignalModel::Item *item, const ItemValue &value); + void drawValueDescriptionDlg(); // continuation of the ValueDescriptionDlg opened in drawEditor + static float textWidth(const std::string &text, float font_size = 0); + void closeEditor(); + void commitEditor(); // Qt commits an open editor on focus out + // only an Acceptable value is committed + void drawLineEditor(SignalModel::Item *item, ImGuiInputTextCallback validator, bool take_focus); + static ValidState validateEditor(const SignalModel::Item *item, std::string &text); + + float value_column_width_ = 0; + bool compact_ = false; + float name_column_width_ = 150; + bool editor_open_on_press_ = false; + // computed while drawing the tree: the first top-level row whose own row is visible (a signal whose header + // is scrolled out but whose children are visible is skipped), and the last top-level row with any visible row + int first_visible_row_ = -1; + int last_visible_row_ = -1; + const cabana::Signal *current_sig_ = nullptr; + int current_row_ = -1; // row of current_sig_ at the end of the last draw() + SignalModel::Item::Type current_type_ = SignalModel::Item::Root; + const cabana::Signal *scroll_to_sig_ = nullptr; + const cabana::Signal *hovered_sig_ = nullptr; + std::function pending_action_; // button clicks that destroy rows run after the tree is drawn + std::string sparkline_label_; + std::string filter_edit_; + ChartsWidget *charts_; + std::string signal_count_lb_; + + ImVec2 button_size_ = {}; + // the editor is created for the item that just became current and takes the focus + SignalModel::Item *focus_item_ = nullptr; + // the item whose editor is open; closeEditor() returns the cell to the painted text while the row stays current + SignalModel::Item *open_item_ = nullptr; + std::function pending_commit_; + SignalModel::Item *editing_item_ = nullptr; // the open text editor + std::string edit_text_; + int edit_int_ = 0; + bool editor_active_ = false; // editor had the keyboard focus last frame + bool refocus_editor_ = false; // reopen the editor rejected by the validator + bool enter_pressed_ = false; + bool combo_focused_ = false; // the SignalType combo had the focus last frame + std::unique_ptr desc_dlg_; + const cabana::Signal *desc_sig_ = nullptr; + SignalModel model_; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/tabbar.cc b/openpilot/tools/cabana/ui/widgets/tabbar.cc new file mode 100644 index 0000000000..5e194b965e --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/tabbar.cc @@ -0,0 +1,92 @@ +#include "tools/cabana/ui/widgets/tabbar.h" + +#include +#include + +#include "tools/cabana/ui/widgets/scrollabletabbar.h" + +int TabBar::addTab(const std::string &text) { + tabs_.push_back({text, 0, next_id_++}); + int index = count() - 1; + if (current_index_ == -1) { // the first tab is current + current_index_ = index; + select_current_ = true; + currentChanged(index); + } + return index; +} + +void TabBar::setCurrentIndex(int index) { + if (index == current_index_ || index < -1 || index >= count()) return; + current_index_ = index; + select_current_ = true; + currentChanged(index); +} + +int TabBar::tabAt(const ImVec2 &pos) const { + for (int i = 0; i < count(); ++i) { + if (tabs_[i].rect.Contains(pos)) return i; + } + return -1; +} + +void TabBar::removeTab(int index) { + tabs_.erase(tabs_.begin() + index); + if (index == current_index_) { + // select the tab that moved into this index, else the one to the left + current_index_ = count() ? std::min(index, count() - 1) : -1; + select_current_ = true; + currentChanged(current_index_); + } else if (index < current_index_) { + --current_index_; + } +} + +void TabBar::moveTab(int from, int to) { + if (from == to || from < 0 || from >= count() || to < 0 || to >= count()) return; + const int current_id = current_index_ >= 0 ? tabs_[current_index_].id : -1; + Tab tab = std::move(tabs_[from]); + tabs_.erase(tabs_.begin() + from); + tabs_.insert(tabs_.begin() + to, std::move(tab)); + for (int i = 0; i < count(); ++i) { + if (tabs_[i].id == current_id) current_index_ = i; + } + select_current_ = true; // imgui orders the tabs as submitted only when a tab is (re)selected +} + +void TabBar::draw() { + if (auto_hide_ && count() < 2) return; // auto hidden with fewer than two tabs + ImGui::PushID(this); + // no default tooltip, the tabs carry their own + if (!(scroll_buttons_ ? beginScrollableTabBar("##tabbar", ImGuiTabBarFlags_NoTooltip) : ImGui::BeginTabBar("##tabbar", ImGuiTabBarFlags_NoTooltip))) { + ImGui::PopID(); + return; + } + // every tab gets a close button, not only the hovered/selected one + ImGuiStyle &style = ImGui::GetStyle(); + const float close_button_min_width = tabs_closable_ ? std::exchange(style.TabCloseButtonMinWidthUnselected, -1.0f) : 0.0f; + // setCurrentIndex requests are applied on the next frame + const bool select_current = std::exchange(select_current_, false); + int close_index = -1; + for (int i = 0; i < count(); ++i) { + bool open = true; + const std::string label = tabs_[i].text + "###tab" + std::to_string(tabs_[i].id); + const ImGuiTabItemFlags flags = (select_current && i == current_index_) ? ImGuiTabItemFlags_SetSelected : 0; + if (ImGui::BeginTabItem(label.c_str(), tabs_closable_ ? &open : nullptr, flags)) { + // a programmatic selection takes effect on the next frame, ignore the old tab until then + if (!select_current && i != current_index_) { + current_index_ = i; + currentChanged(i); + } + ImGui::EndTabItem(); + } + tabs_[i].rect = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + if (!tabs_[i].tooltip.empty()) ImGui::SetItemTooltip("%s", tabs_[i].tooltip.c_str()); + tabContextMenu(i); + if (!open) close_index = i; + } + if (tabs_closable_) style.TabCloseButtonMinWidthUnselected = close_button_min_width; + scroll_buttons_ ? endScrollableTabBar() : ImGui::EndTabBar(); + ImGui::PopID(); + if (close_index >= 0) tabCloseRequested(close_index); +} diff --git a/openpilot/tools/cabana/ui/widgets/tabbar.h b/openpilot/tools/cabana/ui/widgets/tabbar.h new file mode 100644 index 0000000000..9a8b7c7835 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/tabbar.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/core/observable.h" + +// QTabBar: tabs are closable when setTabsClosable(true) +class TabBar { +public: + TabBar() = default; + int addTab(const std::string &text); + int count() const { return (int)tabs_.size(); } + void setTabText(int index, const std::string &text) { if (index >= 0 && index < count()) tabs_[index].text = text; } + const std::string &tabText(int index) const { return tabs_[index].text; } + void setTabToolTip(int index, const std::string &tip) { if (index >= 0 && index < count()) tabs_[index].tooltip = tip; } + void setTabData(int index, int data) { if (index >= 0 && index < count()) tabs_[index].data = data; } + int tabData(int index) const { return index >= 0 && index < count() ? tabs_[index].data : 0; } + int currentIndex() const { return current_index_; } + void setCurrentIndex(int index); + int tabAt(const ImVec2 &pos) const; // -1 when no tab covers pos + void removeTab(int index); + void moveTab(int from, int to); + void setAutoHide(bool hide) { auto_hide_ = hide; } + void setTabsClosable(bool closable) { tabs_closable_ = closable; } // off by default + void setUsesScrollButtons(bool use) { scroll_buttons_ = use; } + void draw(); + + Observable currentChanged; + Observable tabCloseRequested; + Observable tabContextMenu; // emitted while drawing, right after the tab: open a context popup from it + +private: + struct Tab { std::string text; int data = 0; int id = 0; std::string tooltip; ImRect rect; }; + std::vector tabs_; + int current_index_ = -1; + int next_id_ = 0; + bool select_current_ = false; // programmatic current change, applied at the next draw() + bool auto_hide_ = false; + bool tabs_closable_ = false; + bool scroll_buttons_ = false; +}; diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc new file mode 100644 index 0000000000..37b4348fd3 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -0,0 +1,612 @@ +#include "tools/cabana/ui/widgets/videowidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +const int MIN_VIDEO_HEIGHT = 100; +const int THUMBNAIL_MARGIN = 3; +const float POINT_10_FONT_SIZE = 13.0f; // 10 pt at 96 dpi +const float POINT_16_FONT_SIZE = 21.0f; // 16 pt at 96 dpi +constexpr float TOOLBAR_SEPARATOR_EXTENT = 1.0f; +const float SLIDER_HEIGHT = 15.0f; // the handle plus a 1 px margin + +// Indexed by TimelineType: None, Engaged, AlertInfo, AlertWarning, AlertCritical, UserBookmark +static const ImU32 timeline_colors[] = { + IM_COL32(111, 143, 175, 255), + IM_COL32(0, 163, 108, 255), + IM_COL32(0, 255, 0, 255), + IM_COL32(255, 195, 0, 255), + IM_COL32(199, 0, 57, 255), + IM_COL32(255, 0, 255, 255), +}; + +static const float speeds[] = {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1., 2., 3., 5.}; +static const int NORMAL_SPEED_INDEX = std::find(std::begin(speeds), std::end(speeds), 1.0f) - std::begin(speeds); + +static Replay *getReplay() { + auto stream = dynamic_cast(can); + return stream ? stream->getReplay() : nullptr; +} + +static std::string colorName(ImU32 c) { + char buf[16]; + snprintf(buf, sizeof(buf), "#%02x%02x%02x", (c >> IM_COL32_R_SHIFT) & 0xff, (c >> IM_COL32_G_SHIFT) & 0xff, (c >> IM_COL32_B_SHIFT) & 0xff); + return buf; +} + +// the zoomed range, or the whole route +static std::pair displayedTimeRange() { + return can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); +} + +// decode with libavcodec, already linked for the replay video decoder +static bool decodeJpeg(const uint8_t *data, size_t size, RgbImage *out) { + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_MJPEG); + AVCodecContext *context = codec ? avcodec_alloc_context3(codec) : nullptr; + AVFrame *frame = av_frame_alloc(); + AVPacket *packet = av_packet_alloc(); + bool ok = false; + if (context && frame && packet && size > 0 && size <= (size_t)INT32_MAX && av_new_packet(packet, (int)size) >= 0) { + std::copy(data, data + size, packet->data); + ok = avcodec_open2(context, codec, nullptr) >= 0 && avcodec_send_packet(context, packet) >= 0 && + avcodec_receive_frame(context, frame) >= 0 && frame->width > 0 && frame->height > 0; + } + int chroma_x_shift = 0, chroma_y_shift = 0; + if (ok) { + switch ((AVPixelFormat)frame->format) { + case AV_PIX_FMT_YUV420P: case AV_PIX_FMT_YUVJ420P: chroma_x_shift = 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: ok = false; break; + } + } + if (ok) { + out->resize(frame->width, frame->height); + 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; + const float y_scale = full_range ? 1.0f : 1.164383f; + const float y_offset = full_range ? 0.0f : 16.0f; + const float kr = full_range ? 1.402f : 1.596027f; + const float kgu = full_range ? 0.344136f : 0.391762f; + const float kgv = full_range ? 0.714136f : 0.812968f; + const float kb = full_range ? 1.772f : 2.017232f; + for (int y = 0; y < frame->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 *dst = out->data.data() + (size_t)y * out->bytesPerLine(); + for (int x = 0; x < frame->width; ++x) { + const float luma = y_scale * ((float)y_row[x] - y_offset); + const float u = (float)u_row[x >> chroma_x_shift] - 128.0f; + const float v = (float)v_row[x >> chroma_x_shift] - 128.0f; + const float r = luma + kr * v; + const float g = luma - kgu * u - kgv * v; + const float b = luma + kb * u; + dst[x * 4 + 0] = (uint8_t)std::clamp(std::lround(r), 0L, 255L); + dst[x * 4 + 1] = (uint8_t)std::clamp(std::lround(g), 0L, 255L); + dst[x * 4 + 2] = (uint8_t)std::clamp(std::lround(b), 0L, 255L); + dst[x * 4 + 3] = 255; + } + } + } + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return ok; +} + +VideoWidget::VideoWidget() { + if (!can->liveStreaming()) + createCameraWidget(); + + createSpeedDropdown(); + + connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { timeRangeChanged(); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { msgs_received_ = true; })); +} + +std::string VideoWidget::whatsThis() const { + // one
separated line per legend row, with the same entries and colors + return "Video
\n" + "Timeline color
\n" + + colorName(timeline_colors[(int)TimelineType::None]) + " Disengaged   " + + colorName(timeline_colors[(int)TimelineType::Engaged]) + " Engaged
\n" + + colorName(timeline_colors[(int)TimelineType::UserBookmark]) + " User Flag   " + + colorName(timeline_colors[(int)TimelineType::AlertInfo]) + " Info
\n" + + colorName(timeline_colors[(int)TimelineType::AlertWarning]) + " Warning   " + + colorName(timeline_colors[(int)TimelineType::AlertCritical]) + " Critical
\n" + "Shortcuts
\n" + "Pause/Resume:  space "; +} + +static float toolbarHeight() { return ImGui::GetStyle().ItemSpacing.y + ImGui::GetFrameHeight(); } + +void VideoWidget::drawPlaybackController() { + if (!can->liveStreaming()) + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + ImGui::GetStyle().ItemSpacing.y); + const float speed_width = menuButtonWidth("0.05x", true); + + const char *play_icon = can->isPaused() ? icon::PLAY : icon::PAUSE; + const char *play_tooltip = can->isPaused() ? "Play" : "Pause"; + const char *loop_icon = getReplay() && getReplay()->loop() ? icon::REPEAT : icon::REPEAT_1; + const bool timestamps_resolved = can->liveStreaming() + ? msgs_received_ + : can->beginDateTime() != std::chrono::system_clock::time_point{}; + const std::string time_text = timestamps_resolved + ? (slider_ ? formatTime(can->currentSec(), true) + " / " + formatTime(slider_->maximum() / slider_->factor) + : formatTime(can->currentSec(), true)) + : "--:--.-- / --:--.--"; + const char *time_tooltip = settings.absolute_time ? "Elapsed time" : "Absolute time"; + + std::vector items; + if (!can->liveStreaming()) { + items.push_back(toolbarAction("rewind", icon::REWIND, "Seek backward", []() { can->seekTo(can->currentSec() - 1); })); + } + items.push_back(toolbarAction("play", play_icon, play_tooltip, []() { can->pause(!can->isPaused()); })); + if (can->liveStreaming()) { + items.push_back(toolbarAction("skip-end", icon::SKIP_END, "Go live", [this]() { skipToEnd(); }, skip_to_end_enabled_)); + } else { + items.push_back(toolbarAction("fast-forward", icon::FAST_FORWARD, "Seek forward", []() { can->seekTo(can->currentSec() + 1); })); + } + if (slider_ || timestamps_resolved) { + // a mono font: with proportional digits the time changed width as it ticked and the items after it moved + pushMonoFont(ImGui::GetStyle().FontSizeBase); + const float time_width = ImGui::CalcTextSize(time_text.c_str()).x; + popMonoFont(); + items.push_back({time_width, + [&]() { + pushMonoFont(ImGui::GetStyle().FontSizeBase); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(time_text.c_str()); + popMonoFont(); + if (ImGui::IsItemClicked()) toggleTimeDisplay(); + ImGui::SetItemTooltip("%s", time_tooltip); + }, + time_tooltip, [this]() { toggleTimeDisplay(); }}); + } + // the expanding spacer: the items after it are right aligned as long as everything fits + const size_t spacer_index = items.size(); + auto separator = []() { + ToolbarItem item{TOOLBAR_SEPARATOR_EXTENT, []() { + // a 1 px separator line centered in TOOLBAR_SEPARATOR_EXTENT, inset from the top and bottom + const ImVec2 min = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(TOOLBAR_SEPARATOR_EXTENT, ImGui::GetFrameHeight())); + const float x = std::floor(min.x + TOOLBAR_SEPARATOR_EXTENT * 0.5f); + const float inset = ImGui::GetStyle().FramePadding.y; + ImGui::GetWindowDrawList()->AddLine(ImVec2(x, min.y + inset), ImVec2(x, min.y + ImGui::GetFrameHeight() - inset), + ImGui::GetColorU32(ImGuiCol_Separator)); + }}; + item.in_menu = false; + return item; + }; + const char *aspect_ratio_icon = settings.crop_video ? icon::ASPECT_RATIO_FILL : icon::ASPECT_RATIO; + if (!can->liveStreaming()) { + items.push_back(toolbarAction("crop_video", aspect_ratio_icon, "Crop to fill", [this]() { cropVideoClicked(); })); + items.push_back(separator()); + items.push_back(toolbarAction("loop", loop_icon, "Loop playback", [this]() { loopPlaybackClicked(); })); + items.push_back(toolbarMenu("speed_btn", speed_text_, "Speed", [this]() { drawSpeedMenuItems(); }, true, speed_width)); + items.push_back(separator()); + items.push_back(toolbarAction("route_info", icon::INFO_CIRCLE, "View route details", [this]() { showRouteInfo(); })); + } + + drawToolbar(items, spacer_index); +} + +void VideoWidget::skipToEnd() { + // set speed to 1.0; this only checks the menu entry, the speed and the button text are unchanged + speed_index_ = NORMAL_SPEED_INDEX; + can->pause(false); + can->seekTo(can->maxSeconds() + 1); +} + +void VideoWidget::toggleTimeDisplay() { + settings.absolute_time = !settings.absolute_time; +} + +static std::string speedText(float speed) { + char buf[32]; + snprintf(buf, sizeof(buf), "%gx", speed); + return buf; +} + +void VideoWidget::createSpeedDropdown() { + speed_index_ = NORMAL_SPEED_INDEX; + can->setSpeed(speeds[speed_index_]); + speed_text_ = speedText(speeds[speed_index_]); +} + +void VideoWidget::drawSpeedMenuItems() { + for (int i = 0; i < (int)std::size(speeds); ++i) { + const float speed = speeds[i]; + if (dropdown::Item(speedText(speed).c_str(), nullptr, speed_index_ == i)) { + speed_index_ = i; + can->setSpeed(speed); + speed_text_ = speedText(speed); + } + } +} + +void VideoWidget::createCameraWidget() { + camera_tab_ = std::make_unique(); + camera_tab_->setAutoHide(true); + + cam_widget_ = std::make_unique("camerad", VISION_STREAM_NARROW_ROAD); + + slider_ = std::make_unique(); + slider_->setTimeRange(can->minSeconds(), can->maxSeconds()); + + connections_.push_back(slider_->sliderReleased.connect([this]() { can->seekTo(slider_->currentSecond()); })); + connections_.push_back(cam_widget_->clicked.connect([]() { can->pause(!can->isPaused()); })); + connections_.push_back(cam_widget_->availableStreamsUpdated.connect([this](std::set streams) { vipcAvailableStreamsUpdated(streams); })); + connections_.push_back(camera_tab_->currentChanged.connect([this](int index) { + if (index != -1) cam_widget_->setStreamType((VisionStreamType)camera_tab_->tabData(index)); + })); + connections_.push_back(static_cast(can)->qLogLoaded.connect([this](std::shared_ptr qlog) { cam_widget_->parseQLog(qlog); })); + + if (auto *replay = getReplay(); replay && !replay->hasFlag(REPLAY_FLAG_NO_VIPC)) { + std::set streams; + for (const auto &[num, segment] : replay->route().segments()) { + if (!segment.narrow_road_cam.empty() || !segment.qcamera.empty()) streams.insert(VISION_STREAM_NARROW_ROAD); + if (replay->hasFlag(REPLAY_FLAG_CABIN_CAMERA) && !segment.cabin_cam.empty()) streams.insert(VISION_STREAM_CABIN); + if (replay->hasFlag(REPLAY_FLAG_WIDE_ROAD) && !segment.wide_road_cam.empty()) streams.insert(VISION_STREAM_WIDE_ROAD); + } + vipcAvailableStreamsUpdated(streams); + } +} + +void VideoWidget::drawCameraWidget() { + const float toolbar_height = toolbarHeight(); + // Camera tabs, video and timeline touch; restore the normal gap for the controls. + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + camera_tab_->draw(); + + // Reserve the timeline and playback controls even when the native dock is short. + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const float cam_height = std::max(1.0f, avail.y - SLIDER_HEIGHT - toolbar_height); + cam_widget_->draw(ImVec2(avail.x, cam_height), thumbnail_display_time_); + + if (!slider_->isSliderDown()) slider_->setCurrentSecond(can->currentSec()); + slider_->draw(thumbnail_display_time_); + updateSliderThumbnail(); + ImGui::PopStyleVar(); +} + +void VideoWidget::vipcAvailableStreamsUpdated(std::set streams) { + static const std::string stream_names[] = {"Road camera", "Driver camera", "Wide road camera"}; + for (int i = 0; i < streams.size(); ++i) { + if (camera_tab_->count() <= i) { + camera_tab_->addTab(std::string()); + } + int type = *std::next(streams.begin(), i); + camera_tab_->setTabText(i, stream_names[type]); + camera_tab_->setTabData(i, type); + } + while (camera_tab_->count() > streams.size()) { + camera_tab_->removeTab(camera_tab_->count() - 1); + } +} + +void VideoWidget::loopPlaybackClicked() { + getReplay()->setLoop(!getReplay()->loop()); +} + +void VideoWidget::cropVideoClicked() { + settings.crop_video = !settings.crop_video; + settings.changed(); +} + +void VideoWidget::timeRangeChanged() { + const auto time_range = can->timeRange(); + if (can->liveStreaming()) { + skip_to_end_enabled_ = !time_range.has_value(); + return; + } + time_range ? slider_->setTimeRange(time_range->first, time_range->second) + : slider_->setTimeRange(can->minSeconds(), can->maxSeconds()); +} + +std::string VideoWidget::formatTime(double sec, bool include_milliseconds) { + if (settings.absolute_time) + sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); + return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time); +} + +void VideoWidget::setVisible(bool visible) { + if (cam_widget_) cam_widget_->setVisible(visible); +} + +void VideoWidget::showThumbnail(double seconds) { + if (can->liveStreaming()) return; + thumbnail_display_time_ = seconds; +} + +void VideoWidget::showRouteInfo() { + // dropped from route_info_dlgs_ once draw() returns false + route_info_dlgs_.push_back(std::make_unique()); +} + +void VideoWidget::updateSliderThumbnail() { + if (slider_->underMouse()) { + auto [min_sec, max_sec] = displayedTimeRange(); + showThumbnail(min_sec + (ImGui::GetMousePos().x - slider_->rect().Min.x) * (max_sec - min_sec) / slider_->width()); + } else if (slider_->mouseLeft()) { + showThumbnail(-1); + } +} + +float VideoWidget::sizeHintHeight() const { + // the camera minimum height plus the slider and the toolbar + return MIN_VIDEO_HEIGHT + SLIDER_HEIGHT + toolbarHeight(); +} + +// Keep the pane's default proportions stable as frames arrive or cameras change. +float VideoWidget::defaultHeight(float width) const { + if (!cam_widget_) return ImGui::GetFrameHeight(); // live streams have no camera or slider + const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, width / DEFAULT_CAMERA_ASPECT_RATIO); + const float tab_height = camera_tab_->count() >= 2 ? ImGui::GetFrameHeight() : 0.0f; + return cam_height + tab_height + SLIDER_HEIGHT + toolbarHeight(); +} + +void VideoWidget::draw() { + if (!can->liveStreaming()) + drawCameraWidget(); + + drawPlaybackController(); + + for (auto it = route_info_dlgs_.begin(); it != route_info_dlgs_.end();) { + it = (*it)->draw() ? it + 1 : route_info_dlgs_.erase(it); + } +} + +void Slider::draw(double thumbnail_time) { + ImGui::InvisibleButton("##slider", ImVec2(std::max(1.0f, ImGui::GetContentRegionAvail().x), SLIDER_HEIGHT)); + rect_ = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + const bool hovered = ImGui::IsItemHovered(); + left_ = hovered_ && !hovered; + hovered_ = hovered; + + if (ImGui::IsItemActivated()) handleMousePress(); + if (slider_down_) { + if (ImGui::IsItemActive()) { + // the handle keeps its grab offset while dragging + setValue(pixelPosToRangeValue(ImGui::GetMousePos().x - click_offset_)); + } else { + slider_down_ = false; + sliderReleased(); + } + } + paint(thumbnail_time); +} + +ImRect Slider::handleRect() const { + const float handle_width = SLIDER_LENGTH; + const float handle_height = std::min(SLIDER_THICKNESS, rect_.GetHeight()); + const int range = std::max(1, maximum() - minimum()); + const float x = rect_.Min.x + (float)(value() - minimum()) / range * std::max(0.0f, width() - handle_width); + const float y = rect_.GetCenter().y - handle_height / 2; + return ImRect(ImVec2(x, y), ImVec2(x + handle_width, y + handle_height)); +} + +// handle left edge (window x) -> value over the groove minus the handle width +int Slider::pixelPosToRangeValue(float x) const { + const float handle_width = SLIDER_LENGTH; + const float span = std::max(1.0f, width() - handle_width); + return minimum() + (int)std::lround((maximum() - minimum()) * std::clamp((x - rect_.Min.x) / span, 0.0f, 1.0f)); +} + +void Slider::paint(double thumbnail_time) { + ImDrawList *p = ImGui::GetWindowDrawList(); + + ImRect handle_rect = handleRect(); + ImRect groove_rect = rect_; + + // adjust the groove height to match the handle height, rounded up to whole pixels + float handle_height = handle_rect.GetHeight(); + const float groove_height = std::ceil(handle_height * 0.5f); + const float center_y = rect_.GetCenter().y; + groove_rect.Min.y = std::floor(center_y - groove_height / 2); + groove_rect.Max.y = groove_rect.Min.y + groove_height; + + p->AddRectFilled(groove_rect.Min, groove_rect.Max, toImU32(graphicColor(fromImVec4(ImGui::ColorConvertU32ToFloat4(timeline_colors[(int)TimelineType::None])), palette().window)), groove_height * 0.5f); + + double min = minimum() / factor; + double max = maximum() / factor; + const double span = std::max(max - min, 1e-9); + + auto fillRange = [&](double begin, double end, ImU32 color) { + if (begin > max || end < min) return; + + // the edges truncate to whole pixels and the right edge is inclusive, so even an event shorter than a + // pixel paints one full pixel in its color instead of an anti-aliased smear + ImRect r = groove_rect; + r.Min.x = rect_.Min.x + std::floor(((std::max(min, begin) - min) / span) * width()); + r.Max.x = rect_.Min.x + std::floor(((std::min(max, end) - min) / span) * width()) + 1.0f; + p->AddRectFilled(r.Min, r.Max, color); + }; + + if (auto replay = getReplay()) { + for (const auto &entry : *replay->getTimeline()) { + fillRange(entry.start_time, entry.end_time, toImU32(graphicColor(fromImVec4(ImGui::ColorConvertU32ToFloat4(timeline_colors[(int)entry.type])), palette().window))); + } + + ImU32 empty_color = ImGui::GetColorU32(ImGuiCol_WindowBg, 160 / 255.0f); + const auto event_data = replay->getEventData(); + for (const auto &[n, _] : replay->route().segments()) { + if (!event_data->isSegmentLoaded(n)) + fillRange(n * 60.0, (n + 1) * 60.0, empty_color); + } + } + + drawSliderHandle(p, handle_rect); + + if (thumbnail_time >= 0) { + float left = rect_.Min.x + (float)((thumbnail_time - min) * width() / span) - 1; + ImRect rc(ImVec2(left, rect_.Min.y + 1), ImVec2(left + 2, rect_.Max.y - 1)); + p->AddRectFilled(rc.Min, rc.Max, ImGui::GetColorU32(ImGuiCol_Header), 1.0f); + } +} + +void Slider::handleMousePress() { + // a press on the handle starts a drag and remembers the grab offset + const ImRect handle_rect = handleRect(); + if (handle_rect.Contains(ImGui::GetMousePos())) { + slider_down_ = true; + click_offset_ = ImGui::GetMousePos().x - handle_rect.Min.x; + return; + } + setValue(minimum() + (int)(((maximum() - minimum()) * (ImGui::GetMousePos().x - rect_.Min.x)) / width())); + sliderReleased(); +} + +StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type) + : CameraWidget(stream_name, stream_type) { + big_thumbnail_texture_.mipmap = true; // the hover thumbnail is drawn at a quarter of the stored size +} + +StreamCameraView::~StreamCameraView() { + for (auto &pending : pending_thumbnails_) pending.done.wait(); +} + +void StreamCameraView::parseQLog(std::shared_ptr qlog) { + auto thumbnails = std::make_shared>(); + auto done = ThreadPool::instance().run([qlog, thumbnails]() { + for (const Event &e : qlog->events) { + if (e.which != cereal::Event::Which::THUMBNAIL) continue; + capnp::FlatArrayMessageReader reader(e.data); + auto thumb_data = reader.getRoot().getThumbnail(); + auto image_data = thumb_data.getThumbnail(); + if (RgbImage thumb; decodeJpeg(image_data.begin(), image_data.size(), &thumb)) { + (*thumbnails)[thumb_data.getTimestampEof()] = std::move(thumb); + } + } + }); + pending_thumbnails_.push_back({std::move(done), std::move(thumbnails)}); +} + +void StreamCameraView::collectThumbnails() { + for (auto it = pending_thumbnails_.begin(); it != pending_thumbnails_.end();) { + if (it->done.wait_for(std::chrono::seconds(0)) != std::future_status::ready) { + ++it; + continue; + } + for (auto &[ts, thumb] : *it->thumbnails) big_thumbnails_[ts] = std::move(thumb); + it = pending_thumbnails_.erase(it); + } +} + +void StreamCameraView::draw(const ImVec2 &size, double thumbnail_time) { + collectThumbnails(); + CameraWidget::draw(size); + + ImDrawList *p = ImGui::GetWindowDrawList(); + bool scrubbing = false; + if (thumbnail_time >= 0) { + scrubbing = can->isPaused(); + scrubbing ? drawScrubThumbnail(p, thumbnail_time) : drawThumbnail(p, thumbnail_time); + } + if (auto alert = getReplay()->findAlertAtTime(scrubbing ? thumbnail_time : can->currentSec())) { + drawAlert(p, rect(), *alert, ImGui::GetFontSize(), ImGui::GetStyle().ChildRounding); + } + + if (can->isPaused()) { + ImFont *font = boldFont(); + const char *text = "PAUSED"; + const ImVec2 text_size = font->CalcTextSizeA(POINT_16_FONT_SIZE, FLT_MAX, 0.0f, text); + const ImVec2 center = rect().GetCenter(); + const ImVec2 pos(center.x - text_size.x / 2, center.y - text_size.y / 2); + p->AddRectFilled(ImVec2(pos.x - 4, pos.y - 2), ImVec2(pos.x + text_size.x + 4, pos.y + text_size.y + 2), ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); + p->AddText(font, POINT_16_FONT_SIZE, pos, ImGui::GetColorU32(palette().text_selected), text); + } +} + +const RgbImage *StreamCameraView::thumbnailAt(double sec) { + auto it = big_thumbnails_.lower_bound(can->toMonoTime(sec)); + if (it == big_thumbnails_.end()) return nullptr; + if (big_thumbnail_texture_.id == 0 || big_thumbnail_texture_.key != it->first) { + big_thumbnail_texture_.upload(it->second); + big_thumbnail_texture_.key = it->first; + } + return &it->second; +} + +void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { + p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255), ImGui::GetStyle().ChildRounding); + if (const RgbImage *image = thumbnailAt(sec)) { + const VideoPlacement placement = videoPlacement(rect(), (float)image->width / image->height, settings.crop_video); + drawVideoFrame(p, big_thumbnail_texture_.ref(), rect(), placement); + drawTime(p, rect(), sec); + } +} + +void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { + if (const RgbImage *image = thumbnailAt(sec)) { + // AddImage scales the stored image to the thumbnail height, keeping the aspect ratio + const int h = MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2; + const int w = std::max(1, (int)std::lround((double)image->width * h / image->height)); + auto [min_sec, max_sec] = displayedTimeRange(); + int pos = (sec - min_sec) * width() / (max_sec - min_sec); + const int max_x = (int)width() - w - THUMBNAIL_MARGIN + 1; + int x = std::clamp(pos - w / 2, THUMBNAIL_MARGIN, std::max(THUMBNAIL_MARGIN, max_x)); + int y = height() - h - THUMBNAIL_MARGIN; + + ImRect thumb_rect(ImVec2(rect().Min.x + x, rect().Min.y + y), ImVec2(rect().Min.x + x + w, rect().Min.y + y + h)); + p->AddImageRounded(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max, ImVec2(0, 0), ImVec2(1, 1), IM_COL32_WHITE, ImGui::GetStyle().FrameRounding); + p->AddRect(thumb_rect.Min, thumb_rect.Max, IM_COL32_WHITE, ImGui::GetStyle().FrameRounding, 0, 2.0f); + // look up the alert at the hovered time, the thumbnail frame itself can be seconds away + if (auto alert = getReplay()->findAlertAtTime(sec)) { + drawAlert(p, thumb_rect, *alert, POINT_10_FONT_SIZE, ImGui::GetStyle().FrameRounding); + } + drawTime(p, thumb_rect, sec); + } +} + +void StreamCameraView::drawTime(ImDrawList *p, const ImRect &rect, double seconds) { + char text[32]; + snprintf(text, sizeof(text), "%.2f", seconds); + ImFont *font = ImGui::GetFont(); + const ImVec2 text_size = font->CalcTextSizeA(POINT_10_FONT_SIZE, FLT_MAX, 0.0f, text); + // centered horizontally, above the bottom margin + const ImVec2 pos(rect.GetCenter().x - text_size.x / 2, rect.Max.y - THUMBNAIL_MARGIN - text_size.y); + p->AddRectFilled(ImVec2(pos.x - 4, pos.y - 2), ImVec2(pos.x + text_size.x + 4, pos.y + text_size.y + 2), ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); + p->AddText(font, POINT_10_FONT_SIZE, pos, ImGui::GetColorU32(palette().text_selected), text); +} + +void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size, float rounding) { + const ImU32 pen = IM_COL32_WHITE; + // Opaque backing keeps alert text readable over every camera frame. + const auto source = fromImVec4(ImGui::ColorConvertU32ToFloat4(timeline_colors[int(alert.type)])); + const ImU32 color = toImU32(contrastColor(source, fromImVec4(palette().text_selected))); + std::string text = alert.text1; + if (!alert.text2.empty()) text += "\n" + alert.text2; + + const ImRect &text_rect = rect; + ImFont *font = ImGui::GetFont(); + const float wrap_width = std::max(1.0f, text_rect.GetWidth()); + const ImVec2 r = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, text.c_str()); + p->AddRectFilled(ImVec2(text_rect.Min.x, text_rect.Min.y), ImVec2(text_rect.Max.x, text_rect.Min.y + r.y), color, rounding, ImDrawFlags_RoundCornersTop); + // each line is centered, wrapped continuations stay left aligned + float y = text_rect.Min.y; + for (const auto &line : utils::split(text, '\n')) { + const ImVec2 line_size = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, line.c_str()); + p->AddText(font, font_size, ImVec2(text_rect.Min.x + (text_rect.GetWidth() - line_size.x) / 2, y), pen, line.c_str(), nullptr, wrap_width); + y += line_size.y; + } +} diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h new file mode 100644 index 0000000000..94b50dbf3f --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -0,0 +1,121 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/ui/widgets/cameraview.h" +#include "tools/cabana/ui/widgets/tabbar.h" +#include "tools/cabana/ui/tools/routeinfo.h" +#include "tools/replay/logreader.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/icons.h" + +class Slider { +public: + Slider() = default; + double currentSecond() const { return value() / factor; } + void setCurrentSecond(double sec) { setValue(sec * factor); } + void setTimeRange(double min, double max) { setRange(min * factor, max * factor); } + int value() const { return value_; } + void setValue(int v) { value_ = std::clamp(v, minimum_, maximum_); } + void setRange(int min, int max) { minimum_ = min; maximum_ = std::max(min, max); setValue(value_); } + int minimum() const { return minimum_; } + int maximum() const { return maximum_; } + bool isSliderDown() const { return slider_down_; } + float width() const { return rect_.GetWidth(); } + const ImRect &rect() const { return rect_; } + bool underMouse() const { return hovered_; } + bool mouseLeft() const { return left_; } // the mouse left the slider in the last draw() + void draw(double thumbnail_time); // thumbnail_time < 0: no thumbnail marker + static constexpr double factor = 1000.0; + + Observable<> sliderReleased; + +private: + void handleMousePress(); + void paint(double thumbnail_time); + ImRect handleRect() const; + int pixelPosToRangeValue(float x) const; + int minimum_ = 0; + int maximum_ = 99; + int value_ = 0; + bool slider_down_ = false; + float click_offset_ = 0; // where inside the handle the drag started + bool hovered_ = false; + bool left_ = false; + ImRect rect_; +}; + +class StreamCameraView : public CameraWidget { +public: + StreamCameraView(std::string stream_name, VisionStreamType stream_type); + ~StreamCameraView(); + void draw(const ImVec2 &size, double thumbnail_time); // thumbnail_time < 0: no thumbnail + void parseQLog(std::shared_ptr qlog); // decodes the thumbnails on the thread pool + +private: + struct PendingThumbnails { + std::future done; + std::shared_ptr> thumbnails; + }; + void collectThumbnails(); // moves the decoded thumbnails in once a parseQLog task is done + // the first thumbnail at or after sec, uploaded to big_thumbnail_texture_; nullptr when there is none + const RgbImage *thumbnailAt(double sec); + void drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size, float rounding); + void drawThumbnail(ImDrawList *p, double sec); + void drawScrubThumbnail(ImDrawList *p, double sec); + void drawTime(ImDrawList *p, const ImRect &rect, double seconds); + + std::map big_thumbnails_; + GlTexture big_thumbnail_texture_; // the currently shown thumbnail + std::vector pending_thumbnails_; +}; + +class VideoWidget { +public: + VideoWidget(); + void draw(); // content only; MainWindow puts it in a child region above the charts + float sizeHintHeight() const; + float defaultHeight(float width) const; + // MainWindow calls this every frame with the video dock visibility, so the camera widget gets its + // vipc thread started and stopped + void setVisible(bool visible); + std::string whatsThis() const; + +private: + void showThumbnail(double seconds); + void updateSliderThumbnail(); // the thumbnail follows the mouse over the slider + std::string formatTime(double sec, bool include_milliseconds = false); + void timeRangeChanged(); + void createCameraWidget(); + void drawCameraWidget(); + void drawPlaybackController(); + void skipToEnd(); + void toggleTimeDisplay(); + void createSpeedDropdown(); + void drawSpeedMenuItems(); + void loopPlaybackClicked(); + void cropVideoClicked(); + void vipcAvailableStreamsUpdated(std::set streams); + void showRouteInfo(); + + std::unique_ptr cam_widget_; + std::string speed_text_; + int speed_index_ = -1; // checked entry of the speed menu + bool skip_to_end_enabled_ = true; + bool msgs_received_ = false; // live-stream timestamps resolve when the first messages arrive + double thumbnail_display_time_ = -1; + std::unique_ptr slider_; + std::unique_ptr camera_tab_; + std::vector> route_info_dlgs_; + Connections connections_; // last: disconnected before the widgets its handlers dereference are destroyed +}; diff --git a/openpilot/tools/cabana/utils/elidedlabel.cc b/openpilot/tools/cabana/utils/elidedlabel.cc deleted file mode 100644 index 629a108b16..0000000000 --- a/openpilot/tools/cabana/utils/elidedlabel.cc +++ /dev/null @@ -1,29 +0,0 @@ -#include "tools/cabana/utils/elidedlabel.h" -#include -#include - -ElidedLabel::ElidedLabel(QWidget *parent) : ElidedLabel({}, parent) {} - -ElidedLabel::ElidedLabel(const QString &text, QWidget *parent) : QLabel(text.trimmed(), parent) { - setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - setMinimumWidth(1); -} - -void ElidedLabel::resizeEvent(QResizeEvent* event) { - QLabel::resizeEvent(event); - lastText_ = elidedText_ = ""; -} - -void ElidedLabel::paintEvent(QPaintEvent *event) { - const QString curText = text(); - if (curText != lastText_) { - elidedText_ = fontMetrics().elidedText(curText, Qt::ElideRight, contentsRect().width()); - lastText_ = curText; - } - - QPainter painter(this); - drawFrame(&painter); - QStyleOption opt; - opt.initFrom(this); - style()->drawItemText(&painter, contentsRect(), alignment(), opt.palette, isEnabled(), elidedText_, foregroundRole()); -} diff --git a/openpilot/tools/cabana/utils/elidedlabel.h b/openpilot/tools/cabana/utils/elidedlabel.h deleted file mode 100644 index 577eea12a0..0000000000 --- a/openpilot/tools/cabana/utils/elidedlabel.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include -#include - -class ElidedLabel : public QLabel { - Q_OBJECT - -public: - explicit ElidedLabel(QWidget *parent = 0); - explicit ElidedLabel(const QString &text, QWidget *parent = 0); - -signals: - void clicked(); - -protected: - void paintEvent(QPaintEvent *event) override; - void resizeEvent(QResizeEvent* event) override; - void mouseReleaseEvent(QMouseEvent *event) override { - if (rect().contains(event->pos())) { - emit clicked(); - } - } - QString lastText_, elidedText_; -}; diff --git a/openpilot/tools/cabana/utils/qtutil.cc b/openpilot/tools/cabana/utils/qtutil.cc deleted file mode 100644 index 040c90c624..0000000000 --- a/openpilot/tools/cabana/utils/qtutil.cc +++ /dev/null @@ -1,236 +0,0 @@ -#include "tools/cabana/utils/qtutil.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -// MessageBytesDelegate - -MessageBytesDelegate::MessageBytesDelegate(QObject *parent, bool multiple_lines) - : font_metrics(QApplication::font()), multiple_lines(multiple_lines), QStyledItemDelegate(parent) { - fixed_font = QFontDatabase::systemFont(QFontDatabase::FixedFont); - byte_size = QFontMetrics(fixed_font).size(Qt::TextSingleLine, "00 ") + QSize(0, 2); - for (int i = 0; i < 256; ++i) { - hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper()); - hex_text_table[i].prepare({}, fixed_font); - } - h_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; - v_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameVMargin) + 1; -} - -QSize MessageBytesDelegate::sizeForBytes(int n) const { - int rows = multiple_lines ? std::max(1, n / 8) : 1; - return {(n / rows) * byte_size.width() + h_margin * 2, rows * byte_size.height() + v_margin * 2}; -} - -QSize MessageBytesDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto data = index.data(BytesRole); - return sizeForBytes(data.isValid() ? static_cast *>(data.value())->size() : 0); -} - -void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - if (option.state & QStyle::State_Selected) { - painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight)); - } - - QRect item_rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin); - QColor highlighted_color = option.palette.color(QPalette::HighlightedText); - auto text_color = index.data(Qt::ForegroundRole).value(); - bool inactive = text_color.isValid(); - if (!inactive) { - text_color = option.palette.color(QPalette::Text); - } - auto data = index.data(BytesRole); - if (!data.isValid()) { - painter->setFont(option.font); - painter->setPen(option.state & QStyle::State_Selected ? highlighted_color : text_color); - QString text = font_metrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, item_rect.width()); - painter->drawText(item_rect, Qt::AlignLeft | Qt::AlignVCenter, text); - return; - } - - // Paint hex column - const auto &bytes = *static_cast *>(data.value()); - const auto &colors = *static_cast *>(index.data(ColorsRole).value()); - - painter->setFont(fixed_font); - const QPen text_pen(option.state & QStyle::State_Selected ? highlighted_color : text_color); - const QPoint pt = item_rect.topLeft(); - for (int i = 0; i < bytes.size(); ++i) { - int row = !multiple_lines ? 0 : i / 8; - int column = !multiple_lines ? i : i % 8; - QRect r({pt.x() + column * byte_size.width(), pt.y() + row * byte_size.height()}, byte_size); - - if (!inactive && i < colors.size() && colors[i].alpha() > 0) { - if (option.state & QStyle::State_Selected) { - painter->setPen(option.palette.color(QPalette::Text)); - painter->fillRect(r, option.palette.color(QPalette::Window)); - } - painter->fillRect(r, toQColor(colors[i])); - } else { - painter->setPen(text_pen); - } - utils::drawStaticText(painter, r, hex_text_table[bytes[i]]); - } -} - -// TabBar - -int TabBar::addTab(const QString &text) { - int index = QTabBar::addTab(text); - QToolButton *btn = new ToolButton("x", tr("Close Tab")); - int width = style()->pixelMetric(QStyle::PM_TabCloseIndicatorWidth, nullptr, btn); - int height = style()->pixelMetric(QStyle::PM_TabCloseIndicatorHeight, nullptr, btn); - btn->setFixedSize({width, height}); - setTabButton(index, QTabBar::RightSide, btn); - QObject::connect(btn, &QToolButton::clicked, this, &TabBar::closeTabClicked); - return index; -} - -void TabBar::closeTabClicked() { - QObject *object = sender(); - for (int i = 0; i < count(); ++i) { - if (tabButton(i, QTabBar::RightSide) == object) { - emit tabCloseRequested(i); - break; - } - } -} - -// validators - -static QValidator::State toQtState(ValidState s) { - switch (s) { - case ValidState::Acceptable: return QValidator::Acceptable; - case ValidState::Intermediate: return QValidator::Intermediate; - default: return QValidator::Invalid; - } -} - -QValidator::State NameValidator::validate(QString &input, int &pos) const { - std::string s = input.toStdString(); - auto state = validateName(s); - input = QString::fromStdString(s); - return toQtState(state); -} - -QValidator::State NodeValidator::validate(QString &input, int &pos) const { - return toQtState(validateNodes(input.toStdString())); -} - -QValidator::State NonWhitespaceValidator::validate(QString &input, int &pos) const { - return toQtState(validateNonWhitespace(input.toStdString())); -} - -QValidator::State IpAddressValidator::validate(QString &input, int &pos) const { - return toQtState(validateIpAddress(input.toStdString())); -} - -QValidator::State DoubleValidator::validate(QString &input, int &pos) const { - return toQtState(validateDouble(input.toLatin1().toStdString())); -} - -namespace utils { - -bool isDarkTheme() { - QColor windowColor = QApplication::palette().color(QPalette::Window); - return windowColor.lightness() < 128; -} - -QPixmap icon(const QString &id) { - bool dark_theme = isDarkTheme(); - - QPixmap pm; - QString key = "bootstrap_" % id % (dark_theme ? "1" : "0"); - if (!QPixmapCache::find(key, &pm)) { - pm = bootstrapPixmap(id); - if (dark_theme) { - QPainter p(&pm); - p.setCompositionMode(QPainter::CompositionMode_SourceIn); - p.fillRect(pm.rect(), QColor("#bbbbbb")); - } - QPixmapCache::insert(key, pm); - } - return pm; -} - -void setTheme(int theme) { - auto style = QApplication::style(); - if (!style) return; - - static int prev_theme = 0; - if (theme != prev_theme) { - prev_theme = theme; - QPalette new_palette; - if (theme == DARK_THEME) { - new_palette.setColor(QPalette::Window, toQColor(DarkTheme::window)); - new_palette.setColor(QPalette::WindowText, toQColor(DarkTheme::window_text)); - new_palette.setColor(QPalette::Base, toQColor(DarkTheme::base)); - new_palette.setColor(QPalette::AlternateBase, toQColor(DarkTheme::base)); - new_palette.setColor(QPalette::ToolTipBase, toQColor(DarkTheme::base)); - new_palette.setColor(QPalette::ToolTipText, toQColor(DarkTheme::tooltip_text)); - new_palette.setColor(QPalette::Text, toQColor(DarkTheme::text)); - new_palette.setColor(QPalette::Button, toQColor(DarkTheme::button)); - new_palette.setColor(QPalette::ButtonText, toQColor(DarkTheme::window_text)); - new_palette.setColor(QPalette::Highlight, toQColor(DarkTheme::highlight)); - new_palette.setColor(QPalette::HighlightedText, toQColor(DarkTheme::window_text)); - new_palette.setColor(QPalette::BrightText, toQColor(DarkTheme::bright_text)); - new_palette.setColor(QPalette::Disabled, QPalette::ButtonText, toQColor(DarkTheme::disabled_text)); - new_palette.setColor(QPalette::Disabled, QPalette::WindowText, toQColor(DarkTheme::disabled_text)); - new_palette.setColor(QPalette::Disabled, QPalette::Text, toQColor(DarkTheme::disabled_text)); - new_palette.setColor(QPalette::Light, toQColor(DarkTheme::light)); - new_palette.setColor(QPalette::Dark, toQColor(DarkTheme::dark)); - } else { - new_palette = style->standardPalette(); - } - qApp->setPalette(new_palette); - style->polish(qApp); - for (auto w : QApplication::allWidgets()) { - w->setPalette(new_palette); - } - } -} - -} // namespace utils - -void sigTermHandler(int s) { - std::signal(s, SIG_DFL); - qApp->quit(); -} - -void initApp(int argc, char *argv[], bool disable_hidpi) { - // setup signal handlers to exit gracefully - std::signal(SIGINT, sigTermHandler); - std::signal(SIGTERM, sigTermHandler); - -#ifdef __APPLE__ - // Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering - QApplication tmp(argc, argv); - if (disable_hidpi) { - qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit()); - } -#endif - - qputenv("QT_DBL_CLICK_DIST", "150"); - // ensure the current dir matches the exectuable's directory - std::error_code ec; - std::filesystem::current_path(executableDir(), ec); -} - -QPixmap bootstrapPixmap(const QString &id) { - QPixmap pixmap; - const std::string svg = utils::bootstrapSvg(id.toStdString()); - if (!svg.empty()) { - pixmap.loadFromData((const uchar *)svg.data(), svg.size(), "svg"); - } - return pixmap; -} diff --git a/openpilot/tools/cabana/utils/qtutil.h b/openpilot/tools/cabana/utils/qtutil.h deleted file mode 100644 index db0417ec9a..0000000000 --- a/openpilot/tools/cabana/utils/qtutil.h +++ /dev/null @@ -1,139 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/core/observable.h" -#include "tools/cabana/dbc/dbc.h" -#include "tools/cabana/settings.h" -#include "tools/cabana/utils/strings.h" -#include "tools/cabana/utils/util.h" - -// needed by QVariant::fromValue() in the Qt views; goes away with QVariant -Q_DECLARE_METATYPE(MessageId) -Q_DECLARE_METATYPE(ValueDescription) - -inline QColor toQColor(const CabanaColor &color) { - return QColor(color.r, color.g, color.b, color.a); -} - -class LogSlider : public QSlider { - Q_OBJECT - -public: - LogSlider(double factor, Qt::Orientation orientation, QWidget *parent = nullptr) : scale(factor), QSlider(orientation, parent) {} - - void setRange(double min, double max) { - scale.setRange(min, max); - QSlider::setRange(min, max); - setValue(QSlider::value()); - } - int value() const { return scale.value(QSlider::value(), minimum(), maximum()); } - void setValue(int v) { QSlider::setValue(scale.position(v, minimum(), maximum())); } - -private: - LogScale scale; -}; - -enum { - ColorsRole = Qt::UserRole + 1, - BytesRole = Qt::UserRole + 2 -}; - -class MessageBytesDelegate : public QStyledItemDelegate { - Q_OBJECT -public: - MessageBytesDelegate(QObject *parent, bool multiple_lines = false); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; - bool multipleLines() const { return multiple_lines; } - void setMultipleLines(bool v) { multiple_lines = v; } - QSize sizeForBytes(int n) const; - -private: - std::array hex_text_table; - QFontMetrics font_metrics; - QFont fixed_font; - QSize byte_size = {}; - bool multiple_lines = false; - int h_margin, v_margin; -}; - -// QValidator wrappers around the std::string validators in util.h -#define CABANA_VALIDATOR(Name) \ - class Name : public QValidator { \ - Q_OBJECT \ - public: \ - Name(QObject *parent = nullptr) : QValidator(parent) {} \ - QValidator::State validate(QString &input, int &pos) const override; \ - }; -CABANA_VALIDATOR(NameValidator) -CABANA_VALIDATOR(NodeValidator) -CABANA_VALIDATOR(NonWhitespaceValidator) -CABANA_VALIDATOR(IpAddressValidator) -CABANA_VALIDATOR(DoubleValidator) -#undef CABANA_VALIDATOR - -namespace utils { - -QPixmap icon(const QString &id); -bool isDarkTheme(); -void setTheme(int theme); -inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) { - auto size = (r.size() - text.size()) / 2; - p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text); -} -inline auto qbytes(const std::vector &dat) { - return decltype(QString().toUtf8())((const char *)dat.data(), (int)dat.size()); -} - -} - -class ToolButton : public QToolButton { - Q_OBJECT -public: - ToolButton(const QString &icon, const QString &tooltip = {}, QWidget *parent = nullptr) : QToolButton(parent) { - setIcon(icon); - setToolTip(tooltip); - setAutoRaise(true); - const int metric = QApplication::style()->pixelMetric(QStyle::PM_SmallIconSize); - setIconSize({metric, metric}); - theme = settings.theme; - settings_connection_ = settings.changed.connect([this]() { updateIcon(); }); - } - void setIcon(const QString &icon) { - icon_str = icon; - QToolButton::setIcon(utils::icon(icon_str)); - } - -private: - void updateIcon() { if (std::exchange(theme, settings.theme) != theme) setIcon(icon_str); } - Connection settings_connection_; - QString icon_str; - int theme; -}; - -class TabBar : public QTabBar { - Q_OBJECT - -public: - TabBar(QWidget *parent) : QTabBar(parent) {} - int addTab(const QString &text); - -private: - void closeTabClicked(); -}; - -void initApp(int argc, char *argv[], bool disable_hidpi = true); -QPixmap bootstrapPixmap(const QString &id); diff --git a/openpilot/tools/cabana/utils/strings.cc b/openpilot/tools/cabana/utils/strings.cc index 5590b6078e..3a1191609e 100644 --- a/openpilot/tools/cabana/utils/strings.cc +++ b/openpilot/tools/cabana/utils/strings.cc @@ -21,7 +21,7 @@ std::string formatSeconds(double sec, bool include_milliseconds, bool absolute_t char buf[64] = {}; std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); if (!include_milliseconds) return buf; - snprintf(out, sizeof(out), "%s.%03d", buf, millis); + snprintf(out, sizeof(out), "%s.%02d", buf, millis / 10); return out; } @@ -33,11 +33,11 @@ std::string formatSeconds(double sec, bool include_milliseconds, bool absolute_t const int seconds = (total_ms / 1000) % 60; const int millis = total_ms % 1000; if (show_hours && include_milliseconds) { - snprintf(out, sizeof(out), "%02d:%02d:%02d.%03d", hours, minutes, seconds, millis); + snprintf(out, sizeof(out), "%02d:%02d:%02d.%02d", hours, minutes, seconds, millis / 10); } else if (show_hours) { snprintf(out, sizeof(out), "%02d:%02d:%02d", hours, minutes, seconds); } else if (include_milliseconds) { - snprintf(out, sizeof(out), "%02d:%02d.%03d", minutes, seconds, millis); + snprintf(out, sizeof(out), "%02d:%02d.%02d", minutes, seconds, millis / 10); } else { snprintf(out, sizeof(out), "%02d:%02d", minutes, seconds); } diff --git a/openpilot/tools/cabana/utils/strings.h b/openpilot/tools/cabana/utils/strings.h index f6581c3164..1432ea9257 100644 --- a/openpilot/tools/cabana/utils/strings.h +++ b/openpilot/tools/cabana/utils/strings.h @@ -1,7 +1,11 @@ #pragma once +#include +#include +#include #include #include +#include #include #include @@ -12,6 +16,78 @@ namespace utils { std::string formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false); std::string signalToolTip(const cabana::Signal *sig); +inline std::string trimmed(const std::string &s) { + const char *ws = " \t\n\r\f\v"; + size_t b = s.find_first_not_of(ws); + if (b == std::string::npos) return ""; + return s.substr(b, s.find_last_not_of(ws) - b + 1); +} + +inline bool containsCI(const std::string &s, const std::string &txt) { + auto it = std::search(s.begin(), s.end(), txt.begin(), txt.end(), + [](unsigned char a, unsigned char b) { return std::tolower(a) == std::tolower(b); }); + return it != s.end(); +} + +// drop the tags of a rich text tooltip for the plain text imgui tooltip +inline std::string stripHtml(const std::string &s) { + std::string out; + bool in_tag = false; + for (char c : s) { + if (c == '<') in_tag = true; + else if (c == '>') in_tag = false; + else if (!in_tag) out += c; + } + return trimmed(out); +} + +inline std::vector split(const std::string &s, char sep) { + std::vector parts; + size_t start = 0; + for (size_t pos; (pos = s.find(sep, start)) != std::string::npos; start = pos + 1) { + parts.push_back(s.substr(start, pos - start)); + } + parts.push_back(s.substr(start)); + return parts; +} + +inline std::string toString(double v) { + char buf[32]; + snprintf(buf, sizeof(buf), "%g", v); + return buf; +} + +// 0 when the text is not a valid number +inline double toDouble(const std::string &s) { + char *end = nullptr; + double v = std::strtod(s.c_str(), &end); + return (end != s.c_str() && *end == '\0') ? v : 0.0; +} + +// 0 when the text is not fully consumed +inline int toInt(const std::string &s) { + char *end = nullptr; + long v = std::strtol(s.c_str(), &end, 10); + return (end != s.c_str() && *end == '\0') ? (int)v : 0; +} + +// 0 when the text is not fully consumed +inline unsigned long toULong(const std::string &s, int base = 10) { + char *end = nullptr; + unsigned long v = std::strtoul(s.c_str(), &end, base); + return (end != s.c_str() && *end == '\0') ? v : 0; +} + +// "00".."FF" +inline const char *hexByte(uint8_t value) { + static const auto table = [] { + std::array t; + for (int i = 0; i < 256; ++i) snprintf(t[i], sizeof(t[i]), "%02X", i); + return t; + }(); + return table[value]; +} + inline std::string toHex(const std::vector &dat, char separator = '\0') { static const char digits[] = "0123456789ABCDEF"; std::string hex; diff --git a/openpilot/tools/cabana/utils/util.cc b/openpilot/tools/cabana/utils/util.cc index 7bdf225975..1d1c8c75e4 100644 --- a/openpilot/tools/cabana/utils/util.cc +++ b/openpilot/tools/cabana/utils/util.cc @@ -242,7 +242,6 @@ static std::unordered_map load_bootstrap_icons() { } namespace utils { - std::string homePath() { const char *home = ::getenv("HOME"); return home ? home : ""; diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index 324c966b5a..3570320f34 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -59,21 +60,6 @@ ValidState validateIpAddress(const std::string &input); // C-locale floating-point ValidState validateDouble(const std::string &input); -// "Darcula" like dark theme -struct DarkTheme { - static constexpr CabanaColor window{0x35, 0x35, 0x35}; - static constexpr CabanaColor window_text{0xbb, 0xbb, 0xbb}; - static constexpr CabanaColor base{0x3c, 0x3f, 0x41}; - static constexpr CabanaColor tooltip_text{0xbb, 0xbb, 0xbb}; - static constexpr CabanaColor text{0xbb, 0xbb, 0xbb}; - static constexpr CabanaColor button{0x3c, 0x3f, 0x41}; - static constexpr CabanaColor highlight{0x2f, 0x65, 0xca}; - static constexpr CabanaColor bright_text{0xf0, 0xf0, 0xf0}; - static constexpr CabanaColor disabled_text{0x77, 0x77, 0x77}; - static constexpr CabanaColor light{0x77, 0x77, 0x77}; - static constexpr CabanaColor dark{0x35, 0x35, 0x35}; -}; - namespace utils { bool isMainThread(); @@ -90,6 +76,14 @@ std::string bootstrapSvg(const std::string &id); // empty if unknown template std::vector toBytes(const T &dat) { return {dat.begin(), dat.end()}; } +// a callback that is skipped once `alive` expired: the owner resets its token when it goes away +template +auto guarded(const std::shared_ptr &alive, F fn) { + return [alive = std::weak_ptr(alive), fn = std::move(fn)](auto &&...args) { + if (!alive.expired()) fn(std::forward(args)...); + }; +} + } // Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread. diff --git a/openpilot/tools/cabana/videowidget.cc b/openpilot/tools/cabana/videowidget.cc deleted file mode 100644 index f7b1b41628..0000000000 --- a/openpilot/tools/cabana/videowidget.cc +++ /dev/null @@ -1,434 +0,0 @@ -#include "tools/cabana/videowidget.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/tools/routeinfo.h" -#include "tools/cabana/utils/qtutil.h" - -const int MIN_VIDEO_HEIGHT = 100; -const int THUMBNAIL_MARGIN = 3; - -// Indexed by TimelineType: None, Engaged, AlertInfo, AlertWarning, AlertCritical, UserBookmark -static const QColor timeline_colors[] = { - QColor(111, 143, 175), - QColor(0, 163, 108), - Qt::green, - QColor(255, 195, 0), - QColor(199, 0, 57), - Qt::magenta, -}; - -static Replay *getReplay() { - auto stream = dynamic_cast(can); - return stream ? stream->getReplay() : nullptr; -} - -VideoWidget::VideoWidget(QWidget *parent) : QFrame(parent) { - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - auto main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - if (!can->liveStreaming()) - main_layout->addWidget(createCameraWidget()); - - createPlaybackController(); - - setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); - connections_.push_back(can->paused.connect([this]() { updatePlayBtnState(); })); - connections_.push_back(can->resume.connect([this]() { updatePlayBtnState(); })); - connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); - connections_.push_back(can->seeking.connect([this](double) { updateState(); })); - connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { timeRangeChanged(); })); - - updatePlayBtnState(); - setWhatsThis(tr(R"( - Video
- - Timeline color - - - - - - - -
■ Disengaged ■ Engaged
■ User Flag ■ Info
■ Warning ■ Critical
- Shortcuts
- Pause/Resume:  space  - )").arg(timeline_colors[(int)TimelineType::None].name(), - timeline_colors[(int)TimelineType::Engaged].name(), - timeline_colors[(int)TimelineType::UserBookmark].name(), - timeline_colors[(int)TimelineType::AlertInfo].name(), - timeline_colors[(int)TimelineType::AlertWarning].name(), - timeline_colors[(int)TimelineType::AlertCritical].name())); -} - -void VideoWidget::createPlaybackController() { - QToolBar *toolbar = new QToolBar(this); - layout()->addWidget(toolbar); - - int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize); - toolbar->setIconSize({icon_size, icon_size}); - - toolbar->addAction(utils::icon("rewind"), tr("Seek backward"), []() { can->seekTo(can->currentSec() - 1); }); - play_toggle_action = toolbar->addAction(utils::icon("play"), tr("Play"), []() { can->pause(!can->isPaused()); }); - toolbar->addAction(utils::icon("fast-forward"), tr("Seek forward"), []() { can->seekTo(can->currentSec() + 1); }); - - if (can->liveStreaming()) { - skip_to_end_action = toolbar->addAction(utils::icon("skip-end"), tr("Skip to the end"), this, [this]() { - // set speed to 1.0 - speed_btn->menu()->actions()[7]->setChecked(true); - can->pause(false); - can->seekTo(can->maxSeconds() + 1); - }); - } - - time_display_action = toolbar->addAction("", this, [this]() { - settings.absolute_time = !settings.absolute_time; - time_display_action->setToolTip(settings.absolute_time ? tr("Elapsed time") : tr("Absolute time")); - updateState(); - }); - - QWidget *spacer = new QWidget(); - spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - toolbar->addWidget(spacer); - - if (!can->liveStreaming()) { - toolbar->addAction(utils::icon("repeat"), tr("Loop playback"), this, &VideoWidget::loopPlaybackClicked); - createSpeedDropdown(toolbar); - toolbar->addSeparator(); - toolbar->addAction(utils::icon("info-circle"), tr("View route details"), this, &VideoWidget::showRouteInfo); - } else { - createSpeedDropdown(toolbar); - } -} - -void VideoWidget::createSpeedDropdown(QToolBar *toolbar) { - toolbar->addWidget(speed_btn = new QToolButton(this)); - speed_btn->setMenu(new QMenu(speed_btn)); - speed_btn->setPopupMode(QToolButton::InstantPopup); - QActionGroup *speed_group = new QActionGroup(this); - speed_group->setExclusive(true); - - for (float speed : {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1., 2., 3., 5.}) { - auto act = speed_btn->menu()->addAction(QString("%1x").arg(speed), this, [this, speed]() { - can->setSpeed(speed); - speed_btn->setText(QString("%1x ").arg(speed)); - }); - - speed_group->addAction(act); - act->setCheckable(true); - if (speed == 1.0) { - act->setChecked(true); - act->trigger(); - } - } - - QFont font = speed_btn->font(); - font.setBold(true); - speed_btn->setFont(font); - speed_btn->setMinimumWidth(speed_btn->fontMetrics().horizontalAdvance("0.05x ") + style()->pixelMetric(QStyle::PM_MenuButtonIndicator)); -} - -QWidget *VideoWidget::createCameraWidget() { - QWidget *w = new QWidget(this); - QVBoxLayout *l = new QVBoxLayout(w); - l->setContentsMargins(0, 0, 0, 0); - l->setSpacing(0); - - l->addWidget(camera_tab = new TabBar(w)); - camera_tab->setAutoHide(true); - camera_tab->setExpanding(false); - - l->addWidget(cam_widget = new StreamCameraView("camerad", VISION_STREAM_NARROW_ROAD)); - cam_widget->setMinimumHeight(MIN_VIDEO_HEIGHT); - cam_widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding); - - l->addWidget(slider = new Slider(w)); - slider->setSingleStep(0); - slider->setTimeRange(can->minSeconds(), can->maxSeconds()); - - QObject::connect(slider, &QSlider::sliderReleased, [this]() { can->seekTo(slider->currentSecond()); }); - connections_.push_back(can->paused.connect([this]() { cam_widget->update(); })); - connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { slider->update(); })); - connections_.push_back(cam_widget->clicked.connect([]() { can->pause(!can->isPaused()); })); - connections_.push_back(cam_widget->availableStreamsUpdated.connect([this](std::set streams) { vipcAvailableStreamsUpdated(streams); })); - QObject::connect(camera_tab, &QTabBar::currentChanged, [this](int index) { - if (index != -1) cam_widget->setStreamType((VisionStreamType)camera_tab->tabData(index).toInt()); - }); - connections_.push_back(static_cast(can)->qLogLoaded.connect([this](std::shared_ptr qlog) { cam_widget->parseQLog(qlog); })); - slider->installEventFilter(this); - return w; -} - -void VideoWidget::vipcAvailableStreamsUpdated(std::set streams) { - static const QString stream_names[] = {"Road camera", "Driver camera", "Wide road camera"}; - for (int i = 0; i < streams.size(); ++i) { - if (camera_tab->count() <= i) { - camera_tab->addTab(QString()); - } - int type = *std::next(streams.begin(), i); - camera_tab->setTabText(i, stream_names[type]); - camera_tab->setTabData(i, type); - } - while (camera_tab->count() > streams.size()) { - camera_tab->removeTab(camera_tab->count() - 1); - } -} - -void VideoWidget::loopPlaybackClicked() { - bool is_looping = getReplay()->loop(); - getReplay()->setLoop(!is_looping); - qobject_cast(sender())->setIcon(utils::icon(!is_looping ? "repeat" : "repeat-1")); -} - -void VideoWidget::timeRangeChanged() { - const auto time_range = can->timeRange(); - if (can->liveStreaming()) { - skip_to_end_action->setEnabled(!time_range.has_value()); - return; - } - time_range ? slider->setTimeRange(time_range->first, time_range->second) - : slider->setTimeRange(can->minSeconds(), can->maxSeconds()); - updateState(); -} - -QString VideoWidget::formatTime(double sec, bool include_milliseconds) { - if (settings.absolute_time) - sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); - return QString::fromStdString(utils::formatSeconds(sec, include_milliseconds, settings.absolute_time)); -} - -void VideoWidget::updateState() { - if (slider) { - if (!slider->isSliderDown()) { - slider->setCurrentSecond(can->currentSec()); - } - if (camera_tab->count() == 0) { // No streams available - cam_widget->update(); // Manually refresh to show alert events - } - time_display_action->setText(QString("%1 / %2").arg(formatTime(can->currentSec(), true), - formatTime(slider->maximum() / slider->factor))); - } else { - time_display_action->setText(formatTime(can->currentSec(), true)); - } -} - -void VideoWidget::updatePlayBtnState() { - play_toggle_action->setIcon(utils::icon(can->isPaused() ? "play" : "pause")); - play_toggle_action->setToolTip(can->isPaused() ? tr("Play") : tr("Pause")); -} - -void VideoWidget::showThumbnail(double seconds) { - if (can->liveStreaming()) return; - - cam_widget->thumbnail_dispaly_time = seconds; - slider->thumbnail_dispaly_time = seconds; - cam_widget->update(); - slider->update(); -} - -void VideoWidget::showRouteInfo() { - RouteInfoDlg *route_info = new RouteInfoDlg(this); - route_info->setAttribute(Qt::WA_DeleteOnClose); - route_info->show(); -} - -bool VideoWidget::eventFilter(QObject *obj, QEvent *event) { - if (event->type() == QEvent::MouseMove) { - auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); - showThumbnail(min_sec + static_cast(event)->pos().x() * (max_sec - min_sec) / slider->width()); - } else if (event->type() == QEvent::Leave) { - showThumbnail(-1); - } - return false; -} - -// Slider -Slider::Slider(QWidget *parent) : QSlider(Qt::Horizontal, parent) { - setMouseTracking(true); -} - -void Slider::paintEvent(QPaintEvent *ev) { - QPainter p(this); - - QStyleOptionSlider opt; - initStyleOption(&opt); - QRect handle_rect = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderHandle, this); - QRect groove_rect = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderGroove, this); - - // Adjust groove height to match handle height - int handle_height = handle_rect.height(); - groove_rect.setHeight(handle_height * 0.5); - groove_rect.moveCenter(QPoint(groove_rect.center().x(), rect().center().y())); - - p.fillRect(groove_rect, timeline_colors[(int)TimelineType::None]); - - double min = minimum() / factor; - double max = maximum() / factor; - - auto fillRange = [&](double begin, double end, const QColor &color) { - if (begin > max || end < min) return; - - QRect r = groove_rect; - r.setLeft(((std::max(min, begin) - min) / (max - min)) * width()); - r.setRight(((std::min(max, end) - min) / (max - min)) * width()); - p.fillRect(r, color); - }; - - if (auto replay = getReplay()) { - for (const auto &entry : *replay->getTimeline()) { - fillRange(entry.start_time, entry.end_time, timeline_colors[(int)entry.type]); - } - - QColor empty_color = palette().color(QPalette::Window); - empty_color.setAlpha(160); - const auto event_data = replay->getEventData(); - for (const auto &[n, _] : replay->route().segments()) { - if (!event_data->isSegmentLoaded(n)) - fillRange(n * 60.0, (n + 1) * 60.0, empty_color); - } - } - - opt.minimum = minimum(); - opt.maximum = maximum(); - opt.subControls = QStyle::SC_SliderHandle; - opt.sliderPosition = value(); - style()->drawComplexControl(QStyle::CC_Slider, &opt, &p); - - if (thumbnail_dispaly_time >= 0) { - int left = (thumbnail_dispaly_time - min) * width() / (max - min) - 1; - QRect rc(left, rect().top() + 1, 2, rect().height() - 2); - p.setBrush(palette().highlight()); - p.setPen(Qt::NoPen); - p.drawRoundedRect(rc, 1.5, 1.5); - } -} - -void Slider::mousePressEvent(QMouseEvent *e) { - QSlider::mousePressEvent(e); - if (e->button() == Qt::LeftButton && !isSliderDown()) { - setValue(minimum() + ((maximum() - minimum()) * e->x()) / width()); - emit sliderReleased(); - } -} - -// StreamCameraView -StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent) - : CameraWidget(stream_name, stream_type, parent) { -} - -void StreamCameraView::parseQLog(std::shared_ptr qlog) { - std::mutex mutex; - const auto &events = qlog->events; - unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency()); - size_t chunk = (events.size() + num_threads - 1) / num_threads; - std::vector threads; - for (unsigned int t = 0; t < num_threads && t * chunk < events.size(); ++t) { - size_t start = t * chunk; - size_t end = std::min(start + chunk, events.size()); - threads.emplace_back([this, &mutex, &events, start, end]() { - for (size_t i = start; i < end; ++i) { - const Event &e = events[i]; - if (e.which == cereal::Event::Which::THUMBNAIL) { - capnp::FlatArrayMessageReader reader(e.data); - auto thumb_data = reader.getRoot().getThumbnail(); - auto image_data = thumb_data.getThumbnail(); - if (QPixmap thumb; thumb.loadFromData(image_data.begin(), image_data.size(), "jpeg")) { - QPixmap generated_thumb = generateThumbnail(thumb, can->toSeconds(thumb_data.getTimestampEof())); - std::lock_guard lock(mutex); - thumbnails[thumb_data.getTimestampEof()] = generated_thumb; - big_thumbnails[thumb_data.getTimestampEof()] = thumb; - } - } - } - }); - } - for (auto &th : threads) th.join(); - update(); -} - -void StreamCameraView::paintEvent(QPaintEvent *event) { - CameraWidget::paintEvent(event); - - QPainter p(this); - bool scrubbing = false; - if (thumbnail_dispaly_time >= 0) { - scrubbing = can->isPaused(); - scrubbing ? drawScrubThumbnail(p) : drawThumbnail(p); - } - if (auto alert = getReplay()->findAlertAtTime(scrubbing ? thumbnail_dispaly_time : can->currentSec())) { - drawAlert(p, rect(), *alert); - } - - if (can->isPaused()) { - 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")); - } -} - -QPixmap StreamCameraView::generateThumbnail(QPixmap thumb, double seconds) { - QPixmap scaled = thumb.scaledToHeight(MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2, Qt::SmoothTransformation); - QPainter p(&scaled); - p.setPen(QPen(palette().color(QPalette::BrightText), 2)); - p.drawRect(scaled.rect()); - if (auto alert = getReplay()->findAlertAtTime(seconds)) { - p.setFont(QFont(font().family(), 10)); - drawAlert(p, scaled.rect(), *alert); - } - return scaled; -} - -void StreamCameraView::drawScrubThumbnail(QPainter &p) { - p.fillRect(rect(), Qt::black); - auto it = big_thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time)); - if (it != big_thumbnails.end()) { - QPixmap scaled_thumb = it->second.scaled(rect().size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); - QRect thumb_rect(rect().center() - scaled_thumb.rect().center(), scaled_thumb.size()); - p.drawPixmap(thumb_rect.topLeft(), scaled_thumb); - drawTime(p, thumb_rect, thumbnail_dispaly_time); - } -} - -void StreamCameraView::drawThumbnail(QPainter &p) { - auto it = thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time)); - if (it != thumbnails.end()) { - const QPixmap &thumb = it->second; - auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); - int pos = (thumbnail_dispaly_time - min_sec) * width() / (max_sec - min_sec); - int x = std::clamp(pos - thumb.width() / 2, THUMBNAIL_MARGIN, width() - thumb.width() - THUMBNAIL_MARGIN + 1); - int y = height() - thumb.height() - THUMBNAIL_MARGIN; - - p.drawPixmap(x, y, thumb); - drawTime(p, QRect{x, y, thumb.width(), thumb.height()}, thumbnail_dispaly_time); - } -} - -void StreamCameraView::drawTime(QPainter &p, const QRect &rect, double seconds) { - p.setPen(palette().color(QPalette::BrightText)); - p.setFont(QFont(font().family(), 10)); - p.drawText(rect.adjusted(0, 0, 0, -THUMBNAIL_MARGIN), Qt::AlignHCenter | Qt::AlignBottom, QString::number(seconds, 'f', 3)); -} - -void StreamCameraView::drawAlert(QPainter &p, const QRect &rect, const Timeline::Entry &alert) { - p.setPen(QPen(palette().color(QPalette::BrightText), 2)); - QColor color = timeline_colors[int(alert.type)]; - color.setAlphaF(0.5); - QString text = QString::fromStdString(alert.text1); - if (!alert.text2.empty()) text += "\n" + QString::fromStdString(alert.text2); - - QRect text_rect = rect.adjusted(1, 1, -1, -1); - QRect r = p.fontMetrics().boundingRect(text_rect, Qt::AlignTop | Qt::AlignHCenter | Qt::TextWordWrap, text); - p.fillRect(text_rect.left(), r.top(), text_rect.width(), r.height(), color); - p.drawText(text_rect, Qt::AlignTop | Qt::AlignHCenter | Qt::TextWordWrap, text); -} diff --git a/openpilot/tools/cabana/videowidget.h b/openpilot/tools/cabana/videowidget.h deleted file mode 100644 index 8416eece59..0000000000 --- a/openpilot/tools/cabana/videowidget.h +++ /dev/null @@ -1,82 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -#include "tools/cabana/cameraview.h" -#include "tools/cabana/utils/qtutil.h" -#include "tools/replay/logreader.h" -#include "tools/cabana/streams/replaystream.h" - -class Slider : public QSlider { - Q_OBJECT - -public: - Slider(QWidget *parent); - double currentSecond() const { return value() / factor; } - void setCurrentSecond(double sec) { setValue(sec * factor); } - void setTimeRange(double min, double max) { setRange(min * factor, max * factor); } - void mousePressEvent(QMouseEvent *e) override; - void paintEvent(QPaintEvent *ev) override; - const double factor = 1000.0; - double thumbnail_dispaly_time = -1; -}; - -class StreamCameraView : public CameraWidget { - Q_OBJECT - -public: - StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent = nullptr); - void paintEvent(QPaintEvent *event) override; - void parseQLog(std::shared_ptr qlog); - -private: - QPixmap generateThumbnail(QPixmap thumbnail, double seconds); - void drawAlert(QPainter &p, const QRect &rect, const Timeline::Entry &alert); - void drawThumbnail(QPainter &p); - void drawScrubThumbnail(QPainter &p); - void drawTime(QPainter &p, const QRect &rect, double seconds); - - std::map big_thumbnails; - std::map thumbnails; - double thumbnail_dispaly_time = -1; - friend class VideoWidget; -}; - -class VideoWidget : public QFrame { - Q_OBJECT - -public: - VideoWidget(QWidget *parnet = nullptr); - void showThumbnail(double seconds); - -protected: - bool eventFilter(QObject *obj, QEvent *event) override; - QString formatTime(double sec, bool include_milliseconds = false); - void timeRangeChanged(); - void updateState(); - void updatePlayBtnState(); - Connections connections_; - QWidget *createCameraWidget(); - void createPlaybackController(); - void createSpeedDropdown(QToolBar *toolbar); - void loopPlaybackClicked(); - void vipcAvailableStreamsUpdated(std::set streams); - void showRouteInfo(); - - StreamCameraView *cam_widget; - QAction *time_display_action = nullptr; - QAction *play_toggle_action = nullptr; - QToolButton *speed_btn = nullptr; - QAction *skip_to_end_action = nullptr; - Slider *slider = nullptr; - QTabBar *camera_tab = nullptr; -}; diff --git a/openpilot/tools/jotpluggler/app.cc b/openpilot/tools/jotpluggler/app.cc index e6ba696bae..c7a74a0ecb 100644 --- a/openpilot/tools/jotpluggler/app.cc +++ b/openpilot/tools/jotpluggler/app.cc @@ -1614,6 +1614,7 @@ void draw_pane_windows(AppSession *session, UiState *state) { void draw_workspace(AppSession *session, const UiMetrics &ui, UiState *state) { state->custom_series.selected = false; state->logs.selected = false; + state->init_data_selected = false; ImGui::SetNextWindowPos(ImVec2(ui.content_x, ui.content_y)); ImGui::SetNextWindowSize(ImVec2(ui.content_w, ui.content_h)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); @@ -1711,6 +1712,11 @@ void draw_workspace(AppSession *session, const UiMetrics &ui, UiState *state) { draw_logs_tab(session, state); ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("initData##workspace_init_data")) { + state->init_data_selected = true; + draw_init_data_tab(session, state); + ImGui::EndTabItem(); + } if (custom_series_tab_open) { ImGuiTabItemFlags custom_flags = ImGuiTabItemFlags_None; if (state->custom_series.request_select) { @@ -1815,6 +1821,7 @@ int run(const Options &options) { .route_name = options.route_name, .data_dir = options.data_dir, .dbc_override = {}, + .migrate = options.migrate, .stream_source = StreamSourceConfig{.kind = is_local_stream_address(options.stream_address) ? StreamSourceKind::CerealLocal : StreamSourceKind::CerealRemote, diff --git a/openpilot/tools/jotpluggler/app.h b/openpilot/tools/jotpluggler/app.h index 9a6777ee90..e8fc4563e0 100644 --- a/openpilot/tools/jotpluggler/app.h +++ b/openpilot/tools/jotpluggler/app.h @@ -34,6 +34,7 @@ struct Options { bool show = false; bool sync_load = false; bool stream = false; + bool migrate = true; double stream_buffer_seconds = 30.0; }; @@ -168,8 +169,19 @@ struct LogEntry { struct EnumInfo { std::vector names; + bool is_text = false; + std::unordered_map text_indices; + + double text_value(std::string text) { + is_text = true; + auto [it, inserted] = text_indices.try_emplace(text, names.size()); + if (inserted) names.push_back(std::move(text)); + return static_cast(it->second); + } }; +void merge_text_labels(RouteSeries *incoming, const EnumInfo &labels, EnumInfo *destination); + struct SeriesFormat { int decimals = 3; bool integer_like = false; @@ -316,7 +328,19 @@ struct RouteIdentifier { } }; +struct InitDataSection { + std::string name; + std::vector> values; +}; + +struct InitDataSnapshot { + std::vector sections; +}; + +InitDataSnapshot extract_init_data(cereal::InitData::Reader reader); + struct RouteData { + std::optional init_data; std::vector series; std::vector paths; std::vector roots; @@ -340,6 +364,7 @@ struct RouteData { }; struct StreamExtractBatch { + std::optional init_data; std::vector series; std::vector can_messages; std::vector logs; @@ -411,6 +436,7 @@ std::vector decode_can_messages(const std::vector & RouteData load_route_data(const std::string &route_name, const std::string &data_dir = {}, const std::string &dbc_name = {}, + bool migrate = true, const RouteLoadProgressCallback &progress = {}); RouteIdentifier parse_route_identifier(std::string_view route_name); void rebuild_gps_trace(RouteData *route_data); @@ -484,6 +510,7 @@ struct AppSession { std::string route_name; std::string data_dir; std::string dbc_override; + bool migrate = true; StreamSourceConfig stream_source; double stream_buffer_seconds = 30.0; SessionDataMode data_mode = SessionDataMode::Route; @@ -639,6 +666,11 @@ struct UndoStack { } }; +struct PlotView { + ImGuiID plot_id = 0; + PlotRange range; +}; + struct UiState { bool open_open_route = false; bool open_stream = false; @@ -656,6 +688,7 @@ struct UiState { bool has_shared_range = false; bool has_tracker_time = false; bool layout_dirty = false; + int route_time_zone = 0; bool playback_loop = false; bool playback_playing = false; bool show_deprecated_fields = false; @@ -706,7 +739,15 @@ struct UiState { DbcEditorState dbc_editor; CustomSeriesEditorState custom_series; LogsUiState logs; + bool init_data_selected = false; + std::string init_data_search; UndoStack undo; + std::vector plot_view_history; + std::optional restore_plot_view; + ImGuiID last_plot_view_id = 0; + double last_plot_view_change = -1.0; + bool last_plot_view_wheel = false; + double last_plot_pan_click = -1.0; }; // app.cc public API @@ -767,6 +808,7 @@ void draw_custom_series_editor(AppSession *session, UiState *state); // ***** void draw_logs_tab(AppSession *session, UiState *state); +void draw_init_data_tab(AppSession *session, UiState *state); // ***** // map @@ -846,7 +888,7 @@ public: AsyncRouteLoader(const AsyncRouteLoader &) = delete; AsyncRouteLoader &operator=(const AsyncRouteLoader &) = delete; - void start(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name); + void start(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name, bool migrate); RouteLoadSnapshot snapshot() const; bool consume(RouteData *route_data, std::string *error_text); diff --git a/openpilot/tools/jotpluggler/browser.cc b/openpilot/tools/jotpluggler/browser.cc index 91e4a707da..3342e82b37 100644 --- a/openpilot/tools/jotpluggler/browser.cc +++ b/openpilot/tools/jotpluggler/browser.cc @@ -310,8 +310,9 @@ std::string format_display_value(double display_value, const int idx = static_cast(std::llround(display_value)); if (idx >= 0 && std::abs(display_value - static_cast(idx)) < 0.01 && static_cast(idx) < enum_info->names.size() - && !enum_info->names[static_cast(idx)].empty()) { - return enum_info->names[static_cast(idx)]; + && (enum_info->is_text || !enum_info->names[static_cast(idx)].empty())) { + const std::string &name = enum_info->names[static_cast(idx)]; + return name.empty() ? "(empty)" : name; } } char buf[64] = {}; @@ -416,6 +417,18 @@ void draw_browser_node(AppSession *session, app_pop_mono_font(); } + if (hovered) { + auto it = session->route_data.enum_info.find(node.full_path); + if (it != session->route_data.enum_info.end() && it->second.is_text) { + ImGui::BeginTooltip(); + ImGui::TextUnformatted(node.full_path.c_str()); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); + ImGui::TextWrapped("%s", value_text.c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + } + if (clicked) { const bool shift_down = ImGui::GetIO().KeyShift; const bool ctrl_down = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeySuper; diff --git a/openpilot/tools/jotpluggler/common.cc b/openpilot/tools/jotpluggler/common.cc index 50f5fc0b95..4136063607 100644 --- a/openpilot/tools/jotpluggler/common.cc +++ b/openpilot/tools/jotpluggler/common.cc @@ -185,3 +185,14 @@ std::string route_google_maps_url(const GpsTrace &trace) { if (url.size() <= 1900 || n == 0) return url; } } + +void merge_text_labels(RouteSeries *incoming, const EnumInfo &labels, EnumInfo *destination) { + std::vector remap; + remap.reserve(labels.names.size()); + for (const std::string &text : labels.names) { + remap.push_back(destination->text_value(text)); + } + for (double &value : incoming->values) { + value = remap.at(static_cast(value)); + } +} diff --git a/openpilot/tools/jotpluggler/generate_event_extractors.py b/openpilot/tools/jotpluggler/generate_event_extractors.py index a424ebc237..2dc506e4be 100644 --- a/openpilot/tools/jotpluggler/generate_event_extractors.py +++ b/openpilot/tools/jotpluggler/generate_event_extractors.py @@ -18,7 +18,7 @@ SCALAR_KINDS = { "enum": "Enum", } NESTED_TYPE_KINDS = {"struct", "list"} -IGNORED_TYPE_KINDS = {"void", "text", "data", "interface", "anyPointer"} +IGNORED_TYPE_KINDS = {"void", "data", "interface", "anyPointer"} def cxx_string(value): @@ -114,6 +114,11 @@ class Generator: self.emit(indent, f"append_fixed_scalar_point(&series->fixed_series[{slot}], tm, {double_expr});") return + if type_kind == "text": + target = f"ensure_dynamic_series({path_expr}, series)" if dynamic_path else f"&series->fixed_series[{self.add_fixed_path(path)}]" + self.emit(indent, f"append_text_point({target}, tm, {expr}, series);") + return + if type_kind == "struct": self.emit_struct(indent, schema, expr, path, path_expr, dynamic_path) return @@ -180,7 +185,7 @@ class Generator: self.emit(indent, f"const std::string {base_path_var} = {cxx_string(path)};") elem_scalar = scalar_kind(elem_type) - if elem_scalar is not None: + if elem_scalar is not None or elem_kind == "text": self.emit(indent, f"if ({list_expr}.size() <= 16) {{") index_var = self.tmp("i") self.emit(indent + 2, f"for (uint {index_var} = 0; {index_var} < {list_expr}.size(); ++{index_var}) {{") @@ -188,7 +193,10 @@ class Generator: self.emit(indent + 4, f"RouteSeries *{item_series} = ensure_list_scalar_series({base_path_var}, {index_var}, series);") if elem_scalar == "Enum": self.emit_enum_capture(indent + 4, f"{item_series}->path", enum_names(schema.elementType)) - self.emit(indent + 4, f"append_fixed_scalar_point({item_series}, tm, {self.scalar_double_expr(f'{list_expr}[{index_var}]', elem_scalar)});") + if elem_kind == "text": + self.emit(indent + 4, f"append_text_point({item_series}, tm, {list_expr}[{index_var}], series);") + else: + self.emit(indent + 4, f"append_fixed_scalar_point({item_series}, tm, {self.scalar_double_expr(f'{list_expr}[{index_var}]', elem_scalar)});") self.emit(indent + 2, "}") self.emit(indent, "}") return @@ -207,7 +215,7 @@ class Generator: self.emit(indent, "}") def node_emits(self, type_kind, type_proto, schema, seen=frozenset()): - if scalar_kind(type_proto) is not None: + if scalar_kind(type_proto) is not None or type_kind == "text": return True if type_kind == "struct": if schema is None: @@ -238,7 +246,7 @@ class Generator: elem_kind = elem_type.which() if elem_kind in IGNORED_TYPE_KINDS: return False - if scalar_kind(elem_type) is not None: + if scalar_kind(elem_type) is not None or elem_kind == "text": return True if elem_kind == "struct": return self.node_emits("struct", None, schema.elementType, seen) diff --git a/openpilot/tools/jotpluggler/init_data.cc b/openpilot/tools/jotpluggler/init_data.cc new file mode 100644 index 0000000000..d4d2e8ed05 --- /dev/null +++ b/openpilot/tools/jotpluggler/init_data.cc @@ -0,0 +1,113 @@ +#include "tools/jotpluggler/app.h" + +#include + +namespace { + +std::string format_data(capnp::Data::Reader data) { + const bool text = std::all_of(data.begin(), data.end(), [](uint8_t c) { + return (c >= 32 && c < 127) || c == '\n' || c == '\r' || c == '\t'; + }); + if (data.size() == 0) return ""; + if (text) return std::string(reinterpret_cast(data.begin()), data.size()); + constexpr char hex[] = "0123456789abcdef"; + std::string value = "hex: "; + for (uint8_t c : data) { + value += hex[c >> 4]; + value += hex[c & 15]; + } + return value; +} + +std::string format_value(capnp::DynamicValue::Reader value) { + if (value.getType() == capnp::DynamicValue::DATA) return format_data(value.as()); + if (value.getType() == capnp::DynamicValue::TEXT) { + const auto text = value.as(); + return std::string(text.begin(), text.size()); + } + return kj::str(value).cStr(); +} + +} // namespace + +InitDataSnapshot extract_init_data(cereal::InitData::Reader reader) { + InitDataSnapshot snapshot; + InitDataSection metadata{"Device & software", {}}; + auto dynamic = capnp::toDynamic(reader); + for (auto field : dynamic.getSchema().getFields()) { + const std::string name = field.getProto().getName().cStr(); + if (name == "deprecated") continue; + if (name == "params" || name == "commands") { + InitDataSection section{name, {}}; + auto entries = dynamic.get(field).as().get("entries").as(); + for (auto entry : entries) { + auto item = entry.as(); + section.values.emplace_back(format_value(item.get("key")), format_value(item.get("value"))); + } + std::sort(section.values.begin(), section.values.end()); + snapshot.sections.push_back(std::move(section)); + } else { + metadata.values.emplace_back(name, format_value(dynamic.get(field))); + } + } + snapshot.sections.insert(snapshot.sections.begin(), std::move(metadata)); + return snapshot; +} + +void draw_init_data_tab(AppSession *session, UiState *state) { + ImGui::SetNextItemWidth(280.0f); + input_text_with_hint_string("##init_data_search", "Search fields and values...", &state->init_data_search); + ImGui::SameLine(); + ImGui::TextDisabled("Right-click a value to copy"); + ImGui::Separator(); + if (!session->route_data.init_data) { + const auto load = session->route_loader ? session->route_loader->snapshot() : RouteLoadSnapshot{}; + ImGui::TextWrapped("%s", load.active ? "Loading initData..." : "No initData available for this route or stream."); + return; + } + const std::string query = lowercase_copy(state->init_data_search); + const auto matches = [&](const auto &row) { + return query.empty() || lowercase_copy(row.first).find(query) != std::string::npos + || lowercase_copy(row.second).find(query) != std::string::npos; + }; + bool found = false; + if (ImGui::BeginChild("##init_data_content", ImVec2(0, 0), false)) { + for (const auto §ion : session->route_data.init_data->sections) { + const size_t count = std::count_if(section.values.begin(), section.values.end(), matches); + if (!query.empty() && count == 0) continue; + found = found || count > 0; + ImGui::PushID(section.name.c_str()); + const std::string label = section.name + " (" + std::to_string(count) + ")"; + if (ImGui::CollapsingHeader(label.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { + if (section.values.empty()) ImGui::TextDisabled("No entries"); + if (ImGui::BeginTable("##values", 2, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_Resizable)) { + ImGui::TableSetupColumn("Field", ImGuiTableColumnFlags_WidthFixed, 230.0f); + ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + for (size_t i = 0; i < section.values.size(); ++i) { + const auto &[key, value] = section.values[i]; + if (!matches(section.values[i])) continue; + ImGui::PushID(static_cast(i)); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(key.c_str()); + ImGui::TableSetColumnIndex(1); + app_push_mono_font(); + ImGui::TextWrapped("%s", value.empty() ? "(empty)" : value.c_str()); + app_pop_mono_font(); + if (ImGui::BeginPopupContextItem("##copy")) { + if (ImGui::MenuItem("Copy value")) ImGui::SetClipboardText(value.c_str()); + if (ImGui::MenuItem("Copy field")) ImGui::SetClipboardText(key.c_str()); + ImGui::EndPopup(); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + } + ImGui::PopID(); + } + if (!query.empty() && !found) ImGui::TextDisabled("No matching fields or values."); + } + ImGui::EndChild(); +} diff --git a/openpilot/tools/jotpluggler/layout.cc b/openpilot/tools/jotpluggler/layout.cc index 6bc3a6168a..091aea5abe 100644 --- a/openpilot/tools/jotpluggler/layout.cc +++ b/openpilot/tools/jotpluggler/layout.cc @@ -547,7 +547,7 @@ bool save_layout(AppSession *session, UiState *state, const std::string &layout_ void rebuild_session_route_data(AppSession *session, UiState *state, const RouteLoadProgressCallback &progress) { - apply_route_data(session, state, load_route_data(session->route_name, session->data_dir, session->dbc_override, progress)); + apply_route_data(session, state, load_route_data(session->route_name, session->data_dir, session->dbc_override, session->migrate, progress)); } void stop_stream_session(AppSession *session, UiState *state, bool preserve_data) { @@ -627,7 +627,7 @@ void start_async_route_load(AppSession *session, UiState *state) { return; } apply_route_data(session, state, RouteData{}); - session->route_loader->start(session->route_name, session->data_dir, session->dbc_override); + session->route_loader->start(session->route_name, session->data_dir, session->dbc_override, session->migrate); state->status_text = session->route_name.empty() ? "Ready" : "Loading route " + session->route_name; } diff --git a/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json b/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json index 3197f40ad8..e82edfbf96 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": "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"}}]} +{"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": "#8e44ad", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/sleep"}, {"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/main.cc b/openpilot/tools/jotpluggler/main.cc index 22bc29664c..89cb8989e8 100644 --- a/openpilot/tools/jotpluggler/main.cc +++ b/openpilot/tools/jotpluggler/main.cc @@ -22,6 +22,7 @@ void print_usage(const char *argv0) { << " --output \n" << " --show\n" << " --sync-load\n" + << " --no-migration\n" << "\n" << "Examples:\n" << " " << argv0 << "\n" @@ -94,6 +95,8 @@ int main(int argc, char *argv[]) { options.show = true; } else if (arg == "--sync-load") { options.sync_load = true; + } else if (arg == "--no-migration") { + options.migrate = false; } else if (arg == "--help" || arg == "-h") { print_usage(argv[0]); return 0; diff --git a/openpilot/tools/jotpluggler/plot.cc b/openpilot/tools/jotpluggler/plot.cc index 019eff7070..59d08ff2c9 100644 --- a/openpilot/tools/jotpluggler/plot.cc +++ b/openpilot/tools/jotpluggler/plot.cc @@ -1,6 +1,7 @@ #include "tools/jotpluggler/internal.h" #include "implot.h" +#include "implot_internal.h" #include "imgui_internal.h" #include @@ -117,8 +118,8 @@ ImU32 state_block_color(int value, float alpha = 1.0f) { std::string state_block_label(const PreparedCurve &curve, int value) { if (curve.enum_info != nullptr && value >= 0 && static_cast(value) < curve.enum_info->names.size()) { const std::string &name = curve.enum_info->names[static_cast(value)]; - if (!name.empty()) { - return name; + if (!name.empty() || curve.enum_info->is_text) { + return name.empty() ? "(empty)" : name; } } return std::to_string(value); @@ -801,7 +802,13 @@ void draw_plot(const AppSession &session, Pane *pane, UiState *state) { plot_flags |= ImPlotFlags_NoLegend; } - const ImPlotAxisFlags x_axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight; + ImPlotAxisFlags x_axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight; + const ImGuiIO &input = ImGui::GetIO(); + const bool at_zoom_limit = state->x_view_max - state->x_view_min <= MIN_HORIZONTAL_ZOOM_SECONDS + 1.0e-6; + if (at_zoom_limit && (input.MouseWheel > 0.0f || input.MouseReleased[ImGuiMouseButton_Left])) { + // Further zoom-in must not move the time window; panning and zoom-out remain available. + x_axis_flags |= ImPlotAxisFlags_LockMin | ImPlotAxisFlags_LockMax; + } ImPlotAxisFlags y_axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight; if (state_block_mode) { y_axis_flags |= ImPlotAxisFlags_NoDecorations; @@ -817,6 +824,8 @@ void draw_plot(const AppSession &session, Pane *pane, UiState *state) { if (ImPlot::BeginPlot("##plot", plot_size, plot_flags)) { ImPlot::SetupAxes(nullptr, nullptr, x_axis_flags, y_axis_flags); ImPlot::SetupAxisFormat(ImAxis_X1, "%.1f"); + // ImPlot updates pan endpoints separately; a minimum span would distort the pan. + ImPlot::SetupAxisZoomConstraints(ImAxis_X1, input.MouseDown[ImGuiMouseButton_Right] ? 0.0 : MIN_HORIZONTAL_ZOOM_SECONDS, INFINITY); if (state_block_mode) { ImPlot::SetupAxisLimits(ImAxis_Y1, 0.0, 1.0, ImPlotCond_Always); } else if (pane_value_format.valid) { @@ -841,6 +850,28 @@ void draw_plot(const AppSession &session, Pane *pane, UiState *state) { ImPlot::SetupLegend(ImPlotLocation_NorthEast); } + ImPlotPlot *plot = ImPlot::GetCurrentPlot(); + const PlotRange previous_view = {.left = previous_x_min, .right = previous_x_max, + .bottom = plot->Axes[ImAxis_Y1].Range.Min, .top = plot->Axes[ImAxis_Y1].Range.Max}; + const bool was_selecting = plot->Selecting; + const bool restoring_view = state->restore_plot_view && state->restore_plot_view->plot_id == plot->ID; + if (restoring_view) { + ImPlot::SetupAxisLimits(ImAxis_Y1, state->restore_plot_view->range.bottom, state->restore_plot_view->range.top, ImPlotCond_Always); + state->restore_plot_view.reset(); + } + const ImGuiIO &io = ImGui::GetIO(); + const ImVec2 drag(io.MousePos.x - plot->SelectStart.x, io.MousePos.y - plot->SelectStart.y); + const bool zoom_ready = ImGui::GetTime() - io.MouseClickedTime[ImGuiMouseButton_Left] >= 0.09 + && (std::abs(drag.x) > 12.0f || std::abs(drag.y) > 12.0f); + // Cancel before ImPlot processes release; hide the box until both thresholds are met. + if (io.MouseReleased[ImGuiMouseButton_Left] && !zoom_ready) { + plot->Selecting = false; + } + ImPlot::SetupFinish(); + if (!zoom_ready) { + plot->Selected = false; + } + if (state_block_mode) { draw_state_blocks_pane(prepared_curves, state); } else { @@ -873,6 +904,28 @@ void draw_plot(const AppSession &session, Pane *pane, UiState *state) { state->tracker_time = std::clamp(ImPlot::GetPlotMousePos().x, state->route_x_min, state->route_x_max); state->has_tracker_time = true; } + const ImPlotRect view = ImPlot::GetPlotLimits(); + const bool changed = std::abs(view.X.Min - previous_view.left) > 1.0e-6 + || std::abs(view.X.Max - previous_view.right) > 1.0e-6 + || std::abs(view.Y.Min - previous_view.bottom) > 1.0e-6 + || std::abs(view.Y.Max - previous_view.top) > 1.0e-6; + const bool wheel = io.MouseWheel != 0.0f; + const bool pan = ImGui::IsMouseDown(ImGuiMouseButton_Right); + if (changed && (ImPlot::IsPlotHovered() || was_selecting || plot->Held) && (wheel || pan || io.MouseReleased[ImGuiMouseButton_Left]) + && !restoring_view && !state->suppress_range_side_effects && !plot->JustCreated) { + // Keep continuous pans and wheel bursts as one view-history entry. + const bool continuing = state->last_plot_view_id == plot->ID + && ((pan && state->last_plot_pan_click == io.MouseClickedTime[ImGuiMouseButton_Right]) + || (wheel && state->last_plot_view_wheel && ImGui::GetTime() - state->last_plot_view_change < 0.3)); + if (!continuing) { + if (state->plot_view_history.size() == 50) state->plot_view_history.erase(state->plot_view_history.begin()); + state->plot_view_history.push_back({plot->ID, previous_view}); + } + state->last_plot_view_id = plot->ID; + state->last_plot_view_change = ImGui::GetTime(); + state->last_plot_view_wheel = wheel; + state->last_plot_pan_click = io.MouseClickedTime[ImGuiMouseButton_Right]; + } ImPlot::EndPlot(); } app_pop_mono_font(); diff --git a/openpilot/tools/jotpluggler/render.cc b/openpilot/tools/jotpluggler/render.cc index 54f0c16cc3..87f88c955d 100644 --- a/openpilot/tools/jotpluggler/render.cc +++ b/openpilot/tools/jotpluggler/render.cc @@ -68,6 +68,18 @@ void render_layout(AppSession *session, UiState *state, bool show_camera_feed) { if (!ImGui::GetIO().WantTextInput && ImGui::IsKeyPressed(ImGuiKey_Space, false)) { state->playback_playing = !state->playback_playing; } + // GLFW/ImGui mouse button 4 is the conventional mouse Back button. + if (ImGui::IsMouseClicked(3) && !state->plot_view_history.empty()) { + const PlotView view = state->plot_view_history.back(); + state->plot_view_history.pop_back(); + state->x_view_min = view.range.left; + state->x_view_max = view.range.right; + state->restore_plot_view = view; + state->follow_latest = false; + state->playback_playing = false; + state->suppress_range_side_effects = true; + state->last_plot_view_id = 0; + } advance_playback(state, *session); CameraFeedView *sidebar_camera = session->pane_camera_feeds[static_cast(sidebar_preview_camera_view(*session))].get(); if (show_camera_feed && sidebar_camera != nullptr && state->has_tracker_time) { @@ -83,7 +95,7 @@ void render_layout(AppSession *session, UiState *state, bool show_camera_feed) { draw_sidebar(session, ui, state, show_camera_feed); draw_workspace(session, ui, state); draw_sidebar_resizer(ui, state); - if (!state->custom_series.selected && !state->logs.selected) { + if (!state->custom_series.selected && !state->logs.selected && !state->init_data_selected) { draw_pane_windows(session, state); } draw_status_bar(*session, ui, state); diff --git a/openpilot/tools/jotpluggler/runtime.cc b/openpilot/tools/jotpluggler/runtime.cc index d7d0fc79c4..beac450b87 100644 --- a/openpilot/tools/jotpluggler/runtime.cc +++ b/openpilot/tools/jotpluggler/runtime.cc @@ -48,7 +48,8 @@ CameraType decoder_camera_type(CameraViewKind view) { } bool stream_batch_has_data(const StreamExtractBatch &batch) { - return !batch.series.empty() + return batch.init_data.has_value() + || !batch.series.empty() || !batch.can_messages.empty() || !batch.logs.empty() || !batch.timeline.empty() @@ -260,13 +261,14 @@ struct AsyncRouteLoader::Impl { join(); } - void start(const std::string &route_name_value, const std::string &data_dir_value, const std::string &dbc_name_value) { + void start(const std::string &route_name_value, const std::string &data_dir_value, const std::string &dbc_name_value, bool migrate_value) { join(); { std::lock_guard lock(mutex); route_name = route_name_value; data_dir = data_dir_value; dbc_name = dbc_name_value; + migrate = migrate_value; result.reset(); error_text.clear(); } @@ -282,7 +284,7 @@ struct AsyncRouteLoader::Impl { worker = std::thread([this]() { try { - RouteData route_data = load_route_data(route_name, data_dir, dbc_name, [this](const RouteLoadProgress &progress) { + RouteData route_data = load_route_data(route_name, data_dir, dbc_name, migrate, [this](const RouteLoadProgress &progress) { total_segments.store(progress.total_segments > 0 ? progress.total_segments : progress.segment_count); segments_downloaded.store(progress.segments_downloaded); segments_parsed.store(progress.segments_parsed); @@ -346,6 +348,7 @@ struct AsyncRouteLoader::Impl { std::string route_name; std::string data_dir; std::string dbc_name; + bool migrate = true; std::string error_text; std::atomic active{false}; std::atomic completed{false}; @@ -361,8 +364,8 @@ AsyncRouteLoader::AsyncRouteLoader(bool enable_terminal_progress) AsyncRouteLoader::~AsyncRouteLoader() = default; -void AsyncRouteLoader::start(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name) { - impl_->start(route_name, data_dir, dbc_name); +void AsyncRouteLoader::start(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name, bool migrate) { + impl_->start(route_name, data_dir, dbc_name, migrate); } RouteLoadSnapshot AsyncRouteLoader::snapshot() const { @@ -526,6 +529,7 @@ struct StreamPoller::Impl { } merge_can_message_data(&dst->can_messages[it->second], &message); } + if (src->init_data) dst->init_data = std::move(src->init_data); if (!src->logs.empty()) { dst->logs.insert(dst->logs.end(), std::make_move_iterator(src->logs.begin()), diff --git a/openpilot/tools/jotpluggler/session.cc b/openpilot/tools/jotpluggler/session.cc index 4c694a8daa..fbadcc03f1 100644 --- a/openpilot/tools/jotpluggler/session.cc +++ b/openpilot/tools/jotpluggler/session.cc @@ -5,6 +5,7 @@ #include #include #include +#include namespace fs = std::filesystem; @@ -30,6 +31,9 @@ void apply_route_data(AppSession *session, UiState *state, RouteData route_data) } else if (session->route_name.empty() && session->data_mode == SessionDataMode::Route) { session->route_id = {}; } + state->plot_view_history.clear(); + state->last_plot_view_id = 0; + state->restore_plot_view.reset(); session->route_data = std::move(route_data); rebuild_route_index(session); rebuild_browser_nodes(session, state); @@ -659,6 +663,9 @@ void draw_route_id_chip(AppSession *session, UiState *state) { ImGui::EndPopup(); } + ImGui::SetCursorScreenPos(ImVec2(chip_max.x + 16.0f, window->Pos.y)); + ImGui::Dummy(ImVec2(0.0f, window->Size.y)); + draw_route_copy_feedback(state, draw_list, chip_max); draw_route_info_popup(session, state, ImVec2(std::max(window->Pos.x + 16.0f, chip_max.x - 360.0f), chip_max.y + 6.0f)); } @@ -767,6 +774,58 @@ float draw_main_menu_bar(AppSession *session, UiState *state) { } ImGui::SameLine(0.0f, 8.0f); draw_route_id_chip(session, state); + if (session->data_mode == SessionDataMode::Route && state->has_tracker_time) { + const RouteSeries *wall = app_find_route_series(*session, "/initData/wallTimeNanos"); + const double route_start = wall && !wall->times.empty() ? wall->times.front() : 0.0; + const double elapsed = std::max(0.0, state->tracker_time - route_start); + int segment = static_cast(elapsed / 60.0); + for (const char *path : {"/narrowRoadEncodeIdx/segmentNum", "/wideRoadEncodeIdx/segmentNum", + "/qNarrowRoadEncodeIdx/segmentNum", "/cabinEncodeIdx/segmentNum"}) { + const RouteSeries *series = app_find_route_series(*session, path); + if (!series) continue; + auto value = app_sample_xy_value_at_time(series->times, series->values, true, state->tracker_time); + if (value) { + segment = static_cast(*value); + break; + } + } + ImGui::SameLine(0.0f, 0.0f); + const float segment_x = ImGui::GetCursorPosX(); + ImGui::Text("Segment %d", segment); + ImGui::SameLine(); + ImGui::SetCursorPosX(segment_x + ImGui::CalcTextSize(util::string_format("Segment %d", std::max(segment, session->route_id.available_end)).c_str()).x + 16.0f); + app_push_mono_font(); + const int total = static_cast(std::max(0.0, session->route_data.x_max - route_start)); + const int current = static_cast(elapsed); + const int minutes_width = std::max(2, static_cast(std::to_string(total / 60).size())); + ImGui::Text("%0*d:%02d / %0*d:%02d", minutes_width, current / 60, current % 60, minutes_width, total / 60, total % 60); + ImGui::SameLine(0.0f, 20.0f); + std::string clock_text = "--"; + if (wall && !wall->values.empty() && wall->values.front() > 0.0) { + const time_t seconds = static_cast(wall->values.front() / 1.0e9 + elapsed); + std::tm tm = {}; + std::string zone_name; + if (state->route_time_zone == 1) localtime_r(&seconds, &tm); + else gmtime_r(&seconds, &tm); + char text[64]; + std::strftime(text, sizeof(text), "%Y-%m-%d %H:%M:%S", &tm); + if (state->route_time_zone == 1) { + char name[32]; + std::strftime(name, sizeof(name), "%Z", &tm); + zone_name = name; + } + if (state->route_time_zone == 0) zone_name = "UTC"; + clock_text = util::string_format("%s %s", text, zone_name.c_str()); + } + if (ImGui::BeginMenu((clock_text + "###route_clock").c_str())) { + const char *zones[] = {"UTC", "Computer local"}; + for (int i = 0; i < 2; ++i) { + if (ImGui::MenuItem(zones[i], nullptr, state->route_time_zone == i)) state->route_time_zone = i; + } + ImGui::EndMenu(); + } + app_pop_mono_font(); + } height = ImGui::GetWindowSize().y; ImGui::EndMainMenuBar(); } diff --git a/openpilot/tools/jotpluggler/sketch_layout.cc b/openpilot/tools/jotpluggler/sketch_layout.cc index 5440d9dd8d..8fb72fbe1e 100644 --- a/openpilot/tools/jotpluggler/sketch_layout.cc +++ b/openpilot/tools/jotpluggler/sketch_layout.cc @@ -25,7 +25,7 @@ #include "common/util.h" #include "json11/json11.hpp" #include "tools/replay/logreader.h" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" namespace fs = std::filesystem; @@ -74,6 +74,8 @@ struct SeriesAccumulator { void append_fixed_scalar_point(RouteSeries *series, double tm, double value); void append_dynamic_scalar_point(const std::string &path, double tm, double value, SeriesAccumulator *series); +RouteSeries *ensure_dynamic_series(const std::string &path, SeriesAccumulator *series); +void append_text_point(RouteSeries *series, double tm, capnp::Text::Reader text, SeriesAccumulator *accumulator); RouteSeries *ensure_list_scalar_series(const std::string &base_path, size_t index, SeriesAccumulator *series); void append_can_frame(CanServiceKind service, uint8_t bus, @@ -94,6 +96,7 @@ void decode_can_frame(const dbc::Database *can_dbc, #include "tools/jotpluggler/generated_event_extractors.h" struct LoadedRouteArtifacts { + std::optional init_data; std::vector series; std::vector can_messages; std::vector logs; @@ -317,20 +320,18 @@ std::map trim_segments(std::map segments, co std::map load_segments_from_json(const json11::Json &json) { std::map segments; - static const std::regex rx(R"(\/(\d+)\/)"); - for (const auto &value : json.object_items()) { - for (const auto &url : value.second.array_items()) { - const std::string url_str = url.string_value(); - std::smatch match; - if (!std::regex_search(url_str, match, rx)) continue; - add_log_file_to_segments(&segments, std::stoi(match[1].str()), url_str); - } + for (const auto &[number, files] : json.object_items()) { + segments[std::stoi(number)] = { + files["rlog"].string_value(), files["qlog"].string_value(), files["narrow_road"].string_value(), + files["cabin"].string_value(), files["wide_road"].string_value(), files["qcamera"].string_value(), + }; } return segments; } std::map load_segments_from_server(const RouteSelection &route) { - const std::string result = PyDownloader::getRouteFiles(route.canonical_name); + const std::string selector = route.selector == LogSelector::RLog ? "r" : route.selector == LogSelector::QLog ? "q" : "a"; + const std::string result = PyTools::resolveRouteFiles(route.canonical_name, route.begin_segment, route.end_segment, selector); if (result.empty()) throw std::runtime_error("Failed to fetch route files for " + route.canonical_name); std::string parse_error; @@ -958,7 +959,14 @@ void append_can_frame(CanServiceKind service, }); } +void append_text_point(RouteSeries *series, double tm, capnp::Text::Reader text, SeriesAccumulator *accumulator) { + const double value = accumulator->enum_info[series->path].text_value(std::string(text.begin(), text.size())); + append_fixed_scalar_point(series, tm, value); +} + void append_dynamic_scalar_point(const std::string &path, double tm, double value, SeriesAccumulator *series); +RouteSeries *ensure_dynamic_series(const std::string &path, SeriesAccumulator *series); +void append_text_point(RouteSeries *series, double tm, capnp::Text::Reader text, SeriesAccumulator *accumulator); void decode_can_frame(const dbc::Database *can_dbc, const std::string &service_name, @@ -1115,6 +1123,15 @@ void merge_series_accumulator(SeriesAccumulator *dst, SeriesAccumulator *src) { throw std::runtime_error("Fixed-series slot count mismatch during merge"); } + const auto remap_text = [&](RouteSeries &series) { + auto it = src->enum_info.find(series.path); + if (it != src->enum_info.end() && it->second.is_text) { + merge_text_labels(&series, it->second, &dst->enum_info[series.path]); + } + }; + for (RouteSeries &series : src->fixed_series) remap_text(series); + for (RouteSeries &series : src->dynamic_series) remap_text(series); + for (size_t i = 0; i < dst->fixed_series.size(); ++i) { merge_route_series(&dst->fixed_series[i], &src->fixed_series[i]); } @@ -1201,9 +1218,12 @@ RouteData build_route_data(std::vector &&series_list, route_data.paths.reserve(series_list.size()); for (RouteSeries &series : series_list) { if (series.times.empty()) continue; - route_data.has_time_range = true; - route_data.x_min = route_data.series.empty() ? series.times.front() : std::min(route_data.x_min, series.times.front()); - route_data.x_max = route_data.series.empty() ? series.times.back() : std::max(route_data.x_max, series.times.back()); + // initData is copied into every segment with its original route timestamp. + if (series.path.rfind("/initData/", 0) != 0) { + route_data.x_min = !route_data.has_time_range ? series.times.front() : std::min(route_data.x_min, series.times.front()); + route_data.x_max = !route_data.has_time_range ? series.times.back() : std::max(route_data.x_max, series.times.back()); + route_data.has_time_range = true; + } route_data.paths.push_back(series.path); route_data.series.push_back(std::move(series)); } @@ -1557,9 +1577,11 @@ LoadedRouteArtifacts load_route_series_parallel( const dbc::Database *can_dbc, LogSelector selector, bool skip_raw_can, + bool migrate, LoadStats *stats) { struct SegmentResult { SeriesAccumulator series; + std::optional init_data; std::vector logs; std::vector timeline; std::vector thumbnails; @@ -1600,8 +1622,16 @@ LoadedRouteArtifacts load_route_series_parallel( continue; } + std::string migrated_data; + if (migrate) { + migrated_data = PyTools::migrateLog(log_path); + } + LogReader reader; - if (!reader.load(log_path, nullptr, true)) { + const bool loaded = !migrated_data.empty() + ? reader.load(migrated_data.data(), migrated_data.size()) + : reader.load(log_path, nullptr, true); + if (!loaded) { segment_stats.failed = true; std::lock_guard lock(error_mutex); if (first_error.empty()) { @@ -1624,6 +1654,13 @@ LoadedRouteArtifacts load_route_series_parallel( const auto extract_start = LoadStats::Clock::now(); 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); + for (const Event &record : reader.events) { + if (record.which != cereal::Event::Which::INIT_DATA) continue; + with_parseable_event(record.data, [&](const cereal::Event::Reader &event) { + results[index].init_data = extract_init_data(event.getInitData()); + }); + if (results[index].init_data) break; + } 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(); @@ -1671,6 +1708,12 @@ LoadedRouteArtifacts load_route_series_parallel( } } LoadedRouteArtifacts artifacts; + for (SegmentResult &result : results) { + if (result.init_data) { + artifacts.init_data = std::move(result.init_data); + break; + } + } artifacts.series = collect_series(std::move(merged)); artifacts.can_messages = std::move(merged.can_messages); artifacts.logs = std::move(logs); @@ -1720,6 +1763,7 @@ std::vector collect_route_roots_for_paths(const std::vector init_data; const SchemaIndex &schema = SchemaIndex::instance(); SeriesAccumulator series = make_series_accumulator(schema); std::vector logs; @@ -1762,6 +1806,9 @@ void StreamAccumulator::appendEvent(kj::ArrayPtr data) { if (!impl_->time_offset.has_value()) { impl_->time_offset = boot_time; } + if (which == cereal::Event::Which::INIT_DATA) { + impl_->init_data = extract_init_data(event.getInitData()); + } if (which == cereal::Event::Which::CAR_PARAMS) { const std::string fingerprint = event.getCarParams().getCarFingerprint().cStr(); if (!fingerprint.empty() && fingerprint != impl_->car_fingerprint) { @@ -1803,6 +1850,8 @@ void StreamAccumulator::appendCanFrames(CanServiceKind service, const std::vecto StreamExtractBatch StreamAccumulator::takeBatch() { StreamExtractBatch batch; + batch.init_data = std::move(impl_->init_data); + impl_->init_data.reset(); batch.car_fingerprint = impl_->car_fingerprint; batch.dbc_name = impl_->detected_dbc_name; if (impl_->time_offset.has_value()) { @@ -1849,6 +1898,7 @@ SketchLayout load_sketch_layout(const fs::path &layout_path) { RouteData load_route_data(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name, + bool migrate, const RouteLoadProgressCallback &progress) { if (route_name.empty()) return RouteData{}; @@ -1876,7 +1926,7 @@ RouteData load_route_data(const std::string &route_name, const SchemaIndex &schema = SchemaIndex::instance(); LoadedRouteArtifacts artifacts = load_route_series_parallel(segments, schema, can_dbc ? &*can_dbc : nullptr, - route.selector, can_dbc.has_value(), &stats); + route.selector, can_dbc.has_value(), migrate, &stats); RouteData route_data = build_route_data(std::move(artifacts.series), std::move(artifacts.can_messages), std::move(artifacts.logs), @@ -1885,6 +1935,7 @@ RouteData load_route_data(const std::string &route_name, std::move(artifacts.enum_info), metadata.car_fingerprint, resolved_dbc); + route_data.init_data = std::move(artifacts.init_data); route_data.route_id = make_route_identifier(route, segments); 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); diff --git a/openpilot/tools/jotpluggler/stream.cc b/openpilot/tools/jotpluggler/stream.cc index fcfa6585bb..35bca3d3c5 100644 --- a/openpilot/tools/jotpluggler/stream.cc +++ b/openpilot/tools/jotpluggler/stream.cc @@ -72,6 +72,7 @@ bool can_message_less(const CanMessageData &a, const CanMessageData &b) { } void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch batch) { + if (batch.init_data) session->route_data.init_data = std::move(batch.init_data); if (batch.has_time_offset) { session->stream_time_offset = batch.time_offset; } @@ -83,7 +84,7 @@ void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch } if (!batch.enum_info.empty()) { for (auto &[path, info] : batch.enum_info) { - session->route_data.enum_info[path] = std::move(info); + if (!info.is_text) session->route_data.enum_info[path] = std::move(info); } } @@ -92,6 +93,10 @@ void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch std::vector touched_paths; touched_paths.reserve(batch.series.size()); for (RouteSeries &incoming : batch.series) { + auto labels = batch.enum_info.find(incoming.path); + if (labels != batch.enum_info.end() && labels->second.is_text) { + merge_text_labels(&incoming, labels->second, &session->route_data.enum_info[incoming.path]); + } touched_paths.push_back(incoming.path); auto existing_it = session->series_by_path.find(incoming.path); if (existing_it == session->series_by_path.end()) { diff --git a/openpilot/tools/lib/auth.py b/openpilot/tools/lib/auth.py index 6a685e38d9..02df4954a5 100755 --- a/openpilot/tools/lib/auth.py +++ b/openpilot/tools/lib/auth.py @@ -22,34 +22,50 @@ Examples:: """ import argparse +import json import sys +import subprocess import pprint -import webbrowser -from http.server import BaseHTTPRequestHandler, HTTPServer +import time +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any -from urllib.parse import parse_qs, urlencode +from urllib.parse import parse_qs, urlencode, urlsplit -from openpilot.tools.lib.api import APIError, CommaApi, UnauthorizedError +from openpilot.tools.lib.api import CommaApi, UnauthorizedError from openpilot.tools.lib.auth_config import set_token, get_token -class ClientRedirectServer(HTTPServer): - query_params: dict[str, Any] = {} +class ClientRedirectServer(ThreadingHTTPServer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.query_params: dict[str, Any] = {} + self.result_lock = threading.Lock() + + def get_request(self): + request, address = super().get_request() + request.settimeout(1) # Bound incomplete requests, including browser preconnections. + return request, address class ClientRedirectHandler(BaseHTTPRequestHandler): def do_GET(self): - if not self.path.startswith('/auth'): + if urlsplit(self.path).path not in ('/auth', '/auth/'): self.send_response(204) + self.end_headers() return - query = self.path.split('?', 1)[-1] - query_parsed = parse_qs(query, keep_blank_values=True) - self.server.query_params = query_parsed + query_parsed = parse_qs(urlsplit(self.path).query, keep_blank_values=True) + with self.server.result_lock: + if not self.server.query_params and ('code' in query_parsed or 'error' in query_parsed): + self.server.query_params = query_parsed self.send_response(200) self.send_header('Content-type', 'text/plain') self.end_headers() - self.wfile.write(b'Return to the CLI to continue') + try: + self.wfile.write(b'Sign-in received. You can close this tab and return to Cabana or your terminal.') + except ConnectionError: + pass # A closing browser tab must not discard the received callback. def log_message(self, format: str, *args: object) -> None: # noqa: A002 # stdlib override pass # this prevent http server from dumping messages to stdout @@ -94,36 +110,52 @@ def auth_redirect_link(method, port): raise NotImplementedError(f"no redirect implemented for method {method}") -def login(method): - # 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) - - while True: - web_server.handle_request() - if 'code' in web_server.query_params: - break - elif 'error' in web_server.query_params: - print('Authentication Error: "{}". Description: "{}" '.format( - web_server.query_params['error'], - web_server.query_params.get('error_description')), file=sys.stderr) - break - +def login(method, timeout=180): + """Sign in through a browser and save the token, returning a success/error status.""" try: - auth_resp = CommaApi().post('v2/auth/', data={'code': web_server.query_params['code'], 'provider': web_server.query_params['provider']}) - set_token(auth_resp['access_token']) - except APIError as e: - print(f'Authentication Error: {e}', file=sys.stderr) + with ClientRedirectServer(('localhost', 0), ClientRedirectHandler) as server: + url = auth_redirect_link(method, server.server_port) + print(f'To sign in, use your browser and navigate to {url}', file=sys.stderr) + browser = subprocess.Popen(['open' if sys.platform == 'darwin' else 'xdg-open', url], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + server.timeout = min(0.1, max(0, deadline - time.monotonic())) + server.handle_request() + params = server.query_params + if 'error' in params: + return {"error": "Sign-in was declined. Choose a provider to try again."} + if 'code' in params: + provider = {'google': 'g', 'apple': 'a', 'github': 'h'}[method] + if len(params['code']) != 1 or not params['code'][0].strip() or params.get('provider') != [provider]: + return {"error": "Invalid sign-in response. Please try again."} + response = CommaApi().post('v2/auth/', data={'code': params['code'], 'provider': params['provider']}, timeout=30) + token = response.get('access_token') + if not isinstance(token, str) or not token.strip(): + return {"error": "Sign-in did not return an access token. Please try again."} + CommaApi(token).get('v1/me', timeout=30) + set_token(token) + return {"success": True} + if browser.poll() not in (None, 0): + return {"error": "Could not open your browser. Check your default browser and try again."} + return {"error": "Sign-in timed out. Choose a provider to try again."} + except Exception: + return {"error": "Could not complete sign-in. Check your connection and try again."} if __name__ == '__main__': parser = argparse.ArgumentParser(description='Login to your comma account') parser.add_argument('method', default='google', const='google', nargs='?', choices=['google', 'apple', 'github', 'jwt']) parser.add_argument('jwt', nargs='?') + parser.add_argument('--json', action='store_true', help='Return browser sign-in status as JSON') args = parser.parse_args() + if args.json: + if args.method == 'jwt': + parser.error('--json requires a browser sign-in provider') + print(json.dumps(login(args.method))) + sys.exit(0) + if args.method == 'jwt': if args.jwt is None: print("method JWT selected, but no JWT was provided") @@ -131,7 +163,10 @@ if __name__ == '__main__': set_token(args.jwt) else: - login(args.method) + result = login(args.method) + if "error" in result: + print(result["error"], file=sys.stderr) + sys.exit(1) try: me = CommaApi(token=get_token()).get('/v1/me') diff --git a/openpilot/tools/lib/auth_config.py b/openpilot/tools/lib/auth_config.py index 5966bd34a9..cc29b3ba1a 100644 --- a/openpilot/tools/lib/auth_config.py +++ b/openpilot/tools/lib/auth_config.py @@ -1,6 +1,7 @@ import json import os from openpilot.common.hardware.hw import Paths +from openpilot.common.utils import atomic_write class MissingAuthConfigError(Exception): @@ -18,12 +19,5 @@ def get_token(): def set_token(token): os.makedirs(Paths.config_root(), exist_ok=True) - with open(os.path.join(Paths.config_root(), 'auth.json'), 'w') as f: + with atomic_write(os.path.join(Paths.config_root(), 'auth.json'), overwrite=True) as f: json.dump({'access_token': token}, f) - - -def clear_token(): - try: - os.unlink(os.path.join(Paths.config_root(), 'auth.json')) - except FileNotFoundError: - pass diff --git a/openpilot/tools/lib/file_downloader.py b/openpilot/tools/lib/file_downloader.py index efb06095be..b473740be9 100755 --- a/openpilot/tools/lib/file_downloader.py +++ b/openpilot/tools/lib/file_downloader.py @@ -5,6 +5,7 @@ Called by C++ replay/cabana via subprocess. Subcommands: route-files - Get route file URLs as JSON + resolve-route-files - Resolve logs and cameras from API and internal storage 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 @@ -18,17 +19,17 @@ import os import shutil import sys import tempfile - -import zstandard as zstd +import traceback +from urllib.parse import urlparse from openpilot.common.hardware.hw import Paths -from openpilot.tools.lib.api import CommaApi, UnauthorizedError, APIError -from openpilot.tools.lib.auth_config import get_token -from openpilot.tools.lib.url_file import URLFile def api_call(func): """Run an API call, outputting JSON result or error to stdout.""" + from openpilot.tools.lib.api import CommaApi, UnauthorizedError, APIError + from openpilot.tools.lib.auth_config import get_token + try: result = func(CommaApi(get_token())) json.dump(result, sys.stdout) @@ -62,6 +63,7 @@ def make_decompressor(compression): if compression == 'bz2': return bz2.BZ2Decompressor() if compression == 'zst': + import zstandard as zstd return zstd.ZstdDecompressor().decompressobj() raise ValueError(f"Unsupported compression type: {compression}") @@ -101,6 +103,100 @@ def cmd_route_files(args): api_call(lambda api: api.get(f"v1/route/{args.route}/files")) +def resolve_route_files(route_name: str, begin: int = 0, end: int = -1, selector: str = "a") -> dict[int, dict[str, str]]: + from openpilot.tools.lib.filereader import DATA_ENDPOINT + from openpilot.tools.lib.file_sources import InternalUnavailableException, internal_source + from openpilot.tools.lib.route import FileName, SegmentRange + from openpilot.tools.lib.api import CommaApi + from openpilot.tools.lib.auth_config import get_token + + sr = SegmentRange(route_name) + api_error = None + try: + route_files = CommaApi(get_token()).get(f"v1/route/{sr.route_name}/files") + except Exception as e: + api_error = e + route_files = {} + fields = {"rlog": FileName.RLOG, "qlog": FileName.QLOG, + "narrow_road": FileName.FCAMERA, "cabin": FileName.DCAMERA, "wide_road": FileName.ECAMERA, "qcamera": FileName.QCAMERA} + names = {name: key for key, fns in fields.items() for name in fns} + names.update({"rlog": "rlog", "qlog": "qlog"}) + api_files: dict[int, dict[str, str]] = {} + for url in (url for urls in route_files.values() for url in urls): + _, number, name = urlparse(url).path.rsplit("/", 2) + if name in names: + api_files.setdefault(int(number), {})[names[name]] = url + # Preserve replay's inclusive segment bounds. + open_ended = end < 0 + local_internal = os.path.isdir(DATA_ENDPOINT) + if open_ended: + end = max(begin, max(api_files, default=0)) + if local_internal: + internal_dir = os.path.join(DATA_ENDPOINT, sr.dongle_id, sr.log_id) + if os.path.isdir(internal_dir): + end = max(end, max((int(name) for name in os.listdir(internal_dir) if name.isdigit()), default=0)) + indices = list(range(max(0, begin), end + 1)) + segments: dict[int, dict[str, str]] = {seg: {} for seg in indices} + internal_errors: dict[str, Exception] = {} + + def resolve(key, requested): + files = {seg: api_files[seg][key] for seg in requested if key in api_files.get(seg, {})} + missing = [seg for seg in requested if seg not in files] + if missing: + try: + files.update(internal_source(sr, missing, fields[key])) + except Exception as e: + if e is not InternalUnavailableException: + internal_errors[key] = e + for seg, path in files.items(): + segments[seg][key] = path + + log_types = ["qlog"] if selector == "q" else ["rlog"] + if selector == "a": + log_types.append("qlog") + for key in log_types: + resolve(key, [seg for seg in indices if not segments[seg]]) + if open_ended and not local_internal: + # Remote internal storage exposes individual files, not a segment listing. + # Probe the contiguous tail beyond the API's last segment until no selected log exists. + number = end + 1 + while True: + internal_errors.clear() + segments[number] = {} + for key in log_types: + if not segments[number]: + resolve(key, [number]) + if not segments[number]: + if internal_errors: + print(f"Internal discovery stopped at segment {number}; keeping resolved logs", file=sys.stderr) + traceback.print_exception(next(iter(internal_errors.values()))) + del segments[number] + break + indices.append(number) + number += 1 + available = [seg for seg in indices if segments[seg]] + if not available: + if api_error is not None: + raise api_error + raise FileNotFoundError(f"No {selector} logs found for {sr.route_name} in segments {indices} through the API or internal storage") + for key in ("narrow_road", "cabin", "wide_road", "qcamera"): + resolve(key, available) + return {seg: segments[seg] for seg in available} + + +def cmd_resolve_route_files(args): + try: + result = resolve_route_files(args.route, args.begin, args.end, args.selector) + except Exception as e: + error = f"{type(e).__name__} resolving {args.route} (begin={args.begin}, end={args.end}, selector={args.selector}): {e}" + print(error, file=sys.stderr) + traceback.print_exc() + result = {"error": error} + json.dump(result, sys.stdout) + sys.stdout.write("\n") + sys.stdout.flush() + + def cmd_download(args): url = args.url use_cache = not args.no_cache @@ -126,6 +222,7 @@ def cmd_download(args): try: # Stream the file in a single HTTP request instead of making # a separate Range request per chunk (which was very slow). + from openpilot.tools.lib.url_file import URLFile pool = URLFile.pool_manager() r = pool.request("GET", url, preload_content=False) if r.status not in (200, 206): @@ -232,6 +329,13 @@ def main(): p_rf.add_argument("route") p_rf.set_defaults(func=cmd_route_files) + p_resolve = subparsers.add_parser("resolve-route-files") + p_resolve.add_argument("route") + p_resolve.add_argument("--begin", type=int, default=0) + p_resolve.add_argument("--end", type=int, default=-1, help="Inclusive last segment; negative means open-ended") + p_resolve.add_argument("--selector", choices=("a", "r", "q"), default="a") + p_resolve.set_defaults(func=cmd_resolve_route_files) + p_dl = subparsers.add_parser("download") p_dl.add_argument("url") p_dl.add_argument("--no-cache", action="store_true") diff --git a/openpilot/tools/lib/github_utils.py b/openpilot/tools/lib/github_utils.py index 6a443b4155..1e473398c3 100644 --- a/openpilot/tools/lib/github_utils.py +++ b/openpilot/tools/lib/github_utils.py @@ -84,13 +84,13 @@ class GithubUtils: def get_bucket_link(self, bucket): return f'https://raw.githubusercontent.com/{self.OWNER}/{self.DATA_REPO}/refs/heads/{bucket}' - def comment_on_pr(self, comment, pr_branch, commenter="", overwrite=False): + def comment_on_pr(self, comment, pr_branch, commenter="", overwrite=False, comment_marker=""): pr_number = self.get_pr_number(pr_branch) data = f'{{"body": "{comment}"}}' github_path = f'issues/{pr_number}/comments' if overwrite: r = self.api_call(github_path) - comments = [x['id'] for x in r.json() if x['user']['login'] == commenter] + comments = [x['id'] for x in r.json() if x['user']['login'] == commenter and (not comment_marker or comment_marker in x['body'])] if comments: github_path = f'issues/comments/{comments[0]}' self.api_call(github_path, data=data, method=HTTPMethod.PATCH) diff --git a/openpilot/tools/lib/route.py b/openpilot/tools/lib/route.py index 98334a06c8..94ed7c1fa6 100644 --- a/openpilot/tools/lib/route.py +++ b/openpilot/tools/lib/route.py @@ -206,7 +206,8 @@ class Segment: class RouteName: def __init__(self, name_str: str): self._name_str = name_str - delim = next(c for c in self._name_str if c in ("|", "/")) + pipe, slash = name_str.find("|"), name_str.find("/") + delim = "|" if slash == -1 or 0 <= pipe < slash else "/" self._dongle_id, self._time_str = self._name_str.split(delim) assert len(self._dongle_id) == 16, self._name_str diff --git a/openpilot/tools/longitudinal_maneuvers/maneuversd.py b/openpilot/tools/longitudinal_maneuvers/maneuversd.py index 3cba805f00..63491a745c 100755 --- a/openpilot/tools/longitudinal_maneuvers/maneuversd.py +++ b/openpilot/tools/longitudinal_maneuvers/maneuversd.py @@ -181,7 +181,6 @@ def main(): longitudinalPlan.shouldStop = should_stop(v_ego, accel) longitudinalPlan.allowBrake = True - longitudinalPlan.allowThrottle = True longitudinalPlan.hasLead = True longitudinalPlan.speeds = [0.2] # triggers carControl.cruiseControl.resume in controlsd diff --git a/openpilot/tools/longitudinal_maneuvers/mpc_longitudinal_tuning_report.py b/openpilot/tools/longitudinal_maneuvers/mpc_longitudinal_tuning_report.py index 6b711a0847..4ad15e66dd 100644 --- a/openpilot/tools/longitudinal_maneuvers/mpc_longitudinal_tuning_report.py +++ b/openpilot/tools/longitudinal_maneuvers/mpc_longitudinal_tuning_report.py @@ -25,7 +25,7 @@ def get_html_from_results(results, labels, AXIS): plt.close(fig) return fig_buffer.getvalue() + '
' -def generate_mpc_tuning_report(e2e=False): +def generate_mpc_tuning_report(): htmls = [] results = {} @@ -42,7 +42,6 @@ def generate_mpc_tuning_report(e2e=False): cruise_values=[100, 100], prob_lead_values=[1.0, 1.0], breakpoints=[1., 11], - e2e=e2e, ) valid, results[lead_accel] = man.evaluate() labels.append(f'{lead_accel} m/s^2 lead acceleration') @@ -64,7 +63,6 @@ def generate_mpc_tuning_report(e2e=False): initial_distance_lead=140., speed_lead_values=[0.0, 0.], breakpoints=[0., 30.], - e2e=e2e, ) valid, results[speed] = man.evaluate() labels.append(f'{speed} m/s approach speed') @@ -87,7 +85,6 @@ def generate_mpc_tuning_report(e2e=False): initial_distance_lead=desired_follow_distance(speed, speed), speed_lead_values=[speed, speed, speed - oscil, speed + oscil, speed - oscil, speed + oscil, speed - oscil], breakpoints=[0.,2., 5, 8, 15, 18, 25.], - e2e=e2e, ) valid, results[oscil] = man.evaluate() labels.append(f'{oscil} m/s oscillation size') @@ -115,7 +112,6 @@ def generate_mpc_tuning_report(e2e=False): initial_distance_lead=desired_follow_distance(speed, speed), speed_lead_values=lead_speeds, breakpoints=bps, - e2e=e2e, ) valid, results[oscil] = man.evaluate() labels.append(f'{oscil} m/s oscillation size') @@ -138,7 +134,6 @@ def generate_mpc_tuning_report(e2e=False): initial_distance_lead=distance, speed_lead_values=[30.0], breakpoints=[0.], - e2e=e2e, ) valid, results[distance] = man.evaluate() labels.append(f'{distance} m initial distance') @@ -160,7 +155,6 @@ def generate_mpc_tuning_report(e2e=False): initial_distance_lead=distance, speed_lead_values=[20.0], breakpoints=[0.], - e2e=e2e, ) valid, results[distance] = man.evaluate() labels.append(f'{distance} m initial distance') @@ -183,7 +177,6 @@ def generate_mpc_tuning_report(e2e=False): initial_distance_lead=60.0, speed_lead_values=[30.0, 30.0, 0.0], breakpoints=[0., 5., 5 + stop_time], - e2e=e2e, ) valid, results[stop_time] = man.evaluate() labels.append(f'{stop_time} seconds stop time') @@ -207,7 +200,6 @@ def generate_mpc_tuning_report(e2e=False): speed_lead_values=[speed, speed, speed], prob_lead_values=[0.0, 0.0, 1.0], breakpoints=[0., 5.0, 5.01], - e2e=e2e, ) valid, results[speed] = man.evaluate() labels.append(f'{speed} m/s speed') @@ -230,7 +222,6 @@ def generate_mpc_tuning_report(e2e=False): speed_lead_values=[0.0, 0.0, speed], prob_lead_values=[1.0, 1.0, 1.0], breakpoints=[0., 1.0, speed/2], - e2e=e2e, ) valid, results[speed] = man.evaluate() labels.append(f'{speed} m/s speed') @@ -254,7 +245,6 @@ def generate_mpc_tuning_report(e2e=False): cruise_values=[0.0, speed], prob_lead_values=[0.0, 0.0], breakpoints=[1., 1.01], - e2e=e2e, ) valid, results[speed] = man.evaluate() labels.append(f'{speed} m/s speed') @@ -278,7 +268,6 @@ def generate_mpc_tuning_report(e2e=False): cruise_values=[speed, 10.0], prob_lead_values=[0.0, 0.0], breakpoints=[1., 1.01], - e2e=e2e, ) valid, results[speed] = man.evaluate() labels.append(f'{speed} m/s speed') @@ -290,14 +279,13 @@ def generate_mpc_tuning_report(e2e=False): return htmls if __name__ == '__main__': - e2e = '--e2e' in sys.argv file_name = 'long_mpc_tune_report.html' for arg in sys.argv[1:]: if not arg.startswith('-'): file_name = arg break - htmls = generate_mpc_tuning_report(e2e=e2e) + htmls = generate_mpc_tuning_report() with open(file_name, 'w') as f: f.write(markdown.markdown('# MPC longitudinal tuning report')) diff --git a/openpilot/tools/replay/.gitignore b/openpilot/tools/replay/.gitignore index aa615770a2..720992de90 100644 --- a/openpilot/tools/replay/.gitignore +++ b/openpilot/tools/replay/.gitignore @@ -1,2 +1,3 @@ replay tests/test_replay +tests/test_route diff --git a/openpilot/tools/replay/SConscript b/openpilot/tools/replay/SConscript index 9e060bc829..8358d28cba 100644 --- a/openpilot/tools/replay/SConscript +++ b/openpilot/tools/replay/SConscript @@ -7,10 +7,12 @@ base_frameworks = ['VideoToolbox', 'CoreMedia', 'CoreFoundation', 'CoreVideo'] i 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"] + "route.cc", "util.cc", "seg_mgr.cc", "timeline.cc", "py_process.cc", "py_tools.cc"] if arch != "Darwin": 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 + ['ncurses'] + base_libs replay_env.Program("replay", ["main.cc"], LIBS=replay_libs, FRAMEWORKS=base_frameworks) +if GetOption('extras'): + replay_env.Program("tests/test_route", ["tests/test_route.cc"], LIBS=replay_libs, FRAMEWORKS=base_frameworks) diff --git a/openpilot/tools/replay/consoleui.cc b/openpilot/tools/replay/consoleui.cc index 6dbc984b2f..1092bfb3e1 100644 --- a/openpilot/tools/replay/consoleui.cc +++ b/openpilot/tools/replay/consoleui.cc @@ -9,7 +9,7 @@ #include "common/ratekeeper.h" #include "common/util.h" #include "common/version.h" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" #include "sunnypilot/common/version.h" diff --git a/openpilot/tools/replay/filereader.cc b/openpilot/tools/replay/filereader.cc index 6ad56bbfc6..f1b506c2b4 100644 --- a/openpilot/tools/replay/filereader.cc +++ b/openpilot/tools/replay/filereader.cc @@ -4,12 +4,12 @@ #include #include "common/util.h" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" std::string FileReader::read(const std::string &file, std::atomic *abort) { const bool is_remote = (file.find("https://") == 0) || (file.find("http://") == 0); if (is_remote) { - std::string local_path = PyDownloader::download(file, cache_to_local_, abort); + std::string local_path = PyTools::download(file, cache_to_local_, abort); if (local_path.empty()) return {}; return util::read_file(local_path); } @@ -19,7 +19,7 @@ std::string FileReader::read(const std::string &file, std::atomic *abort) 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); + std::string local_path = PyTools::decompress(file, abort); if (local_path.empty()) return {}; std::string data = util::read_file(local_path); unlink(local_path.c_str()); diff --git a/openpilot/tools/replay/framereader.cc b/openpilot/tools/replay/framereader.cc index 19e5fa0d0f..b475e297b3 100644 --- a/openpilot/tools/replay/framereader.cc +++ b/openpilot/tools/replay/framereader.cc @@ -7,7 +7,7 @@ #include "common/util.h" #include "common/yuv.h" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" #include "tools/replay/util.h" #include "common/hardware/hw.h" @@ -75,7 +75,7 @@ FrameReader::~FrameReader() { bool FrameReader::load(CameraType type, const std::string &url, bool no_hw_decoder, std::atomic *abort, bool local_cache) { std::string local_file_path; if (url.find("https://") == 0 || url.find("http://") == 0) { - local_file_path = PyDownloader::download(url, local_cache, abort); + local_file_path = PyTools::download(url, local_cache, abort); if (local_file_path.empty()) return false; } else { local_file_path = url; diff --git a/openpilot/tools/replay/logreader.cc b/openpilot/tools/replay/logreader.cc index 315aed9741..cafd862e16 100644 --- a/openpilot/tools/replay/logreader.cc +++ b/openpilot/tools/replay/logreader.cc @@ -4,7 +4,7 @@ #include #include #include "tools/replay/filereader.h" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" #include "tools/replay/util.h" #include "common/util.h" @@ -142,7 +142,6 @@ void LogReader::migrateOldEvents() { new_state.setAlertType(old_dep.getAlertType()); new_state.setEnabled(old_dep.getEnabled()); new_state.setEngageable(old_dep.getEngageable()); - new_state.setExperimentalMode(old_dep.getExperimentalMode()); new_state.setPersonality(old_dep.getPersonality()); new_state.setState(old_dep.getState()); diff --git a/openpilot/tools/replay/py_downloader.cc b/openpilot/tools/replay/py_downloader.cc deleted file mode 100644 index db2b2be127..0000000000 --- a/openpilot/tools/replay/py_downloader.cc +++ /dev/null @@ -1,225 +0,0 @@ -#include "tools/replay/py_downloader.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/replay/util.h" - -namespace { - -static std::mutex handler_mutex; -static DownloadProgressHandler progress_handler = nullptr; - -void reportProgress(const char *line) { - uint64_t cur = 0, total = 0; - if (sscanf(line, "PROGRESS:%llu:%llu", (unsigned long long *)&cur, (unsigned long long *)&total) != 2) return; - std::lock_guard lk(handler_mutex); - if (progress_handler && total > 0) progress_handler(cur, total, true); -} - -// Run a Python command and capture stdout. Stderr is scanned for PROGRESS lines and otherwise passed -// through to the parent's stderr. Returns stdout content. If abort is signaled, kills the child process. -std::string runPython(const std::vector &args, std::atomic *abort = nullptr) { - // Build argv for execvp - std::vector argv; - argv.push_back("python3"); - argv.push_back("-m"); - argv.push_back("openpilot.tools.lib.file_downloader"); - for (const auto &a : args) { - argv.push_back(a.c_str()); - } - argv.push_back(nullptr); - - int stdout_pipe[2], stderr_pipe[2]; - if (pipe(stdout_pipe) != 0) { - rWarning("py_downloader: pipe() failed"); - return {}; - } - if (pipe(stderr_pipe) != 0) { - rWarning("py_downloader: pipe() failed"); - close(stdout_pipe[0]); close(stdout_pipe[1]); - return {}; - } - - pid_t pid = fork(); - if (pid < 0) { - rWarning("py_downloader: fork() failed"); - close(stdout_pipe[0]); close(stdout_pipe[1]); - close(stderr_pipe[0]); close(stderr_pipe[1]); - return {}; - } - - if (pid == 0) { - // Child process — detach from controlling terminal so Python - // cannot corrupt terminal settings needed by ncurses in the parent. - setsid(); - int devnull = open("/dev/null", O_RDONLY); - if (devnull >= 0) { - dup2(devnull, STDIN_FILENO); - if (devnull > STDERR_FILENO) close(devnull); - } - - // Clear OPENPILOT_PREFIX so the Python process uses default paths - // (e.g. ~/.comma/auth.json). The prefix is only for IPC in the parent. - unsetenv("OPENPILOT_PREFIX"); - - close(stdout_pipe[0]); - dup2(stdout_pipe[1], STDOUT_FILENO); - close(stdout_pipe[1]); - close(stderr_pipe[0]); - dup2(stderr_pipe[1], STDERR_FILENO); - close(stderr_pipe[1]); - - execvp("python3", const_cast(argv.data())); - _exit(127); - } - - // Parent process - close(stdout_pipe[1]); - close(stderr_pipe[1]); - - // stderr carries the progress lines, so a thread reads it while the loop below waits on stdout - std::thread stderr_thread([fd = stderr_pipe[0]]() { - FILE *f = fdopen(fd, "r"); - if (!f) { - close(fd); - return; - } - char *line = nullptr; - size_t cap = 0; - while (getline(&line, &cap, f) > 0) { - if (strncmp(line, "PROGRESS:", 9) == 0) { - reportProgress(line); - } else { - fputs(line, stderr); - } - } - free(line); - fclose(f); - }); - - std::string stdout_data; - char buf[4096]; - - // Use select() so abort can interrupt while waiting for Python output. - fd_set rfds; - bool stdout_open = true; - - while (stdout_open) { - if (abort && *abort) { - kill(pid, SIGTERM); - break; - } - - FD_ZERO(&rfds); - FD_SET(stdout_pipe[0], &rfds); - - struct timeval tv = {0, 100000}; // 100ms timeout - int ret = select(stdout_pipe[0] + 1, &rfds, nullptr, nullptr, &tv); - if (ret < 0) break; - - if (FD_ISSET(stdout_pipe[0], &rfds)) { - ssize_t n = read(stdout_pipe[0], buf, sizeof(buf)); - if (n <= 0) { - stdout_open = false; - } else { - stdout_data.append(buf, n); - } - } - } - - // Drain remaining pipe data to prevent child from blocking on write - while (true) { - ssize_t n = read(stdout_pipe[0], buf, sizeof(buf)); - if (n <= 0) break; - stdout_data.append(buf, n); - } - close(stdout_pipe[0]); - stderr_thread.join(); - - int status; - waitpid(pid, &status, 0); - - const bool aborted = abort && *abort; - const bool expected_sigterm = aborted && WIFSIGNALED(status) && WTERMSIG(status) == SIGTERM; - bool failed = aborted || - (WIFEXITED(status) && WEXITSTATUS(status) != 0) || - WIFSIGNALED(status); - if (failed) { - if (expected_sigterm) { - // Route/camera teardown cancels outstanding downloader subprocesses. - // Keep that expected shutdown path quiet. - } else if (WIFEXITED(status) && WEXITSTATUS(status) != 0) { - rWarning("py_downloader: process exited with code %d", WEXITSTATUS(status)); - } else if (WIFSIGNALED(status)) { - rWarning("py_downloader: process killed by signal %d", WTERMSIG(status)); - } - std::lock_guard lk(handler_mutex); - if (progress_handler) { - progress_handler(0, 0, false); - } - return {}; - } - - // Trim trailing newline - while (!stdout_data.empty() && (stdout_data.back() == '\n' || stdout_data.back() == '\r')) { - stdout_data.pop_back(); - } - - return stdout_data; -} - -} // namespace - -void installDownloadProgressHandler(DownloadProgressHandler handler) { - std::lock_guard lk(handler_mutex); - progress_handler = handler; -} - -namespace PyDownloader { - -std::string download(const std::string &url, bool use_cache, std::atomic *abort) { - std::vector args = {"download", url}; - if (!use_cache) { - args.push_back("--no-cache"); - } - 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}); -} - -std::string getDevices() { - return runPython({"devices"}); -} - -std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms, int64_t end_ms, bool preserved) { - std::vector args = {"device-routes", dongle_id}; - if (preserved) { - args.push_back("--preserved"); - } else { - if (start_ms > 0) { - args.push_back("--start"); - args.push_back(std::to_string(start_ms)); - } - if (end_ms > 0) { - args.push_back("--end"); - args.push_back(std::to_string(end_ms)); - } - } - return runPython(args); -} - -} // namespace PyDownloader diff --git a/openpilot/tools/replay/py_process.cc b/openpilot/tools/replay/py_process.cc new file mode 100644 index 0000000000..1f46dbf384 --- /dev/null +++ b/openpilot/tools/replay/py_process.cc @@ -0,0 +1,189 @@ +#include "tools/replay/py_process.h" + +#include +#include +#include +#include +#include +#ifdef __APPLE__ +#include +#endif +#include +#include +#include +#include + +#include "tools/replay/util.h" + +namespace PyProcess { + +std::string runModule(const std::string &module, const std::vector &args, + std::atomic *abort, bool trim, const StderrLineCallback &stderr_line_cb) { + // Build argv for the Python module + std::vector argv; + argv.push_back("python3"); + argv.push_back("-m"); + argv.push_back(module.c_str()); + for (const auto &a : args) { + argv.push_back(a.c_str()); + } + argv.push_back(nullptr); + + auto open_pipe = [](int (&fds)[2]) { +#ifdef __linux__ + return pipe2(fds, O_CLOEXEC); +#else + if (pipe(fds) != 0) return -1; + if (fcntl(fds[0], F_SETFD, FD_CLOEXEC) == 0 && fcntl(fds[1], F_SETFD, FD_CLOEXEC) == 0) return 0; + close(fds[0]); close(fds[1]); + return -1; +#endif + }; + int stdout_pipe[2], stderr_pipe[2]; + if (open_pipe(stdout_pipe) != 0) { + rWarning("py_process: pipe() failed"); + return {}; + } + if (open_pipe(stderr_pipe) != 0) { + rWarning("py_process: pipe() failed"); + close(stdout_pipe[0]); close(stdout_pipe[1]); + return {}; + } + + // Avoid copying the large replay address space and running atfork handlers on + // every spawn: both can stall rendering even from a worker thread. + std::vector environment; +#ifdef __APPLE__ + char **parent_environment = *_NSGetEnviron(); +#else + char **parent_environment = environ; +#endif + for (char **entry = parent_environment; *entry; ++entry) { + if (strncmp(*entry, "OPENPILOT_PREFIX=", 17) != 0) environment.emplace_back(*entry); + } + std::vector envp; + for (auto &entry : environment) envp.push_back(entry.data()); + envp.push_back(nullptr); + + posix_spawn_file_actions_t actions; + posix_spawnattr_t attributes; + int error = posix_spawn_file_actions_init(&actions); + const bool actions_initialized = error == 0; + if (!error) error = posix_spawnattr_init(&attributes); + const bool attributes_initialized = error == 0; + if (!error) error = posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0); + if (!error) error = posix_spawn_file_actions_adddup2(&actions, stdout_pipe[1], STDOUT_FILENO); + if (!error) error = posix_spawn_file_actions_adddup2(&actions, stderr_pipe[1], STDERR_FILENO); + for (int fd : {stdout_pipe[0], stdout_pipe[1], stderr_pipe[0], stderr_pipe[1]}) { + if (!error) error = posix_spawn_file_actions_addclose(&actions, fd); + } +#ifdef POSIX_SPAWN_SETSID + if (!error) error = posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETSID); +#else + if (!error) error = posix_spawnattr_setpgroup(&attributes, 0); + if (!error) error = posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETPGROUP); +#endif + pid_t pid = -1; + if (!error) error = posix_spawnp(&pid, "python3", &actions, &attributes, const_cast(argv.data()), envp.data()); + if (attributes_initialized) posix_spawnattr_destroy(&attributes); + if (actions_initialized) posix_spawn_file_actions_destroy(&actions); + if (error) { + rWarning("py_process: posix_spawnp() failed: %s", strerror(error)); + close(stdout_pipe[0]); close(stdout_pipe[1]); + close(stderr_pipe[0]); close(stderr_pipe[1]); + return {}; + } + + // Parent process + close(stdout_pipe[1]); + close(stderr_pipe[1]); + + // stderr is read in a thread so it can be inspected while the loop below waits on stdout + std::thread stderr_thread([fd = stderr_pipe[0], cb = stderr_line_cb]() { + FILE *f = fdopen(fd, "r"); + if (!f) { + close(fd); + return; + } + char *line = nullptr; + size_t cap = 0; + while (getline(&line, &cap, f) > 0) { + if (cb) { + cb(line); + } else { + fputs(line, stderr); + } + } + free(line); + fclose(f); + }); + + std::string stdout_data; + char buf[4096]; + + // Use select() so abort can interrupt while waiting for Python output. + fd_set rfds; + bool stdout_open = true; + + while (stdout_open) { + if (abort && *abort) { + kill(pid, SIGTERM); + break; + } + + FD_ZERO(&rfds); + FD_SET(stdout_pipe[0], &rfds); + + struct timeval tv = {0, 100000}; // 100ms timeout + int ret = select(stdout_pipe[0] + 1, &rfds, nullptr, nullptr, &tv); + if (ret < 0) break; + + if (FD_ISSET(stdout_pipe[0], &rfds)) { + ssize_t n = read(stdout_pipe[0], buf, sizeof(buf)); + if (n <= 0) { + stdout_open = false; + } else { + stdout_data.append(buf, n); + } + } + } + + // Drain remaining pipe data to prevent child from blocking on write + while (true) { + ssize_t n = read(stdout_pipe[0], buf, sizeof(buf)); + if (n <= 0) break; + stdout_data.append(buf, n); + } + close(stdout_pipe[0]); + stderr_thread.join(); + + int status; + waitpid(pid, &status, 0); + + const bool aborted = abort && *abort; + const bool expected_sigterm = aborted && WIFSIGNALED(status) && WTERMSIG(status) == SIGTERM; + bool failed = aborted || + (WIFEXITED(status) && WEXITSTATUS(status) != 0) || + WIFSIGNALED(status); + if (failed) { + if (expected_sigterm) { + // Caller signaled abort; expected shutdown path. + } else if (WIFEXITED(status) && WEXITSTATUS(status) != 0) { + rWarning("py_process: %s exited with code %d", module.c_str(), WEXITSTATUS(status)); + } else if (WIFSIGNALED(status)) { + rWarning("py_process: %s killed by signal %d", module.c_str(), WTERMSIG(status)); + } + return {}; + } + + // Trim trailing newline + if (trim) { + while (!stdout_data.empty() && (stdout_data.back() == '\n' || stdout_data.back() == '\r')) { + stdout_data.pop_back(); + } + } + + return stdout_data; +} + +} // namespace PyProcess diff --git a/openpilot/tools/replay/py_process.h b/openpilot/tools/replay/py_process.h new file mode 100644 index 0000000000..a0ef491ca9 --- /dev/null +++ b/openpilot/tools/replay/py_process.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include +#include + +namespace PyProcess { + +// Called with each line of the child's stderr (newline-terminated). +using StderrLineCallback = std::function; + +// Run a Python module (`python3 -m `) and capture stdout. +// Stderr lines are passed to stderr_line_cb if set, otherwise through to the parent's stderr. +// If trim is true (default), trailing newlines are removed from the returned stdout content. +// Returns empty string on failure. If abort is signaled, kills the child process. +std::string runModule(const std::string &module, const std::vector &args, + std::atomic *abort = nullptr, bool trim = true, + const StderrLineCallback &stderr_line_cb = nullptr); + +} // namespace PyProcess diff --git a/openpilot/tools/replay/py_tools.cc b/openpilot/tools/replay/py_tools.cc new file mode 100644 index 0000000000..1bf9d1fedc --- /dev/null +++ b/openpilot/tools/replay/py_tools.cc @@ -0,0 +1,102 @@ +#include "tools/replay/py_tools.h" + +#include +#include +#include +#include + +#include "tools/replay/py_process.h" + +namespace { + +constexpr const char *AUTH_MODULE = "openpilot.tools.lib.auth"; +constexpr const char *DOWNLOADER_MODULE = "openpilot.tools.lib.file_downloader"; +constexpr const char *MIGRATION_MODULE = "openpilot.selfdrive.test.process_replay.migration"; + +static std::mutex handler_mutex; +static DownloadProgressHandler progress_handler = nullptr; + +void reportProgress(const char *line) { + uint64_t cur = 0, total = 0; + if (sscanf(line, "PROGRESS:%llu:%llu", (unsigned long long *)&cur, (unsigned long long *)&total) != 2) return; + std::lock_guard lk(handler_mutex); + if (progress_handler && total > 0) progress_handler(cur, total, true); +} + +// Run a Python module, report download progress from PROGRESS lines on stderr, +// and notify the progress handler on failure. +std::string runModuleWithProgress(const std::string &module, const std::vector &args, std::atomic *abort = nullptr) { + std::string result = PyProcess::runModule(module, args, abort, true, [](const char *line) { + if (strncmp(line, "PROGRESS:", 9) == 0) { + reportProgress(line); + } else { + fputs(line, stderr); + } + }); + if (result.empty()) { + std::lock_guard lk(handler_mutex); + if (progress_handler) progress_handler(0, 0, false); + } + return result; +} + +} // namespace + +void installDownloadProgressHandler(DownloadProgressHandler handler) { + std::lock_guard lk(handler_mutex); + progress_handler = handler; +} + +namespace PyTools { + +std::string download(const std::string &url, bool use_cache, std::atomic *abort) { + std::vector args = {"download", url}; + if (!use_cache) { + args.push_back("--no-cache"); + } + return runModuleWithProgress(DOWNLOADER_MODULE, args, abort); +} + +std::string decompress(const std::string &path, std::atomic *abort) { + return runModuleWithProgress(DOWNLOADER_MODULE, {"decompress", path}, abort); +} + +std::string getRouteFiles(const std::string &route) { + return runModuleWithProgress(DOWNLOADER_MODULE, {"route-files", route}); +} + +std::string resolveRouteFiles(const std::string &route, int begin, int end, const std::string &selector) { + return runModuleWithProgress(DOWNLOADER_MODULE, {"resolve-route-files", route, "--begin", std::to_string(begin), + "--end", std::to_string(end), "--selector", selector}); +} + +std::string authenticate(const std::string &provider, std::atomic *abort) { + return runModuleWithProgress(AUTH_MODULE, {provider, "--json"}, abort); +} + +std::string getDevices() { + return runModuleWithProgress(DOWNLOADER_MODULE, {"devices"}); +} + +std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms, int64_t end_ms, bool preserved) { + std::vector args = {"device-routes", dongle_id}; + if (preserved) { + args.push_back("--preserved"); + } else { + if (start_ms > 0) { + args.push_back("--start"); + args.push_back(std::to_string(start_ms)); + } + if (end_ms > 0) { + args.push_back("--end"); + args.push_back(std::to_string(end_ms)); + } + } + return runModuleWithProgress(DOWNLOADER_MODULE, args); +} + +std::string migrateLog(const std::string &log_path) { + return PyProcess::runModule(MIGRATION_MODULE, {log_path, "/dev/stdout"}, nullptr, false); +} + +} // namespace PyTools diff --git a/openpilot/tools/replay/py_downloader.h b/openpilot/tools/replay/py_tools.h similarity index 63% rename from openpilot/tools/replay/py_downloader.h rename to openpilot/tools/replay/py_tools.h index 80fab6ab00..9c83b0784a 100644 --- a/openpilot/tools/replay/py_downloader.h +++ b/openpilot/tools/replay/py_tools.h @@ -7,7 +7,7 @@ typedef std::function DownloadProgressHandler; void installDownloadProgressHandler(DownloadProgressHandler handler); -namespace PyDownloader { +namespace PyTools { // 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); @@ -18,10 +18,19 @@ std::string decompress(const std::string &path, std::atomic *abort = nullp // Returns JSON string of route files (same format as /v1/route/.../files API) std::string getRouteFiles(const std::string &route); +// Resolves API/internal files into a JSON manifest keyed by segment number. End is inclusive. +std::string resolveRouteFiles(const std::string &route, int begin, int end, const std::string &selector); + +// Browser sign-in; abort closes the local callback server. Returns a JSON status. +std::string authenticate(const std::string &provider, std::atomic *abort); + // Returns JSON string of user's devices std::string getDevices(); // Returns JSON string of device routes std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms = 0, int64_t end_ms = 0, bool preserved = false); -} // namespace PyDownloader +// Migrates a log segment through the Python migration CLI. Returns the migrated bytes (empty on failure). +std::string migrateLog(const std::string &log_path); + +} // namespace PyTools diff --git a/openpilot/tools/replay/replay.cc b/openpilot/tools/replay/replay.cc index 6449fc30ea..26e10e7c7b 100644 --- a/openpilot/tools/replay/replay.cc +++ b/openpilot/tools/replay/replay.cc @@ -40,8 +40,7 @@ void Replay::setupServices(const std::vector &allow, const std::vec auto event_schema = capnp::Schema::from().asStruct(); sockets_.resize(event_schema.getUnionFields().size(), nullptr); - std::vector active_services; - active_services.reserve(services.size()); + auto &active_services = active_services_; for (const auto &[name, _] : services) { bool is_blocked = std::find(block.begin(), block.end(), name) != block.end(); @@ -55,9 +54,6 @@ void Replay::setupServices(const std::vector &allow, const std::vec std::string services_str = join(active_services, ", "); rInfo("active services: %s", services_str.c_str()); - if (!sm_) { - pm_ = std::make_unique(active_services); - } } void Replay::setupSegmentManager(bool has_filters) { @@ -73,6 +69,15 @@ void Replay::setupSegmentManager(bool has_filters) { } Replay::~Replay() { + stop(); + camera_server_.reset(); + seg_mgr_.reset(); +} + +void Replay::stop() { + // Merge callbacks access both Replay and its owner. Join them while all objects + // and their owning pointers are still alive, before stopping the playback thread. + seg_mgr_->stop(); if (stream_thread_.joinable()) { rInfo("shutdown: in progress..."); interruptStream([this]() { @@ -82,8 +87,6 @@ Replay::~Replay() { stream_thread_.join(); rInfo("shutdown: done"); } - camera_server_.reset(); - seg_mgr_.reset(); } bool Replay::load() { @@ -235,6 +238,7 @@ void Replay::publishMessage(const Event *e) { if (event_filter_ && event_filter_(e)) return; if (!sm_) { + if (!pm_) pm_ = std::make_unique(active_services_); // consumers with an event filter never need one auto bytes = e->data.asBytes(); int ret = pm_->send(sockets_[e->which], (capnp::byte *)bytes.begin(), bytes.size()); if (ret == -1) { diff --git a/openpilot/tools/replay/replay.h b/openpilot/tools/replay/replay.h index 7cd5fbecd0..59e1d67d48 100644 --- a/openpilot/tools/replay/replay.h +++ b/openpilot/tools/replay/replay.h @@ -37,6 +37,7 @@ public: Replay(const std::string &route, std::vector allow, std::vector block, SubMaster *sm = nullptr, uint32_t flags = REPLAY_FLAG_NONE, const std::string &data_dir = "", bool auto_source = false); ~Replay(); + void stop(); bool load(); RouteLoadError lastRouteError() const { return route().lastError(); } void start(int seconds = 0) { seekTo(min_seconds_ + seconds, false); } @@ -107,6 +108,7 @@ private: double min_seconds_ = 0; double max_seconds_ = 0; SubMaster *sm_ = nullptr; + std::vector active_services_; std::unique_ptr pm_; std::vector sockets_; std::unique_ptr camera_server_; diff --git a/openpilot/tools/replay/route.cc b/openpilot/tools/replay/route.cc index f38c2b1971..74a65e4e94 100644 --- a/openpilot/tools/replay/route.cc +++ b/openpilot/tools/replay/route.cc @@ -6,7 +6,7 @@ #include "json11/json11.hpp" #include "common/hardware/hw.h" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" #include "tools/replay/replay.h" #include "tools/replay/util.h" @@ -27,8 +27,8 @@ RouteIdentifier Route::parseRoute(const std::string &str) { const auto range_str = match[6].str(); if (!range_str.empty()) { if (separator == "/") { - int pos = range_str.find(':'); - int begin_seg = std::stoi(range_str.substr(0, pos)); + const auto pos = range_str.find(':'); + int begin_seg = pos == 0 ? 0 : std::stoi(range_str.substr(0, pos)); identifier.begin_segment = identifier.end_segment = begin_seg; if (pos != std::string::npos) { auto end_seg_str = range_str.substr(pos + 1); @@ -104,7 +104,7 @@ bool Route::loadFromAutoSource() { } bool Route::loadFromServer() { - std::string result = PyDownloader::getRouteFiles(route_.str); + std::string result = PyTools::getRouteFiles(route_.str); if (result.empty()) { err_ = RouteLoadError::NetworkError; rWarning("Failed to fetch route files from server"); diff --git a/openpilot/tools/replay/seg_mgr.cc b/openpilot/tools/replay/seg_mgr.cc index 0778cacbc1..2c616bd004 100644 --- a/openpilot/tools/replay/seg_mgr.cc +++ b/openpilot/tools/replay/seg_mgr.cc @@ -3,6 +3,10 @@ #include SegmentManager::~SegmentManager() { + stop(); +} + +void SegmentManager::stop() { { std::unique_lock lock(mutex_); exit_ = true; @@ -47,10 +51,11 @@ void SegmentManager::setCurrentSegment(int seg_num) { void SegmentManager::manageSegmentCache() { while (true) { std::unique_lock lock(mutex_); - cv_.wait(lock, [this]() { return exit_ || needs_update_; }); + cv_.wait_until(lock, next_retry_, [this]() { return exit_ || needs_update_; }); if (exit_) break; needs_update_ = false; + next_retry_ = std::chrono::steady_clock::time_point::max(); auto cur = segments_.lower_bound(cur_seg_num_); if (cur == segments_.end()) continue; @@ -65,8 +70,12 @@ void SegmentManager::manageSegmentCache() { bool merged = mergeSegments(begin, end); // Free segments outside the current range - std::for_each(segments_.begin(), begin, [](auto &segment) { segment.second.reset(); }); - std::for_each(end, segments_.end(), [](auto &segment) { segment.second.reset(); }); + auto evict = [this](auto &segment) { + segment.second.reset(); + load_attempts_.erase(segment.first); + }; + std::for_each(segments_.begin(), begin, evict); + std::for_each(end, segments_.end(), evict); if (merged && onSegmentMergedCallback_) { onSegmentMergedCallback_(); // Notify listener that segments have been merged @@ -117,7 +126,25 @@ void SegmentManager::loadSegmentsInRange(SegmentMap::iterator begin, SegmentMap: auto tryLoadSegment = [this](auto first, auto last) { for (auto it = first; it != last; ++it) { auto &segment_ptr = it->second; + auto &attempt = load_attempts_[it->first]; + if (segment_ptr && segment_ptr->getState() == Segment::LoadState::Failed) { + // A failed object must not permanently occupy its cache slot. Back off + // between retries while allowing other segments to load meanwhile. + if (attempt.count >= MAX_SEGMENT_LOAD_ATTEMPTS) continue; + const auto now = std::chrono::steady_clock::now(); + if (attempt.retry_at == std::chrono::steady_clock::time_point::max()) { + attempt.retry_at = now + std::chrono::seconds(attempt.count); + } + if (now < attempt.retry_at) { + next_retry_ = std::min(next_retry_, attempt.retry_at); + continue; + } + segment_ptr.reset(); + rWarning("retrying segment %d (attempt %d/%d)", it->first, attempt.count + 1, MAX_SEGMENT_LOAD_ATTEMPTS); + } if (!segment_ptr) { + ++attempt.count; + attempt.retry_at = std::chrono::steady_clock::time_point::max(); if (onBenchmarkEvent_) { onBenchmarkEvent_(it->first, "loading"); } diff --git a/openpilot/tools/replay/seg_mgr.h b/openpilot/tools/replay/seg_mgr.h index 54e156fb60..6542335caf 100644 --- a/openpilot/tools/replay/seg_mgr.h +++ b/openpilot/tools/replay/seg_mgr.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -9,6 +10,7 @@ #include "tools/replay/route.h" constexpr int MIN_SEGMENTS_CACHE = 5; +constexpr int MAX_SEGMENT_LOAD_ATTEMPTS = 3; // Includes the initial load. using SegmentMap = std::map>; @@ -23,6 +25,7 @@ public: SegmentManager(const std::string &route_name, uint32_t flags, const std::string &data_dir = "", bool auto_source = false) : flags_(flags), route_(route_name, data_dir, auto_source), event_data_(std::make_shared()) {} ~SegmentManager(); + void stop(); bool load(); void setCurrentSegment(int seg_num); @@ -55,4 +58,11 @@ private: std::function onSegmentMergedCallback_ = nullptr; std::function onBenchmarkEvent_ = nullptr; std::set merged_segments_; + struct LoadAttempt { + int count = 0; + std::chrono::steady_clock::time_point retry_at = std::chrono::steady_clock::time_point::max(); + }; + // Accessed only by the cache management thread. + std::map load_attempts_; + std::chrono::steady_clock::time_point next_retry_ = std::chrono::steady_clock::time_point::max(); }; diff --git a/openpilot/tools/replay/tests/test_route.cc b/openpilot/tools/replay/tests/test_route.cc new file mode 100644 index 0000000000..c1eafbd4c5 --- /dev/null +++ b/openpilot/tools/replay/tests/test_route.cc @@ -0,0 +1,37 @@ +#include "common/tests/native_test.h" +#include "tools/replay/replay.h" +#include "tools/replay/route.h" + +void test_route_ranges() { + struct Range { + const char *suffix; + int begin; + int end; + }; + const Range ranges[] = { + {"", 0, -1}, + {"/:15", 0, 15}, + {"/:0", 0, 0}, + {"/0:15", 0, 15}, + {"/5:15", 5, 15}, + {"/5:", 5, -1}, + {"/5", 5, 5}, + {"--5", 5, -1}, + }; + for (const std::string route : {DEMO_ROUTE, "64e16c4237493597/00000033--332a5f9d82"}) { + const auto full = Route::parseRoute(route); + REQUIRE(!full.str.empty()); + for (const auto &range : ranges) { + const auto parsed = Route::parseRoute(route + range.suffix); + REQUIRE(parsed.str == full.str); + REQUIRE(parsed.dongle_id == full.dongle_id); + REQUIRE(parsed.timestamp == full.timestamp); + REQUIRE(parsed.begin_segment == range.begin); + REQUIRE(parsed.end_segment == range.end); + } + } +} + +int main() { + return run_native_test(test_route_ranges); +} diff --git a/openpilot/tools/sim/launch_openpilot.sh b/openpilot/tools/sim/launch_openpilot.sh index ea3c4cb8f1..1c9c3d0b76 100755 --- a/openpilot/tools/sim/launch_openpilot.sh +++ b/openpilot/tools/sim/launch_openpilot.sh @@ -18,4 +18,4 @@ SCRIPT_DIR=$(dirname "$0") OPENPILOT_DIR=$SCRIPT_DIR/../../ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -cd $OPENPILOT_DIR/system/manager && exec ./manager.py +cd "$OPENPILOT_DIR/system/manager" && exec ./manager.py diff --git a/openpilot/tools/sim/lib/camerad.py b/openpilot/tools/sim/lib/camerad.py index 206e4fdf90..7d8782d9a8 100644 --- a/openpilot/tools/sim/lib/camerad.py +++ b/openpilot/tools/sim/lib/camerad.py @@ -4,6 +4,7 @@ from openpilot.cereal.visionipc import VisionStreamType from msgq.visionipc import VisionIpcServer from openpilot.cereal import messaging +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from openpilot.tools.sim.lib.common import W, H @@ -23,17 +24,20 @@ def rgb_to_nv12(rgb): g_sub = (g[0::2, 0::2] + g[0::2, 1::2] + g[1::2, 0::2] + g[1::2, 1::2] + 2) >> 2 b_sub = (b[0::2, 0::2] + b[0::2, 1::2] + b[1::2, 0::2] + b[1::2, 1::2] + 2) >> 2 - # U and V planes + # Interleave U and V planes for NV12 format u = np.clip((b_sub * 56 - g_sub * 37 - r_sub * 19 + 0x8080) >> 8, 0, 255).astype(np.uint8) v = np.clip((r_sub * 56 - g_sub * 47 - b_sub * 9 + 0x8080) >> 8, 0, 255).astype(np.uint8) + uv = np.stack((u, v), axis=-1).reshape(h // 2, w) - # Interleave UV for NV12 format - uv = np.empty((h // 2, w), dtype=np.uint8) - uv[:, 0::2] = u - uv[:, 1::2] = v + # Copy the visible image into the aligned NV12 buffer + stride, y_height, uv_height, size = get_nv12_info(w, h) + nv12 = np.zeros(size, dtype=np.uint8) + planes = nv12[:stride * (y_height + uv_height)].reshape(-1, stride) - return np.concatenate([y.ravel(), uv.ravel()]).tobytes() + planes[:h, :w] = y + planes[y_height:y_height + h // 2, :w] = uv + return nv12.tobytes() class Camerad: """Simulates the camerad daemon""" @@ -44,9 +48,12 @@ class Camerad: self.frame_wide_id = 0 self.vipc_server = VisionIpcServer("camerad") - self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_NARROW_ROAD, 5, W, H) + stride, y_height, _, size = get_nv12_info(W, H) + buffer_args = (5, W, H, size, stride, stride * y_height) + + self.vipc_server.create_buffers_with_sizes(VisionStreamType.VISION_STREAM_NARROW_ROAD, *buffer_args) if dual_camera: - self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_WIDE_ROAD, 5, W, H) + self.vipc_server.create_buffers_with_sizes(VisionStreamType.VISION_STREAM_WIDE_ROAD, *buffer_args) self.vipc_server.start_listener() @@ -65,10 +72,9 @@ class Camerad: return rgb_to_nv12(rgb) def _send_yuv(self, yuv, frame_id, pub_type, yuv_type): - eof = int(frame_id * 0.05 * 1e9) - self.vipc_server.send(yuv_type, yuv, frame_id, eof, eof) - dat = messaging.new_message(pub_type, valid=True) + self.vipc_server.send(yuv_type, yuv, frame_id, dat.logMonoTime, dat.logMonoTime) + msg = { "frameId": frame_id, "transform": [1.0, 0.0, 0.0, diff --git a/panda b/panda index 74a0adced4..8a16e089b8 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 74a0adced421e8b7acd728d0f9988ce225423f13 +Subproject commit 8a16e089b88593914823743df29e1c30b7dd3e00 diff --git a/pyproject.toml b/pyproject.toml index 6a13796d70..8571d914db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "tqdm", # cars (fw_versions.py) on start + many one-off uses # core - "scons==4.10.1", # 4.11 removed the qt3 tool still used to build Cabana + "scons", "pycapnp==2.1.0", # 2.2 introduces a memory leak due to cyclic references "numpy >=2.0", @@ -44,7 +44,6 @@ dependencies = [ # these should be removed "pyzmq", - "sentry-sdk", "setproctitle", "jeepney", "zstandard", # this can go once we're on Python 3.14+ diff --git a/release/ci/model_generator.py b/release/ci/model_generator.py index 2d35d319c2..5d0ca73644 100755 --- a/release/ci/model_generator.py +++ b/release/ci/model_generator.py @@ -54,18 +54,10 @@ def create_pkl_name(full_name: str) -> str: def _hash_pkl(pkl_path: Path) -> str: - manifest = Path(f"{pkl_path}.chunkmanifest") - if manifest.exists(): - num_chunks = int(manifest.read_text().strip()) - 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) + with pkl_path.open('rb') as f: + while block := f.read(1024 * 1024): + digest.update(block) return digest.hexdigest() @@ -74,24 +66,11 @@ def _find_driving_pkl(output_path: Path) -> Path | None: matches = sorted(output_path.glob(pattern)) if matches: return matches[0] - 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')) return None -def _rename_pkl_with_chunks(old_pkl: Path, new_pkl: Path) -> Path: - manifest = Path(f"{old_pkl}.chunkmanifest") - if manifest.exists(): - for f in sorted(old_pkl.parent.glob(f"{old_pkl.name}.chunk*")): - f.rename(old_pkl.parent / f.name.replace(old_pkl.name, new_pkl.name, 1)) - return new_pkl - return old_pkl.rename(new_pkl) - - def _hash_onnx_files(model_dir: Path) -> str | None: - onnx_files = sorted(model_dir.glob("*.onnx")) + onnx_files = [f for f in sorted(model_dir.glob("*.onnx")) if f.stat().st_size > 1024] if not onnx_files: return None digest = hashlib.sha256() @@ -102,22 +81,9 @@ def _hash_onnx_files(model_dir: Path) -> str | None: return digest.hexdigest() -def generate_chunked_model(driving_pkl: Path) -> dict: +def generate_model(driving_pkl: Path) -> dict: tinygrad_hash = _hash_pkl(driving_pkl) - 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 - }) - artifact_data = { "file_name": driving_pkl.name, "download_uri": { @@ -126,9 +92,6 @@ def generate_chunked_model(driving_pkl: Path) -> dict: } } - if chunks_config: - artifact_data["chunks"] = chunks_config - return { "type": "chunked", "artifact": artifact_data, @@ -175,6 +138,7 @@ if __name__ == "__main__": 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("--upstream-branch", default="unknown", help="Upstream branch name") + parser.add_argument("--onnx-sha256", default=None, help="Pre-computed LFS SHA256 for the model file (used when no ONNX is present)") args = parser.parse_args() _output_dir = Path(args.output_dir) @@ -192,11 +156,11 @@ if __name__ == "__main__": 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) + _driving_pkl = _driving_pkl.rename(new_pkl) else: _driving_pkl = new_pkl - _model_metadata = generate_chunked_model(_driving_pkl) - _onnx_sha256 = _hash_onnx_files(Path(args.model_dir)) + _model_metadata = generate_model(_driving_pkl) + _onnx_sha256 = args.onnx_sha256 or _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 4b328a035c..30bf474b5b 100755 --- a/release/ci/publish.sh +++ b/release/ci/publish.sh @@ -3,7 +3,7 @@ set -e DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -cd $DIR +cd "$DIR" # Take parameters as arguments SOURCE_DIR=$1 @@ -30,24 +30,24 @@ if [ -z "$GIT_ORIGIN" ]; then fi # "Tagging" -echo "#define SUNNYPILOT_VERSION \"$VERSION\"" > ${OUTPUT_DIR}/openpilot/sunnypilot/common/version.h +echo "#define SUNNYPILOT_VERSION \"$VERSION\"" > "${OUTPUT_DIR}"/openpilot/sunnypilot/common/version.h ## set git identity #source $DIR/identity.sh #export GIT_SSH_COMMAND="ssh -i /data/gitkey" echo "[-] Setting up repo T=$SECONDS" -cd $OUTPUT_DIR +cd "$OUTPUT_DIR" git init # set git username/password #source /data/identity.sh -git rm -rf $OUTPUT_DIR/.git || true # Doing cleanup, but it might fail if the .git doesn't exist or not allowed to delete +git rm -rf "$OUTPUT_DIR/.git" || true # Doing cleanup, but it might fail if the .git doesn't exist or not allowed to delete git remote remove origin || true # ensure cleanup -git remote add origin $GIT_ORIGIN +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 --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) +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 . @@ -59,9 +59,9 @@ if git ls-files -s | awk '$1 == "160000" { found = 1; print } END { exit !found fi # include source commit hash and build date in commit -GIT_HASH=$(git --git-dir=$SOURCE_DIR/.git rev-parse HEAD) +GIT_HASH=$(git --git-dir="$SOURCE_DIR/.git" rev-parse HEAD) DATETIME=$(date '+%Y-%m-%dT%H:%M:%S') -SP_VERSION=$(awk -F\" '{print $2}' $SOURCE_DIR/openpilot/sunnypilot/common/version.h) +SP_VERSION=$(awk -F\" '{print $2}' "$SOURCE_DIR/openpilot/sunnypilot/common/version.h") # Commit with detailed message git commit -a -m "sunnypilot v$VERSION @@ -69,9 +69,9 @@ version: sunnypilot v$SP_VERSION (${EXTRA_VERSION_IDENTIFIER}) date: $DATETIME master commit: $GIT_HASH " -git branch --set-upstream-to=origin/$DEV_BRANCH -git branch -m $DEV_BRANCH +git branch --set-upstream-to=origin/"$DEV_BRANCH" +git branch -m "$DEV_BRANCH" # Push! echo "[-] pushing T=$SECONDS" -git push -f origin $DEV_BRANCH +git push -f origin "$DEV_BRANCH" diff --git a/release/ci/upload_default_model.py b/release/ci/upload_default_model.py index 97263ae6f5..8fce46a381 100644 --- a/release/ci/upload_default_model.py +++ b/release/ci/upload_default_model.py @@ -22,22 +22,41 @@ def hash_file(path: str) -> str: return digest.hexdigest() +def update_defaults_json(defaults_json: dict, bundle: dict, dedup_key: str, tinygrad_ref: str) -> dict: + def matches_bundle(target_bundle): + bundle_hash = target_bundle.get("onnx_sha256") + bundle_tinygrad_ref = target_bundle.get("tinygrad_ref") or defaults_json.get("tinygrad_ref") + return bundle_hash == dedup_key and bundle_tinygrad_ref == tinygrad_ref + + defaults_json["tinygrad_ref"] = tinygrad_ref + existing_index = next((i for i, b in enumerate(defaults_json.get("bundles", [])) if matches_bundle(b)), None) + if existing_index is not None: + defaults_json["bundles"][existing_index] = bundle + else: + defaults_json.setdefault("bundles", []).append(bundle) + return defaults_json + + 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("--onnx-path", default="") + parser.add_argument("--onnx-ref", default="") 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] + if args.onnx_path: + onnx_sha256 = hash_file(args.onnx_path) + short_ref = args.onnx_ref[:8] + else: + onnx_sha256 = None + short_ref = args.run_number safe_name = args.model_name.replace(" ", "-") folder_name = f"model-{safe_name}-{short_ref}-{args.run_number}" @@ -51,14 +70,15 @@ def main(): bundle = metadata['bundles'][0] bundle['display_name'] = args.model_name - bundle['onnx_sha256'] = onnx_sha256 - bundle['onnx_ref'] = args.onnx_ref + if onnx_sha256 is not None: + bundle['onnx_sha256'] = onnx_sha256 + bundle['onnx_ref'] = args.onnx_ref + bundle['tinygrad_ref'] = args.tinygrad_ref + dedup_key = onnx_sha256 or bundle.get('onnx_sha256', '') 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( @@ -76,14 +96,7 @@ def main(): 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) + update_defaults_json(defaults_json, bundle, dedup_key, args.tinygrad_ref) print(json.dumps(defaults_json, indent=2)) diff --git a/scripts/apply-pr.sh b/scripts/apply-pr.sh index ad0af46b49..f4a29ec087 100755 --- a/scripts/apply-pr.sh +++ b/scripts/apply-pr.sh @@ -6,6 +6,6 @@ if [ $# -eq 0 ]; then fi BASE="https://github.com/commaai/openpilot/pull/" -PR_NUM="$(echo $1 | grep -o -E '[0-9]+')" +PR_NUM="$(echo "$1" | grep -o -E '[0-9]+')" -curl -L $BASE/$PR_NUM.patch | git apply -3 +curl -L "$BASE/$PR_NUM.patch" | git apply -3 diff --git a/scripts/checkout-pr.sh b/scripts/checkout-pr.sh index eeba816d88..5f5dc8b1e3 100755 --- a/scripts/checkout-pr.sh +++ b/scripts/checkout-pr.sh @@ -7,10 +7,10 @@ if [ $# -eq 0 ]; then fi BASE="https://github.com/commaai/openpilot/pull/" -PR_NUM="$(echo $1 | grep -o -E '[0-9]+')" +PR_NUM="$(echo "$1" | grep -o -E '[0-9]+')" BRANCH=tmp-pr${PR_NUM} -git branch -D -f $BRANCH || true -git fetch -u -f origin pull/$PR_NUM/head:$BRANCH -git switch $BRANCH +git branch -D -f "$BRANCH" || true +git fetch -u -f origin "pull/$PR_NUM/head:$BRANCH" +git switch "$BRANCH" git reset --hard FETCH_HEAD diff --git a/scripts/jenkins_loop_test.sh b/scripts/jenkins_loop_test.sh index 8073f4668c..6cbdafd355 100755 --- a/scripts/jenkins_loop_test.sh +++ b/scripts/jenkins_loop_test.sh @@ -11,7 +11,7 @@ BRANCH="master" RUNS="20" COOKIE_JAR=/tmp/cookies -CRUMB=$(curl -s --cookie-jar $COOKIE_JAR 'https://jenkins.comma.life/crumbIssuer/api/xml?xpath=concat(//crumbRequestField,":",//crumb)') +CRUMB=$(curl -s --cookie-jar "$COOKIE_JAR" 'https://jenkins.comma.life/crumbIssuer/api/xml?xpath=concat(//crumbRequestField,":",//crumb)') FIRST_LOOP=1 @@ -25,13 +25,13 @@ function loop() { if [[ $FIRST_LOOP ]]; then TEMP_DIR=$(mktemp -d) - GIT_LFS_SKIP_SMUDGE=1 git clone --quiet -b $BRANCH --depth=1 --no-tags git@github.com:commaai/openpilot $TEMP_DIR - git -C $TEMP_DIR checkout --quiet -b $JENKINS_BRANCH - echo "TESTING: $(date)" >> $TEMP_DIR/testing_jenkins - git -C $TEMP_DIR add testing_jenkins - git -C $TEMP_DIR commit --quiet -m "testing" - git -C $TEMP_DIR push --quiet -f origin $JENKINS_BRANCH - rm -rf $TEMP_DIR + GIT_LFS_SKIP_SMUDGE=1 git clone --quiet -b "$BRANCH" --depth=1 --no-tags git@github.com:commaai/openpilot "$TEMP_DIR" + git -C "$TEMP_DIR" checkout --quiet -b "$JENKINS_BRANCH" + echo "TESTING: $(date)" >> "$TEMP_DIR/testing_jenkins" + git -C "$TEMP_DIR" add testing_jenkins + git -C "$TEMP_DIR" commit --quiet -m "testing" + git -C "$TEMP_DIR" push --quiet -f origin "$JENKINS_BRANCH" + rm -rf "$TEMP_DIR" FIRST_BUILD=1 echo '' echo 'waiting on Jenkins...' @@ -40,15 +40,15 @@ function loop() { FIRST_LOOP="" fi - FIRST_BUILD=$(curl -s $API_ROUTE/api/json | jq .nextBuildNumber) + FIRST_BUILD=$(curl -s "$API_ROUTE/api/json" | jq .nextBuildNumber) LAST_BUILD=$((FIRST_BUILD+N-1)) - TEST_BUILDS=( $(seq $FIRST_BUILD $LAST_BUILD) ) + read -r -a TEST_BUILDS <<< "$(seq -s ' ' "$FIRST_BUILD" "$LAST_BUILD")" # Start N new builds - for i in ${TEST_BUILDS[@]}; + for i in "${TEST_BUILDS[@]}"; do echo "Starting build $i" - curl -s --output /dev/null --cookie $COOKIE_JAR -H "$CRUMB" -X POST $API_ROUTE/build?delay=0sec + curl -s --output /dev/null --cookie "$COOKIE_JAR" -H "$CRUMB" -X POST "$API_ROUTE/build?delay=0sec" sleep 5 done echo "" @@ -58,14 +58,14 @@ function loop() { sleep 30 count=0 - for i in ${TEST_BUILDS[@]}; + for i in "${TEST_BUILDS[@]}"; do - RES=$(curl -s -w "\n%{http_code}" --cookie $COOKIE_JAR -H "$CRUMB" $API_ROUTE/$i/api/json) + RES=$(curl -s -w "\n%{http_code}" --cookie "$COOKIE_JAR" -H "$CRUMB" "$API_ROUTE/$i/api/json") HTTP_CODE=$(tail -n1 <<< "$RES") JSON=$(sed '$ d' <<< "$RES") if [[ $HTTP_CODE == "200" ]]; then - STILL_RUNNING=$(echo $JSON | jq .inProgress) + STILL_RUNNING=$(echo "$JSON" | jq .inProgress) if [[ $STILL_RUNNING == "true" ]]; then echo -e "Build $i: ${YELLOW}still running${NC}" continue @@ -119,11 +119,11 @@ function _looper() { echo -e "You are about to start $RUNS Jenkins builds against the $BRANCH branch." echo -e "If you expect this to run overnight, ${UNDERLINE}${BOLD}unplug the cold reboot power switch${NC} from the testing closet before." echo "" - read -p "Press (y/Y) to confirm: " choice + read -r -p "Press (y/Y) to confirm: " choice if [[ "$choice" == "y" || "$choice" == "Y" ]]; then loop fi } -_looper $@ +_looper "$@" diff --git a/scripts/launch_corolla.sh b/scripts/launch_corolla.sh index 926569a1e0..8894caaf74 100755 --- a/scripts/launch_corolla.sh +++ b/scripts/launch_corolla.sh @@ -4,4 +4,4 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" export FINGERPRINT="TOYOTA_COROLLA_TSS2" export SKIP_FW_QUERY="1" -$DIR/../launch_openpilot.sh +"$DIR/../launch_openpilot.sh" diff --git a/scripts/lint/check_dependencies.py b/scripts/lint/check_dependencies.py new file mode 100755 index 0000000000..1cec6fca9b --- /dev/null +++ b/scripts/lint/check_dependencies.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +import sys +import tomllib +import importlib.metadata +from pathlib import Path + + +def main() -> int: + if sys.prefix == sys.base_prefix: + print("Dependency checks require a virtual environment. Run tools/op.sh setup first.") + return 1 + + project = tomllib.loads((Path(__file__).resolve().parents[2] / "pyproject.toml").read_text())["project"] + direct = len(project["dependencies"]) + sum(len(deps) for deps in project["optional-dependencies"].values()) + # Count each installed package once, including transitive dependencies and all extras. + packages = {dist.metadata["Name"].lower().replace("_", "-") for dist in importlib.metadata.distributions()} + # Logical file sizes avoid filesystem block-size differences. Don't follow links to the interpreter or source tree. + size = sum(path.stat().st_size for path in Path(sys.prefix).rglob("*") if not path.is_symlink() and path.is_file()) + + """ + This test prevents our depency footprint from growing. + These values are *not* intended to be increased, and we + expect to strictly drive these down over time. + """ + failed = False + for name, value, limit in ( + ("Direct dependencies (all extras)", direct, 39), + ("Total dependencies", len(packages), 84), + ("Venv size (MiB)", size / 1024**2, 570), + ): + print(f"{name}: {value:g} (limit: {limit})") + failed |= value > limit + return int(failed) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lint/check_nomerge_comments.sh b/scripts/lint/check_nomerge_comments.sh index 6737d62a20..0dc3a7bd73 100755 --- a/scripts/lint/check_nomerge_comments.sh +++ b/scripts/lint/check_nomerge_comments.sh @@ -2,9 +2,9 @@ FAIL=0 -if grep -n '\(#\|//\)\([[:space:]]*\)NOMERGE' $@; then +if grep -n '\(#\|//\)\([[:space:]]*\)NOMERGE' "$@"; then echo -e "NOMERGE comments found! Remove them before merging\n" FAIL=1 fi -exit $FAIL +exit "$FAIL" diff --git a/scripts/lint/check_shebang_format.sh b/scripts/lint/check_shebang_format.sh index 89b95d5929..8757bb8a08 100755 --- a/scripts/lint/check_shebang_format.sh +++ b/scripts/lint/check_shebang_format.sh @@ -2,14 +2,14 @@ FAIL=0 -if grep '^#!.*python' $@ | grep -v '#!/usr/bin/env python3$'; then +if grep -I '^#!.*python' "$@" | grep -v '#!/usr/bin/env python3$'; then echo -e "Invalid shebang! Must use '#!/usr/bin/env python3'\n" FAIL=1 fi -if grep '^#!.*bash' $@ | grep -v '#!/usr/bin/env bash$'; then +if grep -I '^#!.*bash' "$@" | grep -v '#!/usr/bin/env bash$'; then echo -e "Invalid shebang! Must use '#!/usr/bin/env bash'" FAIL=1 fi -exit $FAIL +exit "$FAIL" diff --git a/scripts/lint/check_shell.py b/scripts/lint/check_shell.py new file mode 100755 index 0000000000..d34bd6e454 --- /dev/null +++ b/scripts/lint/check_shell.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""A minimal shellcheck-like static analysis tool for shell scripts. + +Covers syntax, unquoted expansions, scalar $@ assignments, and read -r. +""" +import re +import argparse +import subprocess +from pathlib import Path + +VARIABLE = re.compile(r"\$(?:\{[^}\n]*\}|[A-Za-z_]\w*|[@*0-9])") +ASSIGNMENT = re.compile(r"[A-Za-z_]\w*(?:\[[^]]*\])?\+?=") +OPAQUE = re.compile(r"\$?\(\([^\n]*?\)\)|\[\[.*?\]\]", re.DOTALL) +HEREDOC = re.compile(r"<<(-?)\s*('[^']+'|\"[^\"]+\"|\\?[A-Za-z_]\w*)") + + +def commands(text): + def scan(i=0, end="", pattern_group=False): + start, quote, words, expansions, documents = i, False, [], [], [] + cases = [] + while i < len(text): + c = text[i] + if c == "\\": + i += 2 + continue + if c == "'" and not quote: + i = text.find("'", i + 1) + 1 or len(text) + continue + if c == '"': + quote = not quote + elif (not quote or text.startswith("$((", i)) and (match := OPAQUE.match(text, i)): + i = match.end() + continue + elif text.startswith("$(", i): + if not quote: + expansions.append(i) + i = yield from scan(i + 2, ")") + continue + elif match := VARIABLE.match(text, i): + if not quote and not match[0].startswith("${#"): + expansions.append(i) + i = match.end() + continue + elif not quote: + if text.startswith("<<<", i): + i += 3 + continue + if c == "#" and i == start: + i = text.find("\n", i) + i = len(text) if i < 0 else i + start = i + continue + if not text.startswith("<<<", i) and (match := HEREDOC.match(text, i)): + documents.append((match[2].strip("'\"").lstrip("\\"), bool(match[1]))) + i = match.end() + continue + if c in " \t\r\n;|&()": + if start < i: + words.append((text[start:i], start, expansions)) + if len(words) >= 3 and words[0][0] == "case" and words[-1][0] == "in": + cases.append(True) + words = [] + if words and words[0][0] == "esac" and cases: + cases.pop() + words = [] + pattern = bool(cases and cases[-1]) + expansions = [] + if c in "\n;|&()": + if words and not (pattern or pattern_group) and (c != ")" or end): + yield words + words = [] + if c == end and not pattern: + return i + 1 + if c == ")" and pattern: + cases[-1] = False + if cases and (terminator := re.match(r";(?:;&|;|&)", text[i:])): + cases[-1] = True + i += len(terminator[0]) - 1 + if c == "(": + if pattern and i == start: + cases[-1] = False + i = yield from scan(i + 1, ")", pattern or pattern_group) + start = i + continue + if c == "\n": + for delimiter, strip_tabs in documents: + while i < len(text): + stop = text.find("\n", i + 1) + stop = len(text) if stop < 0 else stop + line, i = text[i + 1:stop], stop + if (line.lstrip("\t") if strip_tabs else line) == delimiter: + break + documents = [] + start = i + 1 + i += 1 + if start < i: + words.append((text[start:i], start, expansions)) + if words: + yield words + return i + yield from scan() + + +def check_text(text): + for words in commands(text): + command = next((w for w, _, _ in words if not ASSIGNMENT.match(w) and w not in {"if", "then", "elif", "while", "until", "do", "!"}), "") + prefix = True + for index, (word, start, expansions) in enumerate(words): + assignment = ASSIGNMENT.match(word) and (prefix or command in {"export", "local", "declare", "readonly", "typeset"}) + prefix = prefix and (bool(assignment) or word in {"if", "then", "elif", "while", "until", "do", "!"}) + for offset in expansions: + array = word in {"$@", "$*"} or "[@]" in word or "[*]" in word + if not assignment and (command not in {"case", "for", "select"} or array) and not (index and words[index - 1][0] == "<<<"): + yield text.count("\n", 0, offset) + 1, "Quote this expansion to prevent word splitting and globbing" + if assignment and re.fullmatch(r'"[^"\n]*\$@[^"\n]*"', ASSIGNMENT.sub("", word, count=1)): + yield text.count("\n", 0, start) + 1, 'Use an array for "$@", or "$*" to join arguments' + if command == "read" and not any(re.fullmatch(r"-[A-Za-z]*r[A-Za-z0-9]*", w) for w, _, _ in words): + yield text.count("\n", 0, next(start for w, start, _ in words if w == "read")) + 1, "Use read -r to preserve backslashes" + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("filenames", nargs="+") + failed = False + for filename in parser.parse_args().filenames: + syntax = subprocess.run(["bash", "-n", "--", filename], check=False) + failed |= syntax.returncode != 0 + if syntax.returncode == 0: + for line, message in check_text(Path(filename).read_text()): + print(f"{filename}:{line}: {message}") + failed = True + raise SystemExit(failed) diff --git a/scripts/lint/lint.sh b/scripts/lint/lint.sh index 8f3aab4e52..43bd83a90a 100755 --- a/scripts/lint/lint.sh +++ b/scripts/lint/lint.sh @@ -9,7 +9,7 @@ NC='\033[0m' DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" ROOT="$DIR/../../" -cd $ROOT +cd "$ROOT" FAILED=0 @@ -26,10 +26,10 @@ function run() { done shift 1; - CMD="$@" + CMD=("$@") set +e - log="$((eval "$CMD" ) 2>&1)" + log="$("${CMD[@]}" 2>&1)" if [[ $? -eq 0 ]]; then echo -e "[${GREEN}✔${NC}]" @@ -42,22 +42,21 @@ function run() { } function run_tests() { - ALL_FILES=$1 - PYTHON_FILES=$2 - run "ruff" ruff check openpilot --quiet - 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 + run "check_shell" python3 "$DIR/check_shell.py" "${SHELL_FILES[@]}" + run "check_dependencies" python3 "$DIR/check_dependencies.py" + 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[@]}" if [[ -z "$FAST" ]]; then run "ty" ty check openpilot - run "codespell" codespell $ALL_FILES --ignore-words=$ROOT/.codespellignore + run "codespell" codespell "${ALL_FILES[@]}" --ignore-words="$ROOT/.codespellignore" fi - return $FAILED + return "$FAILED" } function help() { @@ -67,6 +66,8 @@ function help() { echo "" echo -e "${BOLD}${UNDERLINE}Tests:${NC}" echo -e " ${BOLD}ruff${NC}" + echo -e " ${BOLD}check_shell${NC}" + echo -e " ${BOLD}check_dependencies${NC}" echo -e " ${BOLD}check_indentation${NC}" echo -e " ${BOLD}ty${NC}" echo -e " ${BOLD}codespell${NC}" @@ -101,17 +102,29 @@ while [[ $# -gt 0 ]]; do esac done -RUN=$([ -z "$RUN" ] && echo "" || echo "!($(echo $RUN | sed 's/ /|/g'))") -SKIP="@($(echo $SKIP | sed 's/ /|/g'))" +RUN=$([ -z "$RUN" ] && echo "" || echo "!($(echo "$RUN" | sed 's/ /|/g'))") +SKIP="@($(echo "$SKIP" | sed 's/ /|/g'))" -IGNORED_DIRS="^openpilot/third_party/.*" -GIT_FILES="$(git ls-files openpilot | grep -vE "$IGNORED_DIRS")" -ALL_FILES="" -for f in $GIT_FILES; do - if [[ -f $f ]]; then - ALL_FILES+="$f"$'\n' +ALL_FILES=() +PYTHON_FILES=() +while IFS= read -r -d '' f; do + if [[ $f =~ ^openpilot/third_party/.* ]]; then + continue fi -done -PYTHON_FILES=$(echo "$ALL_FILES" | grep --color=never '.py$' || true) + if [[ -f $f ]]; then + ALL_FILES+=("$f") + if [[ $f == *.py ]]; then + PYTHON_FILES+=("$f") + fi + fi +done < <(git ls-files -z openpilot) -run_tests "$ALL_FILES" "$PYTHON_FILES" +# Include tooling, launchers, and the extensionless Git hook. +SHELL_FILES=() +while IFS= read -r -d '' f; do + if [[ -f $f ]]; then + SHELL_FILES+=("$f") + fi +done < <(git ls-files -z '*.sh' '*.bash' scripts/post-commit) + +run_tests diff --git a/scripts/retry.sh b/scripts/retry.sh index 23501d7559..382190fe10 100755 --- a/scripts/retry.sh +++ b/scripts/retry.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash function fail { - echo $1 >&2 + echo "$1" >&2 exit 1 } @@ -14,7 +14,7 @@ function retry { "$@" && break || { if [[ $n -lt $max ]]; then ((n++)) - sleep $delay; + sleep "$delay"; else fail "The command has failed after $n attempts." fi diff --git a/tinygrad_repo b/tinygrad_repo index f6fc4e3f2c..92b742f711 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit f6fc4e3f2c3db5fae1e19cbfbc3ad9fc579a12ae +Subproject commit 92b742f711c7d216fd3ed7a46a30a959e3c43bd4 diff --git a/tools/op.sh b/tools/op.sh index 3d7d17a76b..d1a7dcebbb 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -14,9 +14,9 @@ UNDERLINE='\033[4m' BOLD='\033[1m' NC='\033[0m' -SHELL_NAME="$(basename ${SHELL})" -RC_FILE="${HOME}/.$(basename ${SHELL})rc" -if [ "$(uname)" == "Darwin" ] && [ $SHELL == "/bin/bash" ]; then +SHELL_NAME="$(basename "${SHELL}")" +RC_FILE="${HOME}/.$(basename "${SHELL}")rc" +if [ "$(uname)" == "Darwin" ] && [ "$SHELL" == "/bin/bash" ]; then RC_FILE="$HOME/.bash_profile" fi @@ -73,11 +73,11 @@ function op_get_openpilot_dir() { function op_install_post_commit() { op_get_openpilot_dir - if [[ ! -d $OPENPILOT_ROOT/.git/hooks/post-commit.d ]]; then - mkdir $OPENPILOT_ROOT/.git/hooks/post-commit.d - mv $OPENPILOT_ROOT/.git/hooks/post-commit $OPENPILOT_ROOT/.git/hooks/post-commit.d 2>/dev/null || true + if [[ ! -d "$OPENPILOT_ROOT/.git/hooks/post-commit.d" ]]; then + mkdir "$OPENPILOT_ROOT/.git/hooks/post-commit.d" + mv "$OPENPILOT_ROOT/.git/hooks/post-commit" "$OPENPILOT_ROOT/.git/hooks/post-commit.d" 2>/dev/null || true fi - cd $OPENPILOT_ROOT/.git/hooks + cd "$OPENPILOT_ROOT/.git/hooks" ln -sf ../../scripts/post-commit post-commit } @@ -103,7 +103,7 @@ function op_check_git() { fi echo "Checking for git lfs files..." - if [[ $(file -b $OPENPILOT_ROOT/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx) == "data" ]]; then + if [[ $(file -b "$OPENPILOT_ROOT/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx") == "data" ]]; then echo -e " ↳ [${GREEN}✔${NC}] git lfs files found." else echo -e " ↳ [${RED}✗${NC}] git lfs files not found! Run 'git lfs pull'" @@ -112,7 +112,7 @@ function op_check_git() { echo "Checking for git submodules..." for name in $(git config --file .gitmodules --get-regexp path | awk '{ print $2 }' | tr '\n' ' '); do - if [[ -z $(ls $OPENPILOT_ROOT/$name) ]]; then + if [[ -z $(ls "$OPENPILOT_ROOT/$name") ]]; then echo -e " ↳ [${RED}✗${NC}] git submodule $name not found! Run 'git submodule update --init --recursive'" return 1 fi @@ -123,23 +123,7 @@ function op_check_git() { function op_check_os() { echo "Checking for compatible os version..." if [[ "$OSTYPE" == "linux-gnu"* ]]; then - - if [ -f "/etc/os-release" ]; then - source /etc/os-release - case "$VERSION_CODENAME" in - "jammy" | "kinetic" | "noble" | "focal") - echo -e " ↳ [${GREEN}✔${NC}] Ubuntu $VERSION_CODENAME detected." - ;; - * ) - echo -e " ↳ [${RED}✗${NC}] Incompatible Ubuntu version $VERSION_CODENAME detected!" - return 1 - ;; - esac - else - echo -e " ↳ [${RED}✗${NC}] No /etc/os-release on your system. Make sure you're running on Ubuntu, or similar!" - return 1 - fi - + echo -e " ↳ [${GREEN}✔${NC}] Linux detected." elif [[ "$OSTYPE" == "darwin"* ]]; then echo -e " ↳ [${GREEN}✔${NC}] macOS detected." else @@ -150,10 +134,10 @@ function op_check_os() { function op_check_venv() { echo "Checking for venv..." - if [[ -f $OPENPILOT_ROOT/.venv/bin/activate ]]; then + if [[ -f "$OPENPILOT_ROOT/.venv/bin/activate" ]]; then echo -e " ↳ [${GREEN}✔${NC}] venv detected." else - echo -e " ↳ [${RED}✗${NC}] Can't activate venv in $OPENPILOT_ROOT. Assuming global env!" + echo -e " ↳ [${RED}✗${NC}] Can't activate venv in '$OPENPILOT_ROOT'. Assuming global env!" fi } @@ -163,7 +147,7 @@ function op_before_cmd() { fi op_get_openpilot_dir - cd $OPENPILOT_ROOT + cd "$OPENPILOT_ROOT" result="$((op_check_openpilot_dir ) 2>&1)" || (echo -e "$result" && return 1) result="${result}\n$(( op_check_git ) 2>&1)" || (echo -e "$result" && return 1) @@ -192,7 +176,7 @@ EOF echo -e " ↳ [${GREEN}✔${NC}] op installed successfully. Open a new shell to use it." op_get_openpilot_dir - cd $OPENPILOT_ROOT + cd "$OPENPILOT_ROOT" op_check_openpilot_dir op_check_os @@ -211,7 +195,7 @@ EOF echo "Installing dependencies..." st="$(date +%s)" SETUP_SCRIPT="tools/setup_dependencies.sh" - if ! $OPENPILOT_ROOT/$SETUP_SCRIPT; then + if ! "$OPENPILOT_ROOT/$SETUP_SCRIPT"; then echo -e " ↳ [${RED}✗${NC}] Dependencies installation failed!" return 1 fi @@ -222,6 +206,12 @@ EOF echo "Pulling git lfs files..." st="$(date +%s)" + git config --local filter.lfs.clean ".venv/bin/git-lfs clean -- %f" + git config --local filter.lfs.smudge ".venv/bin/git-lfs smudge -- %f" + git config --local filter.lfs.process ".venv/bin/git-lfs filter-process" + git config --local filter.lfs.required true + printf '#!/bin/sh\nexec .venv/bin/git-lfs pre-push "$@"\n' > "$(git rev-parse --git-path hooks)/pre-push" + chmod +x "$(git rev-parse --git-path hooks)/pre-push" if ! retry 3 git lfs pull; then echo -e " ↳ [${RED}✗${NC}] Pulling git lfs files failed!" return 1 @@ -240,7 +230,7 @@ function op_auth() { function op_activate_venv() { # bash 3.2 can't handle this without the 'set +e' set +e - source $OPENPILOT_ROOT/.venv/bin/activate &> /dev/null || true + source "$OPENPILOT_ROOT/.venv/bin/activate" &> /dev/null || true set -e # persist venv on PATH across GitHub Actions steps @@ -252,18 +242,18 @@ function op_activate_venv() { function op_venv() { op_before_cmd - if [[ ! -f $OPENPILOT_ROOT/.venv/bin/activate ]]; then - echo -e "No venv found in $OPENPILOT_ROOT" + if [[ ! -f "$OPENPILOT_ROOT/.venv/bin/activate" ]]; then + echo -e "No venv found in '$OPENPILOT_ROOT'" return 1 fi case $SHELL_NAME in "zsh") ZSHRC_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'tmp_zsh') - echo "source $RC_FILE; source $OPENPILOT_ROOT/.venv/bin/activate" >> $ZSHRC_DIR/.zshrc + echo "source \"$RC_FILE\"; source \"$OPENPILOT_ROOT/.venv/bin/activate\"" >> "$ZSHRC_DIR/.zshrc" ZDOTDIR=$ZSHRC_DIR zsh ;; *) - bash --rcfile <(echo "source $RC_FILE; source $OPENPILOT_ROOT/.venv/bin/activate") ;; + bash --rcfile <(echo "source \"$RC_FILE\"; source \"$OPENPILOT_ROOT/.venv/bin/activate\"") ;; esac } @@ -349,6 +339,11 @@ function op_clip() { op_run_command openpilot/tools/clip/run.py "$@" } +function op_docs() { + op_before_cmd + op_run_command python docs/serve.py "$@" +} + function op_check_agnos_update() { if [[ ! -f "/AGNOS" ]]; then return 0 @@ -410,14 +405,14 @@ function op_start() { if [[ -f "/AGNOS" ]]; then op_before_cmd op_check_agnos_update - op_run_command sudo systemctl restart comma $@ + op_run_command sudo systemctl restart comma "$@" fi } function op_stop() { if [[ -f "/AGNOS" ]]; then op_before_cmd - op_run_command sudo systemctl stop comma $@ + op_run_command sudo systemctl stop comma "$@" fi } @@ -447,6 +442,7 @@ function op_default() { echo -e " ${BOLD}replay${NC} Run Replay" echo -e " ${BOLD}cabana${NC} Run Cabana" echo -e " ${BOLD}clip${NC} Run clip (linux only)" + echo -e " ${BOLD}docs${NC} Build or serve the openpilot documentation" echo -e " ${BOLD}adb${NC} Run adb shell" echo -e " ${BOLD}ssh${NC} comma prime SSH helper" echo "" @@ -502,6 +498,7 @@ function _op() { test ) shift 1; op_test "$@" ;; replay ) shift 1; op_replay "$@" ;; clip ) shift 1; op_clip "$@" ;; + docs ) shift 1; op_docs "$@" ;; sim ) shift 1; op_sim "$@" ;; switch ) shift 1; op_switch "$@" ;; start ) shift 1; op_start "$@" ;; diff --git a/tools/release/README.md b/tools/release/README.md index 862836bf90..25ae32f704 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -21,8 +21,8 @@ - [ ] update from new release -> previous release - [ ] fresh install with `openpilot-test.comma.ai` - [ ] drive on fresh install - - [ ] no submodules or LFS - - [ ] check sentry, MTBF, etc. + - [ ] no submodules; only the Chestnut big driving model uses LFS + - [ ] check MTBF, etc. - [ ] stress test passes in production - [ ] publish the blog post - [ ] `git reset --hard origin/release-mici-staging` diff --git a/tools/release/build_release.sh b/tools/release/build_release.sh index 21d0bba449..d83bcc7ec1 100755 --- a/tools/release/build_release.sh +++ b/tools/release/build_release.sh @@ -3,7 +3,7 @@ set -e set -x DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -cd $DIR +cd "$DIR" BUILD_DIR=/data/openpilot SOURCE_DIR="$(git rev-parse --show-toplevel)" @@ -19,26 +19,27 @@ BUILD_BRANCH=release-mici-staging # set git identity -source $DIR/identity.sh +source "$DIR/identity.sh" echo "[-] Setting up repo T=$SECONDS" if ! git -C "$SOURCE_DIR" worktree remove --force "$BUILD_DIR" 2>/dev/null; then - rm -rf $BUILD_DIR + 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 +cd "$BUILD_DIR" 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 +cd "$SOURCE_DIR" ./tools/release/release_files.py | xargs -0 cp -pR --parents -t "$BUILD_DIR" -- # in the directory -cd $BUILD_DIR +cd "$BUILD_DIR" +source "$SOURCE_DIR/tools/release/setup_lfs.sh" # use the full CPU available for speeding up the build. # openpilot resets the CPU frequencies when test_onroad.py runs below. @@ -49,9 +50,6 @@ for policy in /sys/devices/system/cpu/cpufreq/policy*; do done 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 @@ -61,6 +59,12 @@ else scons panda/ fi +if [ -n "$INCLUDE_BIG_MODEL" ]; then + test -f openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl + test -f openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl + test -f openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl +fi + # Ensure no submodules in release if test "$(git submodule--helper list | wc -l)" -gt "0"; then echo "submodules found:" @@ -94,10 +98,11 @@ VERSION=$(cat openpilot/sunnypilot/common/version.h | awk -F[\"-] '{print $2}') # 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" +source "$SOURCE_DIR/tools/release/check_file_sizes.sh" # Run tests -cd $BUILD_DIR -RELEASE=1 ./openpilot/selfdrive/test/test_onroad.py +cd "$BUILD_DIR" +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" @@ -106,6 +111,7 @@ for branch in ${RELEASE_BRANCH//,/ }; do REFS+=("$BUILD_BRANCH:$branch") done # 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[@]}" +# The big model is already published to LFS by the source branch. +GIT_LFS_SKIP_PUSH=1 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 6015fdc5dd..c854aeb18e 100755 --- a/tools/release/build_stripped.sh +++ b/tools/release/build_stripped.sh @@ -3,23 +3,23 @@ set -ex DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -SOURCE_DIR="$(git -C $DIR rev-parse --show-toplevel)" +SOURCE_DIR="$(git -C "$DIR" rev-parse --show-toplevel)" if [ -z "$TARGET_DIR" ]; then TARGET_DIR="$(mktemp -d)" fi # set git identity -source $DIR/identity.sh +source "$DIR/identity.sh" echo "[-] Setting up target repo T=$SECONDS" -rm -rf $TARGET_DIR -mkdir -p $TARGET_DIR -cd $TARGET_DIR -cp -r $SOURCE_DIR/.git $TARGET_DIR +rm -rf "$TARGET_DIR" +mkdir -p "$TARGET_DIR" +cd "$TARGET_DIR" +cp -r "$SOURCE_DIR/.git" "$TARGET_DIR" echo "[-] setting up stripped branch sync T=$SECONDS" -cd $TARGET_DIR +cd "$TARGET_DIR" # tmp branch git checkout --orphan tmp @@ -32,20 +32,21 @@ find . -maxdepth 1 -not -path './.git' -not -name '.' -not -name '..' -exec rm - # do the files copy echo "[-] copying files T=$SECONDS" -cd $SOURCE_DIR +cd "$SOURCE_DIR" ./tools/release/release_files.py | xargs -0 cp -pR --parents -t "$TARGET_DIR" -- # in the directory -cd $TARGET_DIR +cd "$TARGET_DIR" rm -rf .git/modules/ -find openpilot/selfdrive/modeld/models -name '*.onnx' -size +95M -exec ./openpilot/common/file_chunker.py {} \; +source "$SOURCE_DIR/tools/release/setup_lfs.sh" # include source commit hash and build date in commit -GIT_HASH=$(git --git-dir=$SOURCE_DIR/.git rev-parse HEAD) -GIT_COMMIT_DATE=$(git --git-dir=$SOURCE_DIR/.git show --no-patch --format='%ct %ci' HEAD) +GIT_HASH=$(git --git-dir="$SOURCE_DIR/.git" rev-parse HEAD) +GIT_COMMIT_DATE=$(git --git-dir="$SOURCE_DIR/.git" show --no-patch --format='%ct %ci' HEAD) DATETIME=$(date '+%Y-%m-%dT%H:%M:%S') -VERSION=$(cat $SOURCE_DIR/openpilot/sunnypilot/common/version.h | awk -F\" '{print $2}') +VERSION=$(cat "$SOURCE_DIR/openpilot/sunnypilot/common/version.h" | awk -F\" '{print $2}') + echo -n "$GIT_HASH" > git_src_commit echo -n "$GIT_COMMIT_DATE" > git_src_commit_date @@ -60,26 +61,20 @@ date: $DATETIME master commit: $GIT_HASH " -# should be no submodules or LFS files +# should be no submodules or unexpected LFS files git submodule status -if [ ! -z "$(git lfs ls-files)" ]; then +if [ -z "$INCLUDE_BIG_MODEL" ] && [ -n "$(git lfs ls-files)" ]; then echo "LFS files detected!" exit 1 fi -# ensure files are within GitHub's limit -BIG_FILES="$(find . -type f -not -path './.git/*' -size +95M)" -if [ ! -z "$BIG_FILES" ]; then - printf '\n\n\n' - echo "Found files exceeding GitHub's 100MB limit:" - echo "$BIG_FILES" - exit 1 -fi +source "$SOURCE_DIR/tools/release/check_file_sizes.sh" if [ ! -z "$BRANCH" ]; then echo "[-] Pushing to $BRANCH T=$SECONDS" # 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 + # The big model is already published to LFS by the source branch. + GIT_LFS_SKIP_PUSH=1 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/check-dirty.sh b/tools/release/check-dirty.sh index ac049970cf..73d393f3c2 100755 --- a/tools/release/check-dirty.sh +++ b/tools/release/check-dirty.sh @@ -2,7 +2,7 @@ set -e DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -cd $DIR +cd "$DIR" if [ ! -z "$(git status --porcelain)" ]; then echo "Dirty working tree after build:" diff --git a/tools/release/check-submodules.sh b/tools/release/check-submodules.sh index 7120fa15d8..5c5204e6b8 100755 --- a/tools/release/check-submodules.sh +++ b/tools/release/check-submodules.sh @@ -9,7 +9,7 @@ has_submodule_changes() { return 1 } -while read hash submodule ref; do +while read -r hash submodule ref; do if [ -z "$hash" ] || [ -z "$submodule" ]; then continue fi @@ -23,21 +23,21 @@ while read hash submodule ref; do if [ "$CHECK_PR_REFS" = "true" ] && has_submodule_changes "$submodule"; then echo "Checking $submodule (non-master): verifying hash $hash exists" - git -C $submodule fetch --depth 100 origin - if git -C $submodule cat-file -e $hash 2>/dev/null; then + git -C "$submodule" fetch --depth 100 origin + if git -C "$submodule" cat-file -e "$hash" 2>/dev/null; then echo "$submodule ok (hash exists)" else echo "$submodule: $hash does not exist in the repository" exit 1 fi else - git -C $submodule fetch --depth 100 origin master - git -C $submodule branch -r --contains $hash | grep "origin/master" + git -C "$submodule" fetch --depth 100 origin master + git -C "$submodule" branch -r --contains "$hash" | grep "origin/master" if [ "$?" -eq 0 ]; then echo "$submodule ok" else - echo "$submodule: $hash is not on master" + echo "$submodule: $hash not on master" exit 1 fi fi -done <<< $(git submodule status --recursive) +done <<< "$(git submodule status)" diff --git a/tools/release/check_file_sizes.sh b/tools/release/check_file_sizes.sh new file mode 100644 index 0000000000..2563e78c2b --- /dev/null +++ b/tools/release/check_file_sizes.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +BIG_FILES="$(git ls-tree -rl HEAD | awk '$4 > 95 * 1024 * 1024 {print $5}')" +if [ -n "$BIG_FILES" ]; then + echo "Found Git blobs exceeding the 95 MiB release limit:" + echo "$BIG_FILES" + exit 1 +fi diff --git a/tools/release/release_files.py b/tools/release/release_files.py index 641dd7ff89..3f22497999 100755 --- a/tools/release/release_files.py +++ b/tools/release/release_files.py @@ -14,7 +14,7 @@ blacklist = [ "matlab.*.md", - # no LFS or submodules in release + # release scripts configure LFS for the big driving model and warps ".lfsconfig", ".gitattributes", ".git$", @@ -24,8 +24,7 @@ blacklist = [ ] # gets you through the blacklist -whitelist: list[str] = [ -] +whitelist: list[str] = [r"^\.lfsconfig$"] if os.getenv("INCLUDE_BIG_MODEL") else [] if __name__ == "__main__": @@ -35,7 +34,7 @@ if __name__ == "__main__": continue rf = os.fsdecode(tracked_file) - if not os.getenv("INCLUDE_BIG_MODEL") and rf.startswith("openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"): + if not os.getenv("INCLUDE_BIG_MODEL") and rf.startswith("openpilot/selfdrive/modeld/models/big_"): continue blacklisted = any(re.search(p, rf) for p in blacklist) whitelisted = any(re.search(p, rf) for p in whitelist) diff --git a/tools/release/setup_lfs.sh b/tools/release/setup_lfs.sh new file mode 100644 index 0000000000..e2122a87cb --- /dev/null +++ b/tools/release/setup_lfs.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +if [ -n "$INCLUDE_BIG_MODEL" ]; then + echo 'openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text' > .gitattributes +fi + +# Reset filters and hooks since releases exclude .venv. +git config --local core.hooksPath "$(git rev-parse --git-common-dir)/hooks" +git lfs install --local --force diff --git a/tools/scripts/adb_ssh.sh b/tools/scripts/adb_ssh.sh index c584a72f2b..b808243d36 100755 --- a/tools/scripts/adb_ssh.sh +++ b/tools/scripts/adb_ssh.sh @@ -36,7 +36,7 @@ SSH_PORT=2222 while ss -tln | grep -q ":${SSH_PORT} "; do SSH_PORT=$((SSH_PORT + 1)) done -adb forward tcp:${SSH_PORT} tcp:22 +adb forward tcp:"${SSH_PORT}" tcp:22 # SSH! -ssh comma@localhost -p ${SSH_PORT} "$@" +ssh comma@localhost -p "${SSH_PORT}" "$@" diff --git a/tools/scripts/profiling/clpeak/.gitignore b/tools/scripts/profiling/clpeak/.gitignore deleted file mode 100644 index d575c207c1..0000000000 --- a/tools/scripts/profiling/clpeak/.gitignore +++ /dev/null @@ -1 +0,0 @@ -clpeak/ diff --git a/tools/scripts/profiling/clpeak/build.sh b/tools/scripts/profiling/clpeak/build.sh deleted file mode 100755 index 5206ed0c45..0000000000 --- a/tools/scripts/profiling/clpeak/build.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" -cd $DIR - -if [ ! -d "$DIR/clpeak" ]; then - git clone https://github.com/krrishnarraj/clpeak.git - - cd clpeak - git fetch - git checkout ec2d3e70e1abc7738b81f9277c7af79d89b2133b - git reset --hard origin/master - git submodule update --init --recursive --remote - - git apply ../run_continuously.patch -fi - -cd clpeak -mkdir build || true -cd build -cmake .. -cmake --build . diff --git a/tools/scripts/profiling/clpeak/run_continuously.patch b/tools/scripts/profiling/clpeak/run_continuously.patch deleted file mode 100644 index 075c65bd2a..0000000000 --- a/tools/scripts/profiling/clpeak/run_continuously.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/src/clpeak.cpp b/src/clpeak.cpp -index 8cb192b..b6fe6f5 100644 ---- a/src/clpeak.cpp -+++ b/src/clpeak.cpp -@@ -47,7 +47,7 @@ int clPeak::runAll() - - log->xmlOpenTag("clpeak"); - log->xmlAppendAttribs("os", OS_NAME); -- for (size_t p = 0; p < platforms.size(); p++) -+ for (size_t p = 0; p < platforms.size(); (p+1 % platforms.size())) - { - if (forcePlatform && (p != specifiedPlatform)) - continue; diff --git a/tools/scripts/profiling/palanteer/.gitignore b/tools/scripts/profiling/palanteer/.gitignore deleted file mode 100644 index 158e7b0759..0000000000 --- a/tools/scripts/profiling/palanteer/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -palanteer/ -viewer diff --git a/tools/scripts/profiling/palanteer/setup.sh b/tools/scripts/profiling/palanteer/setup.sh deleted file mode 100755 index 6f115dc86b..0000000000 --- a/tools/scripts/profiling/palanteer/setup.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash - -set -e - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -cd $DIR - -if [ ! -d palanteer ]; then - git clone https://github.com/dfeneyrou/palanteer - pip install wheel - sudo apt install libunwind-dev libdw-dev -fi - -cd palanteer -git pull - -mkdir -p build -cd build -cmake .. -DCMAKE_BUILD_TYPE=Release -make -j$(nproc) - -pip install --force-reinstall python/dist/palanteer*.whl - -cp bin/palanteer $DIR/viewer diff --git a/tools/scripts/profiling/perfetto/.gitignore b/tools/scripts/profiling/perfetto/.gitignore deleted file mode 100644 index d86c89d462..0000000000 --- a/tools/scripts/profiling/perfetto/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -trace_* - -tracebox -trace_processor - -perfetto/ -configs/ diff --git a/tools/scripts/profiling/perfetto/build.sh b/tools/scripts/profiling/perfetto/build.sh deleted file mode 100755 index 24255ac3b0..0000000000 --- a/tools/scripts/profiling/perfetto/build.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -if [ ! -d perfetto ]; then - git clone https://android.googlesource.com/platform/external/perfetto/ -fi - -cd perfetto - -tools/install-build-deps --linux-arm -tools/gn gen --args='is_debug=false target_os="linux" target_cpu="arm64"' out/linux -tools/ninja -C out/linux tracebox traced traced_probes perfetto diff --git a/tools/scripts/profiling/perfetto/copy.sh b/tools/scripts/profiling/perfetto/copy.sh deleted file mode 100755 index b91d49a80b..0000000000 --- a/tools/scripts/profiling/perfetto/copy.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -DEST=tici:/data/openpilot/selfdrive/debug/profiling/perfetto - -scp -r perfetto/out/linux/tracebox $DEST -scp -r perfetto/test/configs $DEST diff --git a/tools/scripts/profiling/perfetto/record.sh b/tools/scripts/profiling/perfetto/record.sh deleted file mode 100755 index 9715a7e7e9..0000000000 --- a/tools/scripts/profiling/perfetto/record.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -cd $DIR - -OUT=trace_ -sudo ./tracebox -o $OUT --txt -c configs/scheduling.cfg -sudo chown $USER:$USER $OUT diff --git a/tools/scripts/profiling/perfetto/server.sh b/tools/scripts/profiling/perfetto/server.sh deleted file mode 100755 index 103ad9bd84..0000000000 --- a/tools/scripts/profiling/perfetto/server.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -curl -LO https://get.perfetto.dev/trace_processor -chmod +x ./trace_processor - -./trace_processor --httpd diff --git a/tools/scripts/profiling/perfetto/traces.sh b/tools/scripts/profiling/perfetto/traces.sh deleted file mode 100755 index 17ca89da72..0000000000 --- a/tools/scripts/profiling/perfetto/traces.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -DEST=tici:/data/openpilot/selfdrive/debug/profiling/perfetto - -scp tici:/data/openpilot/selfdrive/debug/profiling/perfetto/trace_* . diff --git a/tools/scripts/profiling/py-spy/profile.sh b/tools/scripts/profiling/py-spy/profile.sh deleted file mode 100755 index 2fdbe8d16e..0000000000 --- a/tools/scripts/profiling/py-spy/profile.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -set -e - -cd "$(dirname "$0")" - -# find process with name passed in (excluding this process) -for PID in $(pgrep -f $1); do - if [ "$PID" != "$$" ]; then - ps -p $PID -o args - TRACE_PID=$PID - break - fi -done - -if [ -z "$TRACE_PID" ]; then - echo "could not find PID for $1" - exit 1 -fi - -sudo env PATH=$PATH py-spy record -d 5 -o /tmp/perf$TRACE_PID.svg -p $TRACE_PID && -google-chrome /tmp/perf$TRACE_PID.svg diff --git a/tools/scripts/profiling/snapdragon/.gitignore b/tools/scripts/profiling/snapdragon/.gitignore deleted file mode 100644 index 80078842a0..0000000000 --- a/tools/scripts/profiling/snapdragon/.gitignore +++ /dev/null @@ -1 +0,0 @@ -SnapdragonProfiler/ \ No newline at end of file diff --git a/tools/scripts/profiling/snapdragon/README.md b/tools/scripts/profiling/snapdragon/README.md deleted file mode 100644 index 383d83ba9b..0000000000 --- a/tools/scripts/profiling/snapdragon/README.md +++ /dev/null @@ -1,13 +0,0 @@ -snapdragon profiler --------- - - -* download from https://developer.qualcomm.com/software/snapdragon-profiler/tools-archive (need a qc developer account) - * choose v2021.5 (verified working with 24.04 dev environment) -* unzip to openpilot/selfdrive/debug/profiling/snapdragon/SnapdragonProfiler -* run ```./setup-profiler.sh``` -* run ```./setup-agnos.sh``` -* run ```openpilot/selfdrive/debug/adb.sh``` on device -* run the ```adb connect xxx``` command that was given to you on local pc -* cd to SnapdragonProfiler and run ```./run_sdp.sh``` -* connect to device -> choose device you just setup diff --git a/tools/scripts/profiling/snapdragon/setup-agnos.sh b/tools/scripts/profiling/snapdragon/setup-agnos.sh deleted file mode 100755 index 9a781bf3ce..0000000000 --- a/tools/scripts/profiling/snapdragon/setup-agnos.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -# TODO: there's probably a better way to do this - -cd SnapdragonProfiler/service -mv android real_android -ln -s agl/ android diff --git a/tools/scripts/profiling/snapdragon/setup-profiler.sh b/tools/scripts/profiling/snapdragon/setup-profiler.sh deleted file mode 100755 index b97c1ae688..0000000000 --- a/tools/scripts/profiling/snapdragon/setup-profiler.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash - -# install depends -sudo apt update -sudo apt-get install libc++1 libc++abi1 default-jre android-tools-adb gtk-sharp2 - -# setup mono -sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF -sudo apt install apt-transport-https ca-certificates -echo "deb https://download.mono-project.com/repo/ubuntu stable-xenial main" | sudo tee /etc/apt/sources.list.d/mono-official-stable.list -sudo apt update -sudo apt-get install -y mono-complete - -echo "Setup successful, you should now be able to run the profiler with cd SnapdragonProfiler and ./run_sdp.sh" diff --git a/tools/scripts/ssh.py b/tools/scripts/ssh.py index 2d8ff46604..35c396815a 100755 --- a/tools/scripts/ssh.py +++ b/tools/scripts/ssh.py @@ -2,16 +2,82 @@ import os import sys import argparse +import queue import re +import socket +import subprocess +import threading +import time from openpilot.common.basedir import BASEDIR from openpilot.tools.lib.auth_config import get_token from openpilot.tools.lib.api import CommaApi +LOCAL_TIMEOUT = 1.5 +ATHENA_TIMEOUT = 8.0 + + +def read_banner(f) -> None: + banner = f.readline() + if not banner.startswith(b"SSH-"): + raise ConnectionError("no ssh banner") + + +def probe_local(host: str) -> None: + with socket.create_connection((host, 22), timeout=LOCAL_TIMEOUT) as s, s.makefile("rb") as f: + read_banner(f) + + +def probe_athena(proxy_cmd: list[str], procs: list[subprocess.Popen]) -> None: + p = subprocess.Popen(proxy_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + procs.append(p) + try: + read_banner(p.stdout) + finally: + p.kill() + + +def run_probe(name: str, fn, results: queue.Queue) -> None: + t = time.monotonic() + try: + fn() + results.put((name, (time.monotonic() - t) * 1000, None)) + except socket.gaierror: + results.put((name, None, "not found")) + except Exception as e: + results.put((name, None, type(e).__name__ if not str(e) else str(e))) + + +def probe_routes(routes: dict, athena_proxy: list[str]) -> tuple[str | None, dict]: + # probe all routes in parallel, prefer local since it's direct + results: queue.Queue = queue.Queue() + procs: list[subprocess.Popen] = [] + fns = {"local": lambda: probe_local(routes["local"]), "athena": lambda: probe_athena(athena_proxy, procs)} + for name in routes: + threading.Thread(target=run_probe, args=(name, fns[name], results), daemon=True).start() + + status: dict = {} + deadline = time.monotonic() + ATHENA_TIMEOUT + choice = None + while len(status) < len(routes) and choice is None: + try: + name, ms, err = results.get(timeout=max(0, deadline - time.monotonic())) + except queue.Empty: + break + status[name] = (ms, err) + if err is None and (name == "local" or status.get("local", (None, "x"))[1] is not None or "local" not in routes): + choice = name + + for p in procs: + p.kill() + return choice, status + + if __name__ == "__main__": - parser = argparse.ArgumentParser(description="A helper for connecting to devices over the comma prime SSH proxy.\ - Adding your SSH key to your SSH config is recommended for more convenient use; see https://docs.comma.ai/how-to/connect-to-comma/.") + parser = argparse.ArgumentParser(description="A helper for connecting to devices, either directly over the local network or through the \ + comma prime SSH proxy. Adding your SSH key to your SSH config is recommended for more convenient use; see https://docs.comma.ai/how-to/connect-to-comma/.") parser.add_argument("device", help="device name or dongle id") + parser.add_argument("--route", help="connection route", choices=["auto", "local", "athena"], default="auto") 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/comma/id_rsa")) @@ -19,11 +85,11 @@ if __name__ == "__main__": args = parser.parse_args() r = CommaApi(get_token()).get("v1/me/devices") - devices = {x['dongle_id']: x['alias'] for x in r} + devices = {x['dongle_id']: x for x in r} if not re.match("[0-9a-zA-Z]{16}", args.device): user_input = args.device.replace(" ", "").lower() - matches = { k: v for k, v in devices.items() if isinstance(v, str) and user_input in v.replace(" ", "").lower() } + matches = { k: v['alias'] for k, v in devices.items() if isinstance(v['alias'], str) and user_input in v['alias'].replace(" ", "").lower() } if len(matches) == 1: dongle_id = list(matches.keys())[0] else: @@ -36,22 +102,42 @@ if __name__ == "__main__": else: dongle_id = args.device - name = dongle_id - if dongle_id in devices: - name = f"{devices[dongle_id]} ({dongle_id})" - print(f"connecting to {name} through {args.host}:{args.port} ...") + device = devices.get(dongle_id, {}) + name = f"{device['alias']} ({dongle_id})" if device else dongle_id - command = [ - "ssh", - "-i", args.key, - "-o", f"ProxyCommand=ssh -i {args.key} -W %h:%p -p %p %h@{args.host}", - "-p", str(args.port), - ] + athena_host = f"comma-{dongle_id}" + athena_proxy = ["ssh", "-i", args.key, "-o", "BatchMode=yes", "-W", f"{athena_host}:{args.port}", "-p", str(args.port), f"{athena_host}@{args.host}"] + routes = {} + if device.get('serial') and args.route in ("auto", "local"): + routes["local"] = f"comma-{device['serial']}" + if args.route in ("auto", "athena"): + routes["athena"] = args.host + + if not routes: + print(f"no serial known for {name}, can't use local route", file=sys.stderr) + exit(1) + + print(f"connecting to {name}") + if len(routes) > 1: + route, status = probe_routes(routes, athena_proxy) + for r_name, r_host in routes.items(): + ms, err = status.get(r_name, (None, "not needed" if route else "timeout")) + result = f"{ms:.0f} ms" if err is None else err + print(f" {'*' if r_name == route else ' '} {r_name:<7}{r_host:<24}{result}") + if route is None: + print("no route to device", file=sys.stderr) + exit(1) + else: + route = next(iter(routes)) + print(f" * {route:<7}{routes[route]:<24}{'forced' if args.route != 'auto' else 'only route (serial unknown)'}") + + command = ["ssh", "-i", args.key] + if route == "athena": + command += ["-o", f"ProxyCommand=ssh -i {args.key} -W %h:%p -p %p %h@{args.host}", "-p", str(args.port)] if args.debug: command += ["-v"] - command += [ - f"comma@comma-{dongle_id}", - ] + command += [f"comma@{athena_host if route == 'athena' else routes['local']}"] if args.debug: print(" ".join([f"'{c}'" if " " in c else c for c in command])) + sys.stdout.flush() os.execvp(command[0], command) diff --git a/tools/setup.sh b/tools/setup.sh index ced451ab11..81eb543114 100755 --- a/tools/setup.sh +++ b/tools/setup.sh @@ -59,10 +59,10 @@ function ask_dir() { return 0 fi - read + read -r if [[ ! -z "$REPLY" ]]; then - mkdir -p $REPLY - OPENPILOT_ROOT="$(realpath $REPLY)/openpilot" + mkdir -p "$REPLY" + OPENPILOT_ROOT="$(realpath "$REPLY")/openpilot" fi } @@ -114,7 +114,7 @@ function check_git() { function git_clone() { st="$(date +%s)" echo "Cloning openpilot..." - if $(git clone --filter=blob:none https://github.com/commaai/openpilot.git "$OPENPILOT_ROOT"); then + if git clone --filter=blob:none https://github.com/commaai/openpilot.git "$OPENPILOT_ROOT"; then if [[ -f $OPENPILOT_ROOT/launch_openpilot.sh ]]; then et="$(date +%s)" echo -e " ↳ [${GREEN}✔${NC}] Successfully cloned openpilot in $((et - st)) seconds.\n" @@ -127,10 +127,10 @@ function git_clone() { } function install_with_op() { - cd $OPENPILOT_ROOT - $OPENPILOT_ROOT/tools/op.sh post-commit + cd "$OPENPILOT_ROOT" + "$OPENPILOT_ROOT/tools/op.sh" post-commit - if ! $OPENPILOT_ROOT/tools/op.sh setup; then + if ! "$OPENPILOT_ROOT/tools/op.sh" setup; then echo -e "\n[${RED}✗${NC}] failed to install openpilot!" return 1 fi @@ -147,5 +147,5 @@ check_stdin ask_dir check_dir check_git -[ -z $SKIP_GIT_CLONE ] && git_clone +[ -z "$SKIP_GIT_CLONE" ] && git_clone install_with_op diff --git a/tools/setup_dependencies.sh b/tools/setup_dependencies.sh index 5ad833a5ca..6f023f7b53 100755 --- a/tools/setup_dependencies.sh +++ b/tools/setup_dependencies.sh @@ -20,14 +20,14 @@ function retry() { } function install_linux_deps() { - SUDO="" + SUDO=() if [[ ! $(id -u) -eq 0 ]]; then if [[ -z $(which sudo) ]]; then echo "Please install sudo or run as root" exit 1 fi - SUDO="sudo" + SUDO=(sudo) fi local missing_linux_deps=0 @@ -42,33 +42,38 @@ function install_linux_deps() { # dependencies should never be added to this list. # these are only for inflating bare docker images # to their desktop equivalents. + # + # from a real desktop OS image, we install uv + # then use that to install all the dependencies + # specified in pyproject.toml. # ------------------------------------------------ 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 xclip wl-clipboard + "${SUDO[@]}" apt-get update + "${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 + "${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 - $SUDO yum install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git + "${SUDO[@]}" yum install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git elif command -v pacman > /dev/null 2>&1; then - $SUDO pacman -Syu --noconfirm --needed base-devel ca-certificates curl git + "${SUDO[@]}" pacman -Syu --noconfirm --needed base-devel ca-certificates curl git elif command -v zypper > /dev/null 2>&1; then - $SUDO zypper --non-interactive refresh - $SUDO zypper --non-interactive install ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-locale git + "${SUDO[@]}" zypper --non-interactive refresh + "${SUDO[@]}" zypper --non-interactive install ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-locale git elif command -v apk > /dev/null 2>&1; then - $SUDO apk add --no-cache ca-certificates build-base curl curl-dev musl-locales git + "${SUDO[@]}" apk add --no-cache ca-certificates build-base curl curl-dev musl-locales git elif command -v xbps-install > /dev/null 2>&1; then - $SUDO xbps-install -Syu base-devel ca-certificates curl git libcurl-devel glibc-locales + "${SUDO[@]}" xbps-install -Syu base-devel ca-certificates curl git libcurl-devel glibc-locales else echo "Unsupported Linux distribution. Supported package managers: apt-get, dnf, yum, pacman, zypper, apk, xbps-install." exit 1 fi if [[ -d "/etc/udev/rules.d/" ]]; then - $SUDO tee /etc/udev/rules.d/11-openpilot.rules > /dev/null <<-EOF + local udev_rules + udev_rules=$(cat <<-EOF # Panda Jungle devices SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddcf", MODE="0666" SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddef", MODE="0666" @@ -85,11 +90,17 @@ function install_linux_deps() { # comma devices over ADB SUBSYSTEM=="usb", ATTR{idVendor}=="04d8", ATTR{idProduct}=="1234", ENV{adb_user}="yes" EOF + ) - # delete the old ones - $SUDO rm -f /etc/udev/rules.d/11-panda.rules /etc/udev/rules.d/12-panda_jungle.rules /etc/udev/rules.d/50-comma-adb.rules + if ! cmp -s /etc/udev/rules.d/11-openpilot.rules <<< "$udev_rules" || + [[ -e /etc/udev/rules.d/11-panda.rules || -e /etc/udev/rules.d/12-panda_jungle.rules || -e /etc/udev/rules.d/50-comma-adb.rules ]]; then + "${SUDO[@]}" tee /etc/udev/rules.d/11-openpilot.rules > /dev/null <<< "$udev_rules" - $SUDO udevadm control --reload-rules && $SUDO udevadm trigger || true + # delete the old ones + "${SUDO[@]}" rm -f /etc/udev/rules.d/11-panda.rules /etc/udev/rules.d/12-panda_jungle.rules /etc/udev/rules.d/50-comma-adb.rules + + "${SUDO[@]}" udevadm control --reload-rules && "${SUDO[@]}" udevadm trigger || true + fi fi } diff --git a/tools/test_runner.py b/tools/test_runner.py index 1979ce01e4..2121907e33 100755 --- a/tools/test_runner.py +++ b/tools/test_runner.py @@ -170,7 +170,7 @@ def collect(targets, keyword): cls = type(test) if cls.__name__ == "_FailedTest": continue - if getattr(cls, "__unittest_skip_why__", "") == "parameterized base class": + if cls.__dict__.get("__unittest_skip_why__", "") == "parameterized base class": continue if not keyword or keyword.lower() in test.id().lower(): tests.append(test) diff --git a/uv.lock b/uv.lock index 30be99f497..b12e82170b 100644 --- a/uv.lock +++ b/uv.lock @@ -7,15 +7,15 @@ overrides = [{ name = "opendbc", editable = "opendbc_repo" }] [[package]] name = "anyio" -version = "4.14.2" +version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] [[package]] @@ -93,14 +93,11 @@ wheels = [ [[package]] name = "click" -version = "8.4.2" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] [[package]] @@ -123,229 +120,229 @@ wheels = [ [[package]] name = "comma-deps-acados" -version = "0.2.2.post98" +version = "0.2.2.post103" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/12/bd/b8bbb3791b42c5aef32c5ebaeaebcf4d1d712cc9e67222523daa179c7419/comma_deps_acados-0.2.2.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1067e2404c08bab1ebcdd46b00d505f956d2edacc9f50d307b0618b35eff0421", size = 10554192, upload-time = "2026-09-11T22:31:14.412Z" }, + { url = "https://files.pythonhosted.org/packages/a3/72/31488259bb173161089393e7383ae90a7f3fb76b5d41098639a78bd9699f/comma_deps_acados-0.2.2.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2ad9fcebef1f65112a9cebe8a093977310602e1325d442dc345beaf512679211", size = 11670466, upload-time = "2026-09-11T22:31:18.634Z" }, + { url = "https://files.pythonhosted.org/packages/98/eb/83c5ab1275b812ac10988e1ddbfd541088e8a5a883e45f34bd0ee35e2138/comma_deps_acados-0.2.2.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3b451852e83d62815cead999ab31073db9be60307f772650336cdd4534f12b9e", size = 13139433, upload-time = "2026-09-11T22:31:22.891Z" }, ] [[package]] name = "comma-deps-bootstrap-icons" -version = "1.10.5.0.post98" +version = "1.10.5.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/21/17/277a473fb1f6b28f88ca0b6c477b1ddf439f68371c0d4e80fdc7925f98ea/comma_deps_bootstrap_icons-1.10.5.0.post103-py3-none-any.whl", hash = "sha256:9879fa2b3b112d20f4acf6cc83bb9732f95f0864db74d25224a31fa9827f37ae", size = 386008, upload-time = "2026-09-11T22:31:27.373Z" }, ] [[package]] name = "comma-deps-capnproto" -version = "1.0.1.post98" +version = "1.0.1.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/61/ef/a108b56be5bd6a655c4acaf9c7d6a11c4397d5440bd192314dc2b4e5b997/comma_deps_capnproto-1.0.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cfea5b5679a530affc674717173455e1bb26bde06491283f461d8c070df33811", size = 2404560, upload-time = "2026-09-11T22:31:31.176Z" }, + { url = "https://files.pythonhosted.org/packages/c4/53/38b0e046aab3f0d371d0fa6eb3d4eb3cf787e7d270f429a485e53080416c/comma_deps_capnproto-1.0.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4a115a789af5835dde75371abdd0c1766df40764eaccfd912de4212522935d08", size = 2503570, upload-time = "2026-09-11T22:31:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/fcbdd3ac0f635a3b1eb674e5bd4c3ada804c89348c1a3818ca2fc91e3d91/comma_deps_capnproto-1.0.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f5717a6e6bee615bca9a68e9a5d10a11a871e0f61ee2d66759681de562d82c9a", size = 2587991, upload-time = "2026-09-11T22:31:38.822Z" }, ] [[package]] name = "comma-deps-eigen" -version = "3.4.0.post98" +version = "3.4.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/c0/b4/bfcd0e8b1480059e94f978a85497ebffdd33d59a8272a68556e23ed7d1b8/comma_deps_eigen-3.4.0.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:196b9019947552464cf72c7c4659ade6d8bd6733e0bb063d910b168cdc0c2c75", size = 2272667, upload-time = "2026-09-11T22:31:53.434Z" }, + { url = "https://files.pythonhosted.org/packages/6f/23/02407262cc7cb6509bd95b237535b3b32e6447b24abc32200aed6e9b4745/comma_deps_eigen-3.4.0.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5f18b5ae2b007f5422ce67f1e1e805bb07404885d3a8718a0341daca748a0980", size = 2272674, upload-time = "2026-09-11T22:31:57.274Z" }, + { url = "https://files.pythonhosted.org/packages/40/43/a5001078c02a5f6c28e26e3d0458a7e53826d77e9945f3196921df620a41/comma_deps_eigen-3.4.0.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:e07754a4f6062285545f70c597321a5736e193aa5a600ebe189af9e43ad965ff", size = 2272674, upload-time = "2026-09-11T22:32:01.084Z" }, ] [[package]] name = "comma-deps-ffmpeg" -version = "7.1.0.post98" +version = "7.1.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/90/10/1407eb09f39d036c2c4d101c3e429f69059a20c78197669a2b10023ac30e/comma_deps_ffmpeg-7.1.0.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4b5074ae64ff44f32e5d2b44ea577cafeee5279e6f1029cc05ab74429f72a64f", size = 7326617, upload-time = "2026-09-11T22:32:04.765Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3b/770c32c1a232dc73bfb43f5f5834ecb2ff8fe55ff627513e1ef458214c7c/comma_deps_ffmpeg-7.1.0.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c0da5b62987de3375d1f8fa2e68d85d1a4a11bbb19a42235ae1f907f372bc364", size = 4437978, upload-time = "2026-09-11T22:32:08.792Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c8/a17c8c293b64ed75074510d9460135b2875ac1832028f950d8f69982df53/comma_deps_ffmpeg-7.1.0.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8756f18e5b1fd760663dfed3b0762fd6b6a5f5e5acfb695e64d06605d2d9ce68", size = 4681802, upload-time = "2026-09-11T22:32:12.583Z" }, ] [[package]] name = "comma-deps-gcc-arm-none-eabi" -version = "13.2.1.post98" +version = "13.2.1.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/f4/3c/d106a7edbb6e832974fde089c04282da9e0d035e8de1b1b5c6dbedb9deaf/comma_deps_gcc_arm_none_eabi-13.2.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ae7247a36c61a7c3864ac052dadd6787235ffc04d0b724972968ac57164fde00", size = 15239169, upload-time = "2026-09-11T22:32:16.377Z" }, + { url = "https://files.pythonhosted.org/packages/b5/85/3f97e71022055862e0a5f47c960eea18518a750da62ef7f6125216ec0e9c/comma_deps_gcc_arm_none_eabi-13.2.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:dd8eaac31e11dd291b4ea37f14c43e53e5b2b2b059207deb44b5caa3be2c6d86", size = 17367602, upload-time = "2026-09-11T22:32:21.355Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b5/4d8294c7e081b93363df0b6773be0bb2632d53c52e4fc89e64f51ab51ea5/comma_deps_gcc_arm_none_eabi-13.2.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6006a1073292d56d3cc25f1a236fa4da6039028d9c251bdd66747f5d5013e020", size = 16941494, upload-time = "2026-09-11T22:32:25.886Z" }, ] [[package]] name = "comma-deps-git-lfs" -version = "3.6.1.post98" +version = "3.6.1.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/5f/c3/75c2406e52cb5129d8ce6b79f80a610417d8c8729422c1e80b14b6771e69/comma_deps_git_lfs-3.6.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ba61d0c194725aee5644731f229ff36f56a00dfd450f8226794008ab98a93144", size = 4685121, upload-time = "2026-09-11T22:32:30.047Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4f/17fb16d2de5d858ab95fdd09fd652f25d8a698b35df3e4ce24589dfd9565/comma_deps_git_lfs-3.6.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2d815aab8aa153a6536f538a65872ac64aefe8c62da153f9b0e8bba23a98d5a8", size = 4485292, upload-time = "2026-09-11T22:32:33.868Z" }, + { url = "https://files.pythonhosted.org/packages/d3/30/ae0def1cf507492591fb2852066dda163fd28d0e1cf52ad76c20ce1fb358/comma_deps_git_lfs-3.6.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:a78ca419dec75a1b99229ac40c086c3236b86ff8585740c6b34c2ccea95b1389", size = 4889597, upload-time = "2026-09-11T22:32:38.084Z" }, ] [[package]] name = "comma-deps-imgui" -version = "1.92.7.post98" +version = "1.92.7.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/4d/0f/29f3dc72d8b745f8db30245502c7d5626eff5d244a4c02c4e89bbc8497ec/comma_deps_imgui-1.92.7.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bb693b2e9f3aa5900b8884527409e73d6580c8b05bb42e1ec1a2897f4f6c05fc", size = 1688025, upload-time = "2026-09-11T22:32:41.73Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4b/abf18a8cc4527c6cb1d8f8dbec10b02e28746b303850d90fe0af5ba82a9d/comma_deps_imgui-1.92.7.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1b2737fa6f47ad00835828ff663eae7150519e0a6cc7df9e0b336a48c24032b6", size = 2522805, upload-time = "2026-09-11T22:32:45.498Z" }, + { url = "https://files.pythonhosted.org/packages/17/b8/d5fe737f156ca1dd1797f9ed664464b43fbe7712f9b61271fbe6315ed539/comma_deps_imgui-1.92.7.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6554b6ff6ba818038ac8a6c51bd71afbb285c7b3f1c0c31947298b17db8f111b", size = 2655501, upload-time = "2026-09-11T22:32:49.008Z" }, ] [[package]] name = "comma-deps-json11" -version = "20170411.0.post98" +version = "20170411.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/1c/5c/8bed22f3118f53ae08763c6dfc755d08826c750ac21864d60a4710ef3183/comma_deps_json11-20170411.0.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cb255b3c010e83f8361869f7cf8b1f585db5e0dabc92b41f3132af49838e981a", size = 34039, upload-time = "2026-09-11T22:32:52.45Z" }, + { url = "https://files.pythonhosted.org/packages/6c/be/51c3dd8f80ee0a968a90b4737e0362e7b621366c24c9c55613386ba0aa14/comma_deps_json11-20170411.0.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0f7764609db411d98c540d985c289a2c37f282fbf2a75e2503017f1243546653", size = 41850, upload-time = "2026-09-11T22:32:55.711Z" }, + { url = "https://files.pythonhosted.org/packages/79/73/7f893e3a65e82c2453806c39e79eb4e2a8074f358cfa20e1b66e46e9a6b3/comma_deps_json11-20170411.0.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:a309d401dd22b758455dafd3c28b72c4bd88c7482ddd70fe8653e53efc745a83", size = 42604, upload-time = "2026-09-11T22:32:58.866Z" }, ] [[package]] name = "comma-deps-libusb" -version = "1.0.29.post98" +version = "1.0.29.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/ad/81/865cbc69c8f182a020f4fb85c70b4d4e1b2a636eedfc34c85490472c1e56/comma_deps_libusb-1.0.29.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7a8afc81bce03f3d6386613983a23a303369bed19593e84d0c744e8381087cb1", size = 102343, upload-time = "2026-09-11T22:33:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1a/d07a980d8132c58cc328974c0566c8263fcff9b60e053129030fd849dd2c/comma_deps_libusb-1.0.29.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:56661f2674fd41abb5337a435c032552ecd7318857d598f79c4fd6cdddd727d8", size = 94431, upload-time = "2026-09-11T22:33:05.488Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2f/19227534c7f7cf4850ef73df16ec5feabd80e23edebec648877a5b82eab6/comma_deps_libusb-1.0.29.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:450c9bfaad634931c336eeea8369d4ac8db78ba092b3c5d7b70c098d5634b050", size = 93460, upload-time = "2026-09-11T22:33:08.701Z" }, ] [[package]] name = "comma-deps-ncurses" -version = "6.5.post98" +version = "6.5.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/94/43/6a0c4cec1c245ec0dafa8d2c6bfa433b596153f7efa0d58f6b80c0145a74/comma_deps_ncurses-6.5.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:aad15d3cd54e6930defdad9f3c968b3f115ea634369b75b62b1457eee4789208", size = 264908, upload-time = "2026-09-11T22:33:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ca/f59ff912be4f191f6e3416548d880d662b66237e37bc8b4026af2aad2244/comma_deps_ncurses-6.5.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a8d5822ac1a1cd5bb16e7e06ba702d6de6fb584b5727190583b6d6d983cf8530", size = 260849, upload-time = "2026-09-11T22:33:15.623Z" }, + { url = "https://files.pythonhosted.org/packages/36/f8/7406248610e7862cfca0e2c5b447baa565c504c7ba72ce19e811240e73d6/comma_deps_ncurses-6.5.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:7eeb250ed2bf34d2c69098d42ec5d63e333a2e3a802911a0e1036e855b547b5c", size = 248321, upload-time = "2026-09-11T22:33:19.032Z" }, ] [[package]] name = "comma-deps-raylib" -version = "6.0.0.1.post101" +version = "6.0.0.1.post103" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/90/e289acd1725d71c792c33399422f1052c4b0c38aeb4222a866d30c4a2cad/comma_deps_raylib-6.0.0.1.post101-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa69d5093a92d7d2bfd2714a1afccca63b94d4c55fae88e61b80e8841de6a6cd", size = 1885392, upload-time = "2026-08-27T18:24:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/a1/17/12977631f6d86d1daa4f67a310cfdf2783ba288f42d444d6a89b2297a0a4/comma_deps_raylib-6.0.0.1.post101-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c93ff9b45df414620b3011280da77151764c027e2f95d384fe255615d78872cc", size = 21707644, upload-time = "2026-08-27T18:24:41.282Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ca/ef33aff790b37dfc925f94fbb3a86da19f67929c15b3725ddb18afb91e96/comma_deps_raylib-6.0.0.1.post101-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8f2a1ffe5f60cac06170b144d6ce84925c91657e7ada3ae35bc5df6ccbe0b461", size = 20722616, upload-time = "2026-08-27T18:24:45.803Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/21e3be3ad1e2382b9ecab7d836e7ca19ea0959fe714d539a71c6b87ba510/comma_deps_raylib-6.0.0.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d8bef63b7e49bc8704eda9fecdefbff01d7f6c9027069cb1b1f5150cac077ed1", size = 1885397, upload-time = "2026-09-11T22:33:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4d/cceaf969d771af8870094d06bb84dca805f9268584a9a3c134dfee2307c7/comma_deps_raylib-6.0.0.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d9b9bebb39167442bacf627958d57ae95e452550ced1b68337cbf2a73375856a", size = 21707647, upload-time = "2026-09-11T22:33:26.533Z" }, + { url = "https://files.pythonhosted.org/packages/27/ce/e8b941bcf0b213ca9cb4d8cb69b18398b4dad77923925d80bc3281837461/comma_deps_raylib-6.0.0.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:942ec942ec01005f05584eede1a6add483043e8e8f764e68cfebb7c21c443b89", size = 20722617, upload-time = "2026-09-11T22:33:31.158Z" }, ] [[package]] name = "comma-deps-zeromq" -version = "4.3.5.post98" +version = "4.3.5.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/8f/a9/9013d207cca0f1d4dda65f1babf16e9e2e0b5fe86259bf994d15cc6e6024/comma_deps_zeromq-4.3.5.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:66d4cc63e45bf5864e68d6a9528ce90419f4c7210250970ff58b3919985c0929", size = 814878, upload-time = "2026-09-11T22:33:35.467Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/522bdc7b93d9c84b2600e9ed84588c94acf698a8aa3186e304c6d54b4371/comma_deps_zeromq-4.3.5.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d25d7b336707f39a6c92f0b01b40ca761d067564b39c6c56a6a7f4cca7fd512d", size = 832994, upload-time = "2026-09-11T22:33:39.03Z" }, + { url = "https://files.pythonhosted.org/packages/e4/7f/e79ec0b13146d9aea6004c06bcc37264bda973a5a71248d32dbcc7ac29b4/comma_deps_zeromq-4.3.5.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:bb05f93a91b140eb54129bf68824d00a6f93802071957062995cbbc6184f70d2", size = 798535, upload-time = "2026-09-11T22:33:42.538Z" }, ] [[package]] name = "comma-deps-zstd" -version = "1.5.6.post98" +version = "1.5.6.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/2e/e5/17e340fd63173767d7beae9228c2e227352ba880ced754203f9b5096f223/comma_deps_zstd-1.5.6.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37bd1c5b8bfac9d19095f741030a73a58663bf0aab93a99768f53db67fcd09ac", size = 1004948, upload-time = "2026-09-11T22:33:46.046Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/96a88f630732dd83f0d01adf7707b369a055eea6dc4612a30ae6edaff8fc/comma_deps_zstd-1.5.6.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d9a6b6b6d4ecdfd28d4843f8dcd42dfd6f4886672cc90c0957798f9c0326571a", size = 956886, upload-time = "2026-09-11T22:33:49.654Z" }, + { url = "https://files.pythonhosted.org/packages/4b/64/23cf37ae4d915ad9f7b341b08cc0435aaeeef543185c86bb5b860c87cad9/comma_deps_zstd-1.5.6.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:d7bbc77bb174475f30bde69d7451baa47ddbe12724e586ac8874d799c7368101", size = 980997, upload-time = "2026-09-11T22:33:53.384Z" }, ] [[package]] name = "contourpy" -version = "1.3.3" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/97/ba/01bde9753bdbba04a6da9d2bff3881f021bc708f654655e95fae26d3b3a3/contourpy-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:186ba929df36d61b6127da2e89cd1357e4cb79aca647381ab1a6feb0b152877b", size = 297302, upload-time = "2026-09-11T19:02:45.103Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f5/c5bf67522d49a2222f3154fe46879451d26f0e35ece41770758a64ea78cb/contourpy-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c76f3a5318164db7d9401132fc1b5364b784c613c93fa506e3b5e6d1bf353ec8", size = 284921, upload-time = "2026-09-11T19:02:48.049Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cc/cb989599eec12fda312e127eb8e04a8b21a7a6c94bf7ff1bc68273334a42/contourpy-1.4.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43c3ccbb32c6294b183dcc8e8c46dacf5ecef809497e3d48a5be298eef185ad0", size = 356332, upload-time = "2026-09-11T19:02:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/6f620d7602507817b0b4c21dd790ed68688f1f4ae9c29aacf21f08db5cf1/contourpy-1.4.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86d05cec773c9507a3950122e0e40ce77c23c75ceeb2fc189514e71893cbb34b", size = 405878, upload-time = "2026-09-11T19:02:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/693c6d6bdece679f0953775eef3a1b66d56be55478d936e7deb1f3004422/contourpy-1.4.0-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2deb580178ca19437a84bd77e4bc2cd91a8ccad212413a83c273900868b5978c", size = 408296, upload-time = "2026-09-11T19:02:56.528Z" }, + { url = "https://files.pythonhosted.org/packages/78/f9/b6831508960d559581c448532ba4df312217072975486dbf2b24fcc5b76f/contourpy-1.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:875f42444c9cf48d56f724f2637e60d0f73b3b12c9041e1484580a233edf9591", size = 383243, upload-time = "2026-09-11T19:02:58.638Z" }, + { url = "https://files.pythonhosted.org/packages/f2/21/52903825a0ae7bb625e8bd30a09816d4c3c5d6174a469c10a616166cf780/contourpy-1.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f863c6100bf926cf47d13f3cd75f9bb8ebd98aaab230eeb4468b91f98f39a6b3", size = 1357306, upload-time = "2026-09-11T19:03:01.036Z" }, + { url = "https://files.pythonhosted.org/packages/d8/79/d68b1ce8539e4071518fed9523f558398f34dcd078b8927b109c72dad2ef/contourpy-1.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3863ef2e2b13fe93f8c0ebb08ee400cb07153b8b0e91c5acb26e4537f283634f", size = 1427243, upload-time = "2026-09-11T19:03:03.866Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/b75a10e9d0616b97a30de277b6dfe83f1879880854c7330337309530eb48/contourpy-1.4.0-cp312-cp312-win32.whl", hash = "sha256:5450f091ac1be0be3ad3a2a3b3f23b5e443e78c670ced4fd347d626f92a28fd2", size = 347345, upload-time = "2026-09-11T19:03:06.192Z" }, + { url = "https://files.pythonhosted.org/packages/50/a9/dab08786bb4d77ef9a046b3c1be923be4e73c5d07d91f5a54e8ea9b09e41/contourpy-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e697d94e69f499ff6bebb899cae97a58d5d14f0e1fe9568b43a0248d2f9af8c", size = 234083, upload-time = "2026-09-11T19:03:08.122Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/3ba509755a970dbde5948e7142ac21f266be72f38017cc4087a05fdceee1/contourpy-1.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:0c7a4c2716a4e98342221954416a836cca77c996c14ddbf22b5f01d5d93ca09c", size = 559521, upload-time = "2026-09-11T19:03:10.047Z" }, ] [[package]] name = "coverage" -version = "7.15.4" +version = "7.16.0" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/f5/deb1a27aa20746c0278ac998c4179e272004699b2d33959ce020c5ac1615/coverage-7.16.0.tar.gz", hash = "sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd", size = 945620, upload-time = "2026-08-28T21:54:37.74Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/bc/9c/8d2688694f53dc0b0f0e4783c7eb3c4bb1e79beaf1411879f6dabedf4607/coverage-7.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1c77c3579ac42798f8b7eed6d3dd258debacca32c8753fc8a1f6eaf1db644f5", size = 223194, upload-time = "2026-08-28T21:51:27.767Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/f002163dd688aa3fa49ac6a424b7c2705c7fcf80fba18ec9f586d77827ca/coverage-7.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f81cb1554c3712e41649ed5dc98656b50b958e4da12f0f5adb681ce3db92831", size = 223553, upload-time = "2026-08-28T21:51:29.46Z" }, + { url = "https://files.pythonhosted.org/packages/81/65/f9d469e97c4554372a710650a109004a2434dfc56f577142e5d6057fa0cc/coverage-7.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e701938ec9081d3e400a0c9a9a8ae0f7ca44214741daeac4454b1c6ef6dbd19", size = 255054, upload-time = "2026-08-28T21:51:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/95/29/dd89fd39af1a3b6e9a9c3eddeaf03f6376ba517d43d6cbf8b519177e2a10/coverage-7.16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:719a3feb6220dd32ed932d4c3676d17fb8739e2643b29c0e7c3af400ff80ac44", size = 257790, upload-time = "2026-08-28T21:51:33.374Z" }, + { url = "https://files.pythonhosted.org/packages/0a/64/208d26cedc525d6b5db9c492cf9130784c42d9eb08d22badaa7b806005ad/coverage-7.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87771ecf986cff55e87413238cd5e4f54d949c2074bd6fc1657d26a56314ee24", size = 258904, upload-time = "2026-08-28T21:51:35.096Z" }, + { url = "https://files.pythonhosted.org/packages/1f/98/28e2752aa9a8baee5798edade9c95602ca200f4e7eeb503eb64df42e5921/coverage-7.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47d5e1fc0b321c8308a2aacee0497c435b08acaa629b7059798fdf6fc3006352", size = 261165, upload-time = "2026-08-28T21:51:36.744Z" }, + { url = "https://files.pythonhosted.org/packages/eb/77/fa6ae699a0ea2bc12acb38a85d96b786fea0f833c12b5756056350e0e547/coverage-7.16.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01b18b8a6c9cec8d5f45550e2501426ed982cf2c35016b0acd2ba9b5d8b2fb06", size = 255416, upload-time = "2026-08-28T21:51:38.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/c8/5ee46d1de7d34cb00ba08b5c50da1971114dbc09ca9898ccc32975ec74dd/coverage-7.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:32c56b5b47c50635081445ac404dd08c2d591b9c837c22570aa9e182c3b42cd4", size = 256825, upload-time = "2026-08-28T21:51:40.27Z" }, + { url = "https://files.pythonhosted.org/packages/15/f6/d59e1c0693ad48855fe20169fbf6ee5befefe5887a7fabf5f0bcb464a2dc/coverage-7.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6ad3bbad240ab937512156bc944fdee63ac4dd34a7558a3094548fd4c1150c02", size = 254970, upload-time = "2026-08-28T21:51:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/df/7b/b51bbe05b3a7565927fccfb1be42b8b3c1f4ab15e53d91b303e9923969aa/coverage-7.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4c1f16d5555a195295d0dc9c902612270e3dfed6a11f3bf7bc470b7b6a79ed3c", size = 259039, upload-time = "2026-08-28T21:51:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/d513f816456a8a43c1859abe88a37d01d7d2515b6c3e24ebb3c9b1dd44ec/coverage-7.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f6c9c21a8bf0d19788f3c5f3e020c90317a0a63ef60521b376003801e21250fb", size = 254539, upload-time = "2026-08-28T21:51:46.733Z" }, + { url = "https://files.pythonhosted.org/packages/dc/54/5542190ceb97e0d1333a4ce0c8f95b2ef2efe790f1ad018a4b61766f849e/coverage-7.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f20145a9eb5bf1fd1dde3c0bc2af2e7c22135ab07ca6284d6ada7cc3904c4e", size = 256410, upload-time = "2026-08-28T21:51:48.363Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/78643f361ff6bb5b2ade90f8bfc8395fe9ca367a18c101f8991215b4c65b/coverage-7.16.0-cp312-cp312-win32.whl", hash = "sha256:916cf8d25c1ce148f7eceb1d45afc9724841200110adc4e53250391852debd91", size = 225239, upload-time = "2026-08-28T21:51:50.22Z" }, + { url = "https://files.pythonhosted.org/packages/67/61/8e76b36c36b1a033dc933dd2480db96b04ce3be975793ce3fad122e7174d/coverage-7.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:78f8b56261d608be102c62edd3a60b66bcd0b581f3f86fdcabaf8b8d95adc950", size = 225775, upload-time = "2026-08-28T21:51:51.912Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f3/bb4787a4b81c1792ca69b502f5f730dbbb609f73fed552ab074c6b92cb8b/coverage-7.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:577c2ac8c0036f6f8edd3a7783a9e67302b17771d1abf0fd2ed246e3158be51b", size = 225159, upload-time = "2026-08-28T21:51:53.667Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, ] [[package]] name = "cryptography" -version = "50.0.0" +version = "50.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, ] [[package]] @@ -359,59 +356,59 @@ wheels = [ [[package]] name = "cython" -version = "3.2.9" +version = "3.3.0" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/d8/4981ef716ad0e3ff0d3ef383aefc6b03c4a88dee33b272bf8e0d833001ca/cython-3.3.0.tar.gz", hash = "sha256:eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8", size = 3727515, upload-time = "2026-08-22T05:16:39.493Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/da/0f/95bad838a80ac52c9e982dad00bd9a0b2bad57fb4c688e5f53ac3ef65ff0/cython-3.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03bc5333932f5dda3ba9315298ecdd21daa1b58410bb1f8ce04c78ec8337130a", size = 3143472, upload-time = "2026-08-22T05:17:07.956Z" }, + { url = "https://files.pythonhosted.org/packages/91/8b/53d4a84de853b39940a0e35a6a2a9ed5f54cb05468daee95bc0fd1c2a178/cython-3.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e321ae700995a16dc3055ada06ffb8d61e1a7434e5d0e811547a45ac1015ebd", size = 3258974, upload-time = "2026-08-22T05:17:09.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4e/6b1c5a4e6bbe1726104de007aa2fdf01a3e2e386b4ec93c7be5f5085d53f/cython-3.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:428fafed98ea26927000a287b4dfc9ef07339f56656a5329a34eaa593f79a4f8", size = 3412225, upload-time = "2026-08-22T05:17:12.281Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9b/cd724d91c500116769bdb853450a2197ba3d640dbbe3b02fc54ebdfdbd1b/cython-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:333449cc0350baedee5a6af27929eac8a71eac4ec59333c45ff476b33c6c660d", size = 2872123, upload-time = "2026-08-22T05:17:14.322Z" }, + { url = "https://files.pythonhosted.org/packages/14/59/bc1a84b434cb5bebb0cd6f50da8f239d35a5c141b20fdeafc2817fd87778/cython-3.3.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:e0d2713d2b292c826bc21dc8732bd9e47628103aa3764180c881e04b3fef95dc", size = 3063660, upload-time = "2026-08-22T05:17:40.923Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6d/542e32908fb421d88354f327ed6450e14240f9825d25393065bc65f4723f/cython-3.3.0-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:169e56fd411f4cd5bba51c82f8239421d547a846099db2b261e4aed48ba9f51f", size = 3358395, upload-time = "2026-08-22T05:17:43.036Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ddaf197bc65b581e1891657940bc4f7cb1f740e822115e828920b3a119ce/cython-3.3.0-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:29f38ebafdf23e3da2516f40c4d065da38bfe002181bf93e2b8cf1262449aba6", size = 3041760, upload-time = "2026-08-22T05:17:44.907Z" }, + { url = "https://files.pythonhosted.org/packages/19/a7/ae5ec3e34d43da846ed4c425734752d83aae0dae49feb929f09c90fc9afa/cython-3.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:75c4ae8a6d3a5ccf3cdaba8ab32e6a8d0cd38e3a476aa7ac12df8f8171a8d570", size = 3156152, upload-time = "2026-08-22T05:17:46.884Z" }, + { url = "https://files.pythonhosted.org/packages/31/44/c60b601fc43f0b08e9d6f14b94e0dd02eb0ca8d60f46e242ace7191ac1be/cython-3.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b94fb5613b9fe34c27d13ec9972dc0dcd2a2155db2902e93921cadc162610a38", size = 3046690, upload-time = "2026-08-22T05:17:48.731Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9e/d735c26ed907563d3365534006acb263651c2d3b87fee804f7a483dd1714/cython-3.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c4558ba85849ab65dc57e10fd0efb13fabd9d3c09981a2566e18dec7cf47586a", size = 3373534, upload-time = "2026-08-22T05:17:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e8/aa7b4f3a28d6e8117c76e2cf78a0df7a503486cdf7243c5b53200c9533a1/cython-3.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:311a016369adfd1e0015c4f9819168fc0e518451d7efb4435c30d65a3a26d52b", size = 3265110, upload-time = "2026-08-22T05:17:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/9c/66/37892a8999d6bbd3f92d691a9701cb720c8ddd6171e16f5148eee6e8cb7f/cython-3.3.0-cp39-abi3-win32.whl", hash = "sha256:90869072e50b7c8904fe1dd7810321ae901fd5637a6eec6646ed9c57f9eb1081", size = 2587733, upload-time = "2026-08-22T05:17:54.547Z" }, + { url = "https://files.pythonhosted.org/packages/19/a2/5f4d305cbd4489d21570e5491ad5c483c478cdab032853e2125c280e3bd5/cython-3.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:dce56c26d388f00a19426371b6926bf2f77c5c03b71d5273e4556c68be98c2dd", size = 2608078, upload-time = "2026-08-22T05:17:56.386Z" }, + { url = "https://files.pythonhosted.org/packages/bf/77/67b0b24e45073a699610e50f00c18474ff9b09ea29ecc95083bdf5e60acd/cython-3.3.0-py3-none-any.whl", hash = "sha256:9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1", size = 1349151, upload-time = "2026-08-22T05:16:36.741Z" }, ] [[package]] name = "deepmerge" -version = "3.0" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/38/6e/5cb3548b4d3112fea529375e55e6f3cdc52b8054e3a66f203b1f888ba885/deepmerge-3.0.1.tar.gz", hash = "sha256:35b39a4cb92cf328d6eca61cbbf65f68a37c2ceb3085f0f853cbb2e52a59fc23", size = 22328, upload-time = "2026-09-01T14:09:44.383Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/20/91/600003aaad107e27553fbc9cbfc57e96fa37e0223d2ef9d7d3a0e8d8d070/deepmerge-3.0.1-py3-none-any.whl", hash = "sha256:35c96f6a68fcf90719a5b31d9f8042ecef6c00fb56836660d33455d0f5cfda65", size = 14909, upload-time = "2026-09-01T14:09:43.364Z" }, ] [[package]] name = "filelock" -version = "3.32.3" +version = "3.32.7" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/59/e19834834cb01a32febfbb0f8a23a9088088f5d45991824ff2bc3b5e8acb/filelock-3.32.7.tar.gz", hash = "sha256:37b8a3d9811b0f9aef7e5ec5c71bb320de52df51e6ca9bcd6f5ad81187660da7", size = 225154, upload-time = "2026-09-16T00:24:20.907Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/15/df/31098c5aeb4d966b553641472bd55fcf5fdfac953549894b8a765ba44e91/filelock-3.32.7-py3-none-any.whl", hash = "sha256:65ff0d0190ea42038b32bda4b77834fb05be2cad4c5b9b01aa4dfb3614536e52", size = 100157, upload-time = "2026-09-16T00:24:19.543Z" }, ] [[package]] name = "fonttools" -version = "4.63.0" +version = "4.65.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/51/d63c7e52163ac14393a35bd14bd7c0da95f8f74be5d7cc988092f9965129/fonttools-4.65.0.tar.gz", hash = "sha256:762ba5431358d0dbd4a01982484a1d494fb267e91f974cdcf20b80eab8560f6f", size = 3674467, upload-time = "2026-09-10T15:35:54.955Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, - { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, - { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, - { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, + { url = "https://files.pythonhosted.org/packages/58/db/242fa4fce7f632c5f7ab15585343393b25792510c0c32bd218ad24d59f1c/fonttools-4.65.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e844a45c9e5ced6536f184cf1a65b5d65e8f7e711993b413e10500a8223622e5", size = 3097120, upload-time = "2026-09-10T15:33:46Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b6/42fa4d373416675f74446421cf0b2badb82a4245c60745f05f424f75c649/fonttools-4.65.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b30e953de049bf43fc0a63c7d0c44d205c923e4bbf24716aae1518c0e65f977c", size = 2584658, upload-time = "2026-09-10T15:33:48.473Z" }, + { url = "https://files.pythonhosted.org/packages/75/6f/d589b9d62280a846c77a2c383d852c6dcb79ae8aa02bf0fa46c8577af145/fonttools-4.65.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09c34bdeed8915bfb53bee0c8ed2254dbd8ec69c0014b7f3702f347c049bf358", size = 5425737, upload-time = "2026-09-10T15:33:51.473Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/de2c0c20a42c18e565a2617932beb08c06697bbdd0d3f62b108262e11583/fonttools-4.65.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:05595385ae99f4b9626cebb973bf171b8fe38a8f40708e6e42abba0ed7537778", size = 5402463, upload-time = "2026-09-10T15:33:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/2e/bc0f5c9dce21821454bb5812d3b23410bca33c8bbd5468386d0327aa0cff/fonttools-4.65.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d95b34dd68fbfc0e4a1740c421597656117f979ed8dc85de66e08f9f9981806e", size = 5362168, upload-time = "2026-09-10T15:33:57.481Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0d/2116763ade7e71e0e5d421babe1785d745be9b3d605bf914792ce1c97f79/fonttools-4.65.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:924d06e6130429168318db71c40174a765ad016fc4b56ca811287e3d7373b3a6", size = 5524117, upload-time = "2026-09-10T15:34:00.021Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5e/f9600553b9f645e3068553831685ff1dab6259536a23b38d2e048de38f17/fonttools-4.65.0-cp312-cp312-win32.whl", hash = "sha256:04f73dd01005752a6e75cf4a8dc6b70dc724d1d4bc34cc89522153f4a2f07680", size = 2432089, upload-time = "2026-09-10T15:34:02.803Z" }, + { url = "https://files.pythonhosted.org/packages/3a/02/e436a6a1863b9862bab9f82d6da33055dd7aa3738017edd902a163525dc2/fonttools-4.65.0-cp312-cp312-win_amd64.whl", hash = "sha256:3b5d9ba89edf778b376e669b879ae33a198bf45cf5a23c3f6514f935cf9d0d9d", size = 2483475, upload-time = "2026-09-10T15:34:05.09Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/f894ceb867118c0261d0f69a9bd516b045a3754238f76c88a49513ac7a83/fonttools-4.65.0-py3-none-any.whl", hash = "sha256:3060b8c1fc2329fa20265b7c138614143ea7c1624e26c5c180c76aeb74deae6f", size = 1196441, upload-time = "2026-09-10T15:35:52.347Z" }, ] [[package]] @@ -478,7 +475,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.28.0" +version = "1.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -491,9 +488,9 @@ dependencies = [ { 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" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/f0/61159db90b5cd275d55516fe27920828e7d3be4053fdbdb27c3f70e5f1ef/huggingface_hub-1.31.0.tar.gz", hash = "sha256:f8e9e710a210613fa5d0f26bba6da05ef4aef9fba5a0f23f508f5ac4d08b6f90", size = 968039, upload-time = "2026-09-10T10:27:22.724Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/c3/7f/3f886a625043b77312b80da2f2bf00b5ecbf5a73061af1aa0259cd258c9d/huggingface_hub-1.31.0-py3-none-any.whl", hash = "sha256:9dbb6a503cbe2494ea666695207e7262d410659e09134059deb83e5480864667", size = 798313, upload-time = "2026-09-10T10:27:20.798Z" }, ] [[package]] @@ -546,29 +543,28 @@ wheels = [ [[package]] name = "kiwisolver" -version = "1.5.0" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/bd78e6a8fae171ea041ef5bba3ed21a003522fa088834b069b1909981f30/kiwisolver-1.5.1.tar.gz", hash = "sha256:f1303ef2eec81262a4b708c3e858afe58d7c75ad91c1c05266eda7673369859a", size = 104395, upload-time = "2026-08-28T10:28:27.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, - { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, - { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, - { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, - { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, - { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, - { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, - { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, - { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, - { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, - { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, - { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, - { 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" }, + { url = "https://files.pythonhosted.org/packages/6b/9b/65b302742389c6f96f2956bef5decf26011309feb2fc5d79613af18adea4/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63fb7294b768f444eb4b068965f2662f28c2fd4161e23bd60fcf3ff27b74c046", size = 123876, upload-time = "2026-08-28T10:25:27.44Z" }, + { url = "https://files.pythonhosted.org/packages/71/74/c21f339956f6f691b2ed7e31d5f3ae767304df6c460192739fc830853051/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ebdef3eae5336568147c39a55be6a2036ffde53faa9ca2d978989ae7c2da12c", size = 66487, upload-time = "2026-08-28T10:25:28.728Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/bdb34e21523e01dceda064d63713f3bdec91388af24fba1eca7ea5e85864/kiwisolver-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1798e83840c3f627246104c4d8a9639c60fa068adf9ce92b61791781fa8a68c1", size = 64660, upload-time = "2026-08-28T10:25:30.071Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f4/dadfec469313c7f428efa7e84b4aba9732f813c13ea7131a24b7b008ef57/kiwisolver-1.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34633ecf50d16187ab8e5528b7a2530f2feb4e23f300db4672538b51cfc5cd38", size = 1477929, upload-time = "2026-08-28T10:25:31.495Z" }, + { url = "https://files.pythonhosted.org/packages/6f/35/09c58daac34e6f6ea5c6dee0094b422118e5a7c265586008a95fd135ac5f/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27c2123977cb9269c30a49ba45f03a4323017ef693e19db4ec9dbe1299a3002", size = 1278499, upload-time = "2026-08-28T10:25:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/19/32/739765e24fbad29d13f83e546ea4abc215a78cea9d677ca09025b027724d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a797a1cefc8b9c93170db580337e1fe3d011ad18b1299943231279406342048", size = 1296677, upload-time = "2026-08-28T10:25:35.059Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/03304d1010e2cc45e5b3b52cef7e43fed3a2a5cd6c87a89b4a88e1d85b5d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2551cf9917af48ee7c4b29cc82320489508cf96fd26a51f6fc124de661cd44c7", size = 1346037, upload-time = "2026-08-28T10:25:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/3e/57/4c49377bfd274450dd72ecaa13eaac32ea804a03363e4d1db0c5aa999ceb/kiwisolver-1.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:38f6e0deb4d0a4615efe0c4efc5990b06ae450ab50a0b321c0b078b6d238c083", size = 988248, upload-time = "2026-08-28T10:25:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/38df144a08b6c5d75ca4504e5cc3141bb3bfef64c04f4ef48204f42711b6/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bfd1de989b3330420e29de39352f5c049905c9e3ee67233a50d550e3d652c148", size = 2228722, upload-time = "2026-08-28T10:25:40.038Z" }, + { url = "https://files.pythonhosted.org/packages/e7/11/3221838a89cd64d9b386353e000cd8a296069a20fbe3584507fdfd5bebae/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1209042a623ddfda5497e4066c7b77651dde8e1d3a9dd97599dc7e97f3b9b78c", size = 2325216, upload-time = "2026-08-28T10:25:41.699Z" }, + { url = "https://files.pythonhosted.org/packages/83/d4/075c219230697bb5db910d37262b9bacf880f92b4811a02ab81ed073a253/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:26e8268480be5061d509e29669d59103c067a26377a56491630ece11762e3858", size = 1977689, upload-time = "2026-08-28T10:25:43.559Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/1d219c3c2dd960983d0d4da623d916e9de6385df2b0bab3d1af0e9b8fccc/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d79308fa689fac89cbcfbd4dbfc80b5f95c54c5a7fd4d194be221f9d33d026e6", size = 2491443, upload-time = "2026-08-28T10:25:45.242Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d3/024208ec1079d273f1047468d1bdffbf38bb75b7b268090fd3a0301b9d9a/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b03af77d77e50edba2030fd5f7c352ff209314b09030a3cba7c14edf9a09a444", size = 2295200, upload-time = "2026-08-28T10:25:46.984Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/7b210498f9f92e1cd7855f260fa69ef056881087b199ee20c208f0e4189a/kiwisolver-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:06a6917674de9e0fe3f66f5430787f59a9f2ddb64af9b714eaec547e29ef5c19", size = 70748, upload-time = "2026-08-28T10:25:48.444Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/ef0daa157c8bb23672f7423e0d14c39db1dc6ef8ed47e6bc54c9c1bef3bf/kiwisolver-1.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:ad8b9671348d7c8716715652ae11f85ed0eb99e265a2df2ca490577d69860b2c", size = 68324, upload-time = "2026-08-28T10:25:49.81Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c4/1407df7512a5b36cc79840e01710dc575733c461b13ab866cae77eaf87f3/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:482676e5bd48d70ac99d9fc78863469845421e01184fa83f1f9366dc49f7e974", size = 134002, upload-time = "2026-08-28T10:28:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/16/45/c37a21ad5c0ab581a93c55ad544721aaa1f0ae94edb29c6a678a23d013e6/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:072bdb15a3c19a5b5dbc8f8fb1f4e1884bf4f3507eeb4cc6334401274d37a5c0", size = 194292, upload-time = "2026-08-28T10:28:11.06Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/69f00d627949580e43d57af0aa465df46868d7c29801c137a55374101294/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:a5a00665d1a0e26763a7338d7e911d4598fbc1d50dd0d6b7919b7dc6c5d6569f", size = 73362, upload-time = "2026-08-28T10:28:12.449Z" }, ] [[package]] @@ -643,7 +639,7 @@ wheels = [ [[package]] name = "matplotlib" -version = "3.11.1" +version = "3.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, @@ -656,15 +652,15 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/cb/ce/1bfcc4873b121597791ad74032943b123218c0613af0b97e8dd05e916fb1/matplotlib-3.11.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef752769cd962f39ea0b6ffc82d1ea43a0012c5a6157c7a075212fa509cfcff2", size = 9476466, upload-time = "2026-09-11T19:03:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c4/7f5f3601ee69baf072c0c7d3ce60c03e0618621c5a56c34a62a460e29d11/matplotlib-3.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef31985c4dedb5f1424e1aec6849a47dd37689cb7fa3c20b1b82187f26806261", size = 9305583, upload-time = "2026-09-11T19:03:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/b8/90/2b3fd67ee273163faeda6d514be70b5596eaa0fd77b60ffc294ad0b34f5f/matplotlib-3.11.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df4f7784aca81a94f254c0a2767d592ee25f407e488f5fa7203e51093fb6ca27", size = 9860353, upload-time = "2026-09-11T19:03:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/f4/84/32549e7a462dc311aed2ab62e5d2538028840b5c53a8be0195c789937c3d/matplotlib-3.11.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b9a7ad579856284135e401ecc918c5f8a017ee30539298862a109f51b971710", size = 10670348, upload-time = "2026-09-11T19:03:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/84/39/02e21b74f7439bd643d717ea846006d46e309e6d29b632b57751265311d6/matplotlib-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3aa4b8516fd26659e4363abbf317c703d9116496c5db2e9d0609a2866dd39dd2", size = 10810580, upload-time = "2026-09-11T19:03:31.402Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/0d239bde12b308262265c2d98909b2f0ecad2b5deeae96241642e822e9c7/matplotlib-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:c5c1c68ee401fc98271263410f0e5ce88285abacf7627132914e8adf3d70ff43", size = 9349409, upload-time = "2026-09-11T19:03:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/49/a8/06baf901c02246c8a222b655cc4540ef9c15e1549a04e07927f9cde716c3/matplotlib-3.11.2-cp312-cp312-win_arm64.whl", hash = "sha256:643ff850d8e0f5b8319337f87ed3cb59506afb3df3cc48de777d85871233be7b", size = 9028084, upload-time = "2026-09-11T19:03:37.032Z" }, ] [[package]] @@ -697,21 +693,21 @@ provides-extras = ["dev"] [[package]] name = "numpy" -version = "2.5.2" +version = "2.5.3" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, ] [[package]] @@ -774,7 +770,6 @@ dependencies = [ { name = "pyzmq" }, { name = "requests" }, { name = "scons" }, - { name = "sentry-sdk" }, { name = "setproctitle" }, { name = "sounddevice" }, { name = "tqdm" }, @@ -847,8 +842,7 @@ requires-dist = [ { name = "rednose", marker = "extra == 'submodules'", editable = "rednose_repo" }, { name = "requests" }, { name = "ruff", marker = "extra == 'testing'" }, - { name = "scons", specifier = "==4.10.1" }, - { name = "sentry-sdk" }, + { name = "scons" }, { name = "setproctitle" }, { name = "sounddevice" }, { name = "teleoprtc", marker = "extra == 'submodules'", editable = "teleoprtc_repo" }, @@ -886,17 +880,11 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "cffi", marker = "extra == 'dev'" }, - { name = "cppcheck", marker = "extra == 'dev'", git = "https://github.com/commaai/dependencies.git?subdirectory=cppcheck&rev=release-cppcheck" }, - { name = "flaky", marker = "extra == 'dev'" }, - { name = "gcc-arm-none-eabi", marker = "extra == 'dev'", git = "https://github.com/commaai/dependencies.git?subdirectory=gcc-arm-none-eabi&rev=release-gcc-arm-none-eabi" }, + { name = "comma-deps-cppcheck", marker = "extra == 'dev'" }, + { name = "comma-deps-gcc-arm-none-eabi", marker = "extra == 'dev'" }, { name = "libusb-package" }, { name = "libusb1" }, { name = "opendbc", git = "https://github.com/sunnypilot/opendbc.git?rev=master" }, - { name = "pytest", marker = "extra == 'dev'" }, - { name = "pytest-mock", marker = "extra == 'dev'" }, - { name = "pytest-timeout", marker = "extra == 'dev'" }, - { name = "pytest-xdist", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "scons", marker = "extra == 'dev'" }, { name = "setuptools", marker = "extra == 'dev'" }, @@ -980,11 +968,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, ] [package.optional-dependencies] @@ -994,15 +982,15 @@ crypto = [ [[package]] name = "pymdown-extensions" -version = "11.0.1" +version = "12.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/7e/53144b512818c75d459523e3a91d9bf7b65cd6a1dfb964fe690adc8b3670/pymdown_extensions-12.0.1.tar.gz", hash = "sha256:88fe2c97fb9153b9f2cc1da59f1d7c7cb74971110daf33cccb2a5514e0f7e970", size = 866904, upload-time = "2026-09-15T23:20:33.187Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/8c/30/c866b3ca8ad5cca79a3ee1a55c12d61d3e72381e5eadbfdaceb6006ebc84/pymdown_extensions-12.0.1-py3-none-any.whl", hash = "sha256:15d629e8035892b93d788a8424ccea754d4aa3cec247a64cbfe7b15089c9ad7f", size = 276957, upload-time = "2026-09-15T23:20:31.551Z" }, ] [[package]] @@ -1046,23 +1034,24 @@ wheels = [ [[package]] name = "pyzmq" -version = "27.1.0" +version = "27.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "implementation_name == 'pypy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/8d/5b3d5631c2f4b4b8862f64cd0c9eb777b5710eeb5125b4be8dd0a200a4c0/pyzmq-27.2.0.tar.gz", hash = "sha256:54d4259d1bfae24ecdb5ca79f7acc2eac6c286a02d6a0ae617797cb45f0726d3", size = 292316, upload-time = "2026-08-20T19:08:21.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, - { 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" }, + { url = "https://files.pythonhosted.org/packages/57/8a/153532fa53db30e116118164f3af269a1f3966b3e2ba32c89b12fe864bd8/pyzmq-27.2.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:591c8de5851c5ea372194469fe97587b97c3b641e9a70f31bb3474acbfde0241", size = 1431074, upload-time = "2026-08-20T19:06:40.601Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ef/c08b91248bb90a9efa81fa00ba81b69c157c74d0c5efbb2c319d91babb62/pyzmq-27.2.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:00e73942ef12cecbc7951c4a9104bb8ffaed742abb13af2da6833d90dd368cef", size = 973915, upload-time = "2026-08-20T19:06:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/b4/78/a3a3a86c2b00fadb92ece1ca4f8f028d62b2ce9ac3526097239ab2d6fba9/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8079d0521fe94bbb401fe9407578b28f3701627c8be2c9f7e0c5b77dcb0109", size = 697722, upload-time = "2026-08-20T19:06:43.325Z" }, + { url = "https://files.pythonhosted.org/packages/62/2c/d5828306f795e8d34676d266823b74e2101e0ad3760d12083de3e02abbb2/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dea74fd65f1fc5f7fe167916a473ebe6ed6174e5e5d9de11ea6583661be6cf43", size = 872258, upload-time = "2026-08-20T19:06:44.627Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/51253b78fd8739293e283407eeecb14215c02c71b6519af21f6eed8e69cd/pyzmq-27.2.0-cp312-abi3-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcc99ca132b667a4ed750afd42db4ea73288f18425a9b2e3c0af095665c491f5", size = 739591, upload-time = "2026-08-20T19:06:46.214Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3e/142c85b67a4c9678629b0cf6d5125b29663d75be69bfaa57a3cac344d780/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b8d5f66e4a8246cf77f7b8f7902af64f00553368fa0373c89d99b78f0ad79394", size = 1689031, upload-time = "2026-08-20T19:06:47.612Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ee/0776fb0f98ed1eb74d77240087fef0ab045b6ad15cb09555c6c5134c98ad/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:d1526b42a2e725b84ed226f37becedc250c6347594e5ed304e4e9aff68c9aec3", size = 2059547, upload-time = "2026-08-20T19:06:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/ec77f691a4aebe29ab6329f996fb0e0270c876a3016086e3ca6ef733bcae/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f707bcf2c1d007d14d70531d4dd7b41060881c73efa845580bf6faaf9ea24d42", size = 1910457, upload-time = "2026-08-20T19:06:50.783Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/1f5530ff4fc271b4597048371d5af972c2baab51be132ba15874e0327a6a/pyzmq-27.2.0-cp312-abi3-win32.whl", hash = "sha256:fdaaa4ea3242f6ad298eb5177eb042aea5c73c30e76d20caee7b15af20d24ec2", size = 563450, upload-time = "2026-08-20T19:06:52.307Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl", hash = "sha256:2c218c6ab8bc447ba62054b581fd30209689d199c6ecb253f79615ca74a38e12", size = 628633, upload-time = "2026-08-20T19:06:53.809Z" }, + { url = "https://files.pythonhosted.org/packages/52/aa/3918b5ac7f9987bd9c421b065074fd7409ded88f856f2c704a24341877ec/pyzmq-27.2.0-cp312-abi3-win_arm64.whl", hash = "sha256:348d6fd3e4b81ae4580622ea8c2ea60224e84b2ac1b3be4482e6edc7de06e7a3", size = 556006, upload-time = "2026-08-20T19:06:55.242Z" }, ] [[package]] @@ -1111,49 +1100,36 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.3" +version = "0.16.7" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, ] [[package]] name = "scons" -version = "4.10.1" +version = "4.11.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/b9/b7a5c88f348a0c34594d88100872c55fa1cae863ccb222c1c438341b5503/scons-4.11.1.tar.gz", hash = "sha256:4210d1a80a62e986029208117991b6347ccaaaab37b67463a3ff31ee065dc487", size = 3268963, upload-time = "2026-08-27T04:33:18.232Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, -] - -[[package]] -name = "sentry-sdk" -version = "2.68.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "urllib3" }, -] -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/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" }, + { url = "https://files.pythonhosted.org/packages/8e/43/d6285848e893c19682c06e92679dc1a07d37ff7ea148747b1df681ec496c/scons-4.11.1-py3-none-any.whl", hash = "sha256:454cef364348053422696e3d2ecb4fa593c96a624f955842eaaea64f95c8d11d", size = 4123659, upload-time = "2026-08-27T04:33:12.728Z" }, ] [[package]] @@ -1247,7 +1223,7 @@ provides-extras = ["dev"] [[package]] name = "tinygrad" -version = "0.13.0" +version = "0.14.0" source = { editable = "tinygrad_repo" } [package.metadata] @@ -1261,6 +1237,7 @@ requires-dist = [ { name = "hypothesis", marker = "extra == 'testing-minimal'", specifier = ">=6.148.9" }, { name = "influxdb3-python", marker = "extra == 'testing'" }, { name = "librosa", marker = "extra == 'testing'" }, + { name = "mako", marker = "extra == 'autogen'" }, { name = "markdown-callouts", marker = "extra == 'docs'" }, { name = "markdown-exec", extras = ["ansi"], marker = "extra == 'docs'" }, { name = "mkdocs", marker = "extra == 'docs'" }, @@ -1275,7 +1252,7 @@ requires-dist = [ { name = "numpy", marker = "extra == 'testing-minimal'" }, { name = "onnx", marker = "extra == 'testing'", specifier = "==1.19.0" }, { name = "onnx2torch", marker = "extra == 'testing'" }, - { name = "onnxruntime", marker = "extra == 'testing'" }, + { name = "onnxruntime", marker = "extra == 'testing'", specifier = "==1.24.1" }, { name = "openai", marker = "extra == 'testing-unit'" }, { name = "opencv-python", marker = "extra == 'testing'" }, { name = "pandas", marker = "extra == 'testing'" }, @@ -1286,6 +1263,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'testing-minimal'" }, { name = "pytest-split", marker = "extra == 'testing-minimal'" }, { name = "pytest-xdist", marker = "extra == 'testing-minimal'" }, + { name = "pyyaml", marker = "extra == 'autogen'" }, { name = "ruff", marker = "extra == 'linting'", specifier = "==0.14.10" }, { name = "safetensors", marker = "extra == 'testing-unit'" }, { name = "sentencepiece", marker = "extra == 'testing'" }, @@ -1301,7 +1279,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", "mesa"] +provides-extras = ["linting", "testing-minimal", "testing-unit", "testing", "docs", "mesa", "autogen"] [[package]] name = "tomli" @@ -1323,39 +1301,39 @@ wheels = [ [[package]] name = "tqdm" -version = "4.70.0" +version = "4.70.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, ] [[package]] name = "ty" -version = "0.0.73" +version = "0.0.80" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/54/b0/6d1b10e0d422736a3c439e487c950ae785f401d71ff879d5be68bccb6d90/ty-0.0.80.tar.gz", hash = "sha256:fe86bc91327e45ff5e3593b7e306e7f57a44bc0608f38d647b8d97bd99c96013", size = 7183998, upload-time = "2026-09-09T21:18:47.547Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/29/c8/3c93195eca282936ebb574d0ba98843e4b147ee324006f88464777012a92/ty-0.0.80-py3-none-linux_armv6l.whl", hash = "sha256:738d1cfca466c577aea24547c348629c00c63548cf7da2c46b497b3840f85dee", size = 13606509, upload-time = "2026-09-09T21:18:10.476Z" }, + { url = "https://files.pythonhosted.org/packages/e7/48/bbb47f7001c97262a5109bcd92a45bcc8a2fbb21e994c214a9897630bfb7/ty-0.0.80-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:56060164bb8ee43770fa367524fbdba2611fc90f8923a02cc91f80eb37d5a1b0", size = 13203812, upload-time = "2026-09-09T21:18:12.796Z" }, + { url = "https://files.pythonhosted.org/packages/db/8d/fd141160567047b742e466be8ed09a4c4ed80fa045d37f946eb4f4532fee/ty-0.0.80-py3-none-macosx_11_0_arm64.whl", hash = "sha256:da4062e0fbf3923d9b71688157c5753394f243348f00732e9edbb27498397169", size = 13021896, upload-time = "2026-09-09T21:18:15.186Z" }, + { url = "https://files.pythonhosted.org/packages/1e/10/b4177faf9e71bc37bc4d08f512042269660a1ce0ec457c48f96e51fc91d7/ty-0.0.80-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9eb94b2659f506a3a07a9ea1415d5c18aaa1e554adc1612614d617a0ed320e1", size = 13083881, upload-time = "2026-09-09T21:18:17.616Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c2/192d48b9a9acbbe030fc9a4bd73af75671a5566e731949f77e912a68ce68/ty-0.0.80-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2f5e39a87da48c1af1a3b3135f02be7a83a93da30b0d6a5f5d27806e7c747ce", size = 13354874, upload-time = "2026-09-09T21:18:19.744Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/736dfd31efd98bed9dbe018ff91676e5ea83485b1e24afb635fd0247728f/ty-0.0.80-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e00149e7779c6b98f3ba12e10a631a861bd7ca1a42bbf064e2677ada7d2c0c2", size = 14204805, upload-time = "2026-09-09T21:18:21.999Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6f/557c726cb53401998e3589bb632c963a6887a70240e0a4386024f3731c81/ty-0.0.80-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a377268f359fb6e7a2cc9026a09223c764f58d020dec8e6baeaba9caba6ff829", size = 14630014, upload-time = "2026-09-09T21:18:24Z" }, + { url = "https://files.pythonhosted.org/packages/e9/19/7a4b18fe27f6b6bd4ffa56b67c8b09ecb76bb4312d1c64b73f65417b4dbd/ty-0.0.80-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4511dbf1b266b9ce5b73e9b28d94468096c2b6da00e2fe205168c32d3b352ca7", size = 14326946, upload-time = "2026-09-09T21:18:26.679Z" }, + { url = "https://files.pythonhosted.org/packages/f8/95/16dd90805fc7e53ec54ae9fdb1a8b74753fb159a55a170f60f49b9417824/ty-0.0.80-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe95feffa7156800c6f804195acb9fb5846a39671c7192c30fff23351aaf7c31", size = 13705988, upload-time = "2026-09-09T21:18:28.681Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/4e87992c23ab8a742c7d4914ac5fe66fb9301c8464a69fd2ecbc0be3fbcd/ty-0.0.80-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba26b39f06bc8c3c2c5acd3a147239482b36620cdbca1b9d02e915294854f2cc", size = 14233729, upload-time = "2026-09-09T21:18:30.961Z" }, + { url = "https://files.pythonhosted.org/packages/36/e9/b8c11fda8e66a1d1cc1a7160ed719a33f4dc0107b1cef6a14e2673965763/ty-0.0.80-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2e6167320888c115a6fbe69893b63fd1c848f9121a454e75e5f612674528e3f3", size = 13173523, upload-time = "2026-09-09T21:18:33.266Z" }, + { url = "https://files.pythonhosted.org/packages/73/a7/512083fa540c5be1ac8642658f03bfb5bfa6fd168ea4f55e3c0aabe04166/ty-0.0.80-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7aecb62b4de70b479eab07d1779ea66da26dd8b068f50a9330867157312f103c", size = 13373014, upload-time = "2026-09-09T21:18:35.339Z" }, + { url = "https://files.pythonhosted.org/packages/5e/63/cd2f0ca81fd9b8cafef93bbcf022bf636bc4de8ee7465c6aafeec1d4d52b/ty-0.0.80-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4fed06adacd16b7e2d722f37449021b1419598d0440769701c0f4827faddde63", size = 13667827, upload-time = "2026-09-09T21:18:37.241Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5d/ebdfdcb2dc099ae74dc4b75511eef0dd398b2fa27006c543543974b4567f/ty-0.0.80-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:09329af6303ce611ec2cfffc995dc1ddf76c9f47194fc1a4d64a984759c25f5b", size = 13963866, upload-time = "2026-09-09T21:18:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/c286d5e1b560cfd16d6e91fdab683718aacf09fc88938ca06dcc9d1c8502/ty-0.0.80-py3-none-win32.whl", hash = "sha256:a81b3b512f7b4c68e42fbd1835633de658809666e9aafd5defa52bbc5197698d", size = 12881806, upload-time = "2026-09-09T21:18:41.507Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c8/3b3a8ac16d47a23ff34bf57c0d99a491860748aea96e0e886b4bfed54f9b/ty-0.0.80-py3-none-win_amd64.whl", hash = "sha256:8043f99878a2ae434781cb881c8a3840dff70c4a5b60bdc5ae84251f35ee063f", size = 13528883, upload-time = "2026-09-09T21:18:43.687Z" }, + { url = "https://files.pythonhosted.org/packages/2e/71/a6c697930fca76596d7d17f8853120f41a298fd9ea632c901cfca8ca869b/ty-0.0.80-py3-none-win_arm64.whl", hash = "sha256:e277ef034331da5319efc839c064968d24f35715b71c70369cf97d36fe4dcbdb", size = 13370135, upload-time = "2026-09-09T21:18:45.758Z" }, ] [[package]] @@ -1378,16 +1356,16 @@ wheels = [ [[package]] name = "websocket-client" -version = "1.9.0" +version = "1.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, ] [[package]] name = "zensical" -version = "0.0.56" +version = "0.0.62" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1399,20 +1377,17 @@ dependencies = [ { name = "pyyaml" }, { name = "tomli" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/df/c1/d97c5b09e193dadeea02c93c0d627b7659617a0c274bcd29803f116761fc/zensical-0.0.62.tar.gz", hash = "sha256:2449135eac161eb053bf1138df994b18794e7ab420f73941a65ba66e43b37613", size = 4128550, upload-time = "2026-09-13T17:16:35.632Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/e2/a6/7e3fda49749096cf405e09c4384e348c0f6bfeab44f18d02c1051673dc2e/zensical-0.0.62-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a1efd661fa3241c10c94c8c2b280ac3924bc988b24d0dbf1b8499994b23e12fb", size = 14220149, upload-time = "2026-09-13T17:16:08.144Z" }, + { url = "https://files.pythonhosted.org/packages/fa/13/c8d22c4c946bf4e6ae29db81f1456fbbc35fcd165ad874e4b919422f7b08/zensical-0.0.62-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c468e81adad37517c03de2dda13568c33d9c36b234c34e2b3a04285846d99bd5", size = 13963505, upload-time = "2026-09-13T17:16:11.529Z" }, + { url = "https://files.pythonhosted.org/packages/19/08/cce44a83dbc1070197e2006178bbf42d59e398ccb39806d3db02c9781790/zensical-0.0.62-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dcc744b1b3f56b85cf23c393a2333dec72e555b263a2fbe3d50ac896cf85468", size = 14238419, upload-time = "2026-09-13T17:16:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/af/65/586ab984b9197ab6a7ee53dffa77ef00fdb19f8c09d520c39e3c3d7dccb8/zensical-0.0.62-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b04147ac1f109ed3b1fbc74d958323243f35481926765b939638e8933a8bdc6", size = 14262571, upload-time = "2026-09-13T17:16:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/3a4aac8181caa1079c93ac996bbc418b749ae76ecd2cf2a67943b90ae277/zensical-0.0.62-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6755d7a2d3de424e07a77f68be080b4e54a34fb564c39434322e8427b9402ace", size = 14518577, upload-time = "2026-09-13T17:16:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/5c/03/8de2cc9aecc13b93d4b63c06dec4af95482ab9ce1b586ad8d35d5f56a916/zensical-0.0.62-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2b809aa8aa175f64a362d6a8ab89586ce7dd086b5bf23778f3c3cff68deeae6", size = 14413777, upload-time = "2026-09-13T17:16:23.641Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/c7ac1e27d2832f8ee524397f47193b73a1d7d78b78e407febd5f97d72e89/zensical-0.0.62-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:111cb32c405ff3a0e162860f81cf8e041447d795657720c36ba3e1b8ed607928", size = 14747349, upload-time = "2026-09-13T17:16:26.498Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/e933a718b52161e97e7f477e189374e375be940f6ed4ea6711e9f80f8531/zensical-0.0.62-cp310-abi3-win_amd64.whl", hash = "sha256:7c216f1e33b369aa84826e6e52ecda06059c651793936ec3d64c8b4255a91dcc", size = 14531794, upload-time = "2026-09-13T17:16:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/b4/af/0ad32fb45b1eb9b8bb10ec7ef08b1982ab8f7a5c2837cbf1d3bd2df721ad/zensical-0.0.62-cp310-abi3-win_arm64.whl", hash = "sha256:8d4e48fc44faf0b4b77e880e3ad3ec2573644e2ddd59e18c71c829412733495d", size = 14240254, upload-time = "2026-09-13T17:16:32.675Z" }, ] [[package]]