diff --git a/.gitattributes b/.gitattributes index afdc8192f3..300ca01979 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,7 +3,7 @@ # to move existing files into LFS: # git add --renormalize . *.onnx filter=lfs diff=lfs merge=lfs -text -openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl 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 diff --git a/.github/workflows/build-default-models.yaml b/.github/workflows/build-default-models.yaml index 8ac48c4021..01ebf0c977 100644 --- a/.github/workflows/build-default-models.yaml +++ b/.github/workflows/build-default-models.yaml @@ -47,6 +47,7 @@ jobs: if [ "${{ inputs.target }}" = "big" ]; then NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)") 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" @@ -114,21 +115,7 @@ jobs: env ${TG_FLAGS} python3 ${{ github.workspace }}/${{ env.COMPILER }}/compile_onnx.py \ ${{ github.workspace }}/${{ env.SMALL_ONNX }} \ ${{ github.workspace }}/${{ env.SMALL_PKL }} \ - --device-input "*" --out-of-band --benchmark-runs 1 - - - 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') - " + --out-of-band --benchmark-runs 1 - name: Prepare output env: @@ -141,8 +128,7 @@ 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) @@ -185,7 +171,6 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive - lfs: true - name: Pull big PKL via LFS run: git lfs pull -I "${{ env.BIG_PKL }}" @@ -221,19 +206,33 @@ jobs: raise RuntimeError('Chestnut PCIe link not ready after 10 attempts') " - - name: Chunk big pkl + - name: Compile big warp 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') - " + 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]}')") + + 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: @@ -246,10 +245,9 @@ 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=$(sha256sum "$MODELS_DIR/${PKL_BASE}" | cut -d' ' -f1) + 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" \ @@ -258,6 +256,8 @@ jobs: --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" - name: Upload big model artifact @@ -335,6 +335,8 @@ jobs: --hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \ --artifact-name "$ARTIFACT_NAME" \ --model-dir output \ + --onnx-path "${{ needs.resolve.outputs.onnx_path }}" \ + --onnx-ref "${{ needs.resolve.outputs.onnx_ref }}" \ --model-name "${{ needs.resolve.outputs.model_name }}" \ --tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \ --run-number "${{ github.run_number }}" @@ -358,24 +360,14 @@ 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': [{ @@ -389,8 +381,7 @@ jobs: 'type': 'chunked', 'artifact': { 'file_name': pkl_name, - 'download_uri': {'url': '', 'sha256': digest.hexdigest()}, - 'chunks': chunks + 'download_uri': {'url': '', 'sha256': digest.hexdigest()} } }] }] @@ -449,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: | @@ -499,8 +477,7 @@ jobs: - 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 @@ -514,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 4badd9a224..077077a714 100644 --- a/.github/workflows/build-single-tinygrad-model.yaml +++ b/.github/workflows/build-single-tinygrad-model.yaml @@ -137,6 +137,7 @@ jobs: needs: build_model runs-on: ubuntu-latest permissions: + actions: read id-token: write contents: write steps: @@ -179,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: diff --git a/.github/workflows/compile-warp-kernels.yaml b/.github/workflows/compile-warp-kernels.yaml index c61a81803a..89205b622f 100644 --- a/.github/workflows/compile-warp-kernels.yaml +++ b/.github/workflows/compile-warp-kernels.yaml @@ -2,8 +2,6 @@ name: Compile warp for sunnypilot modeld on: workflow_dispatch: - schedule: - - cron: '0 0 * * 0' jobs: compile_warps: @@ -43,13 +41,13 @@ jobs: 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 tinygrad_repo/examples/openpilot/compile_warp.py \ - --frame ${FRAME_PARAM} --warp-to ${MODEL_SIZE} --layout yuv420 --frames 2 --output "${WARP_PKL}" + 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 DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32 python3 tinygrad_repo/examples/openpilot/compile_warp.py \ - --frame ${FRAME_PARAM} --warp-to ${MODEL_SIZE} --layout yuv420 --frames 2 --output "${BIG_WARP_PKL}" + 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 @@ -60,6 +58,11 @@ jobs: - 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 }} @@ -71,3 +74,7 @@ jobs: 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/download-hf-model-chunks/action.yml b/.github/workflows/download-hf-model/action.yml similarity index 72% rename from .github/workflows/download-hf-model-chunks/action.yml rename to .github/workflows/download-hf-model/action.yml index 83c0dcd133..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: @@ -19,7 +19,7 @@ inputs: runs: using: composite steps: - - name: Download model chunks + - name: Download models shell: bash env: HF_REPO: ${{ inputs.hf_repo }} @@ -33,7 +33,7 @@ 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") if [ -n "$TINYGRAD_REF" ]; then BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" --arg tg "$TINYGRAD_REF" ' @@ -51,26 +51,23 @@ runs: 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 } @@ -82,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 3236d90e13..4c98c24722 100644 --- a/.github/workflows/lfs-maintenance.yaml +++ b/.github/workflows/lfs-maintenance.yaml @@ -33,7 +33,7 @@ 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: | @@ -68,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 @@ -81,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/sunnypilot-build-model.yaml b/.github/workflows/sunnypilot-build-model.yaml index 199c63f7ae..8276c40136 100644 --- a/.github/workflows/sunnypilot-build-model.yaml +++ b/.github/workflows/sunnypilot-build-model.yaml @@ -102,56 +102,39 @@ jobs: cat $GITHUB_OUTPUT - name: Fetch Models - env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} 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 - ONNX_COUNT=$(find selfdrive/modeld/models -maxdepth 1 -type f -name "*.onnx" ! -name "big_*.onnx" ! -name "dmonitoring_*.onnx" 2>/dev/null | wc -l) + 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 - ONNX_COUNT=$(find selfdrive/modeld/models -maxdepth 1 -type f -name "big_*.onnx" 2>/dev/null | wc -l) - fi - if [ "$ONNX_COUNT" -eq 0 ]; then - echo "Detected NEW stock architecture (no ONNX files tracked). Fetching from HF and skipping LFS pull." - COMMIT_MSG=$(git log -1 --pretty=%B) - HF_HASH=$(echo "$COMMIT_MSG" | grep -oE '[a-f0-9]{8}' | head -n 1) - - if [ -n "$HF_HASH" ]; then - echo "Downloading ONNX for hash $HF_HASH from HuggingFace." - pip install huggingface_hub - rm -f selfdrive/modeld/models/*.onnx - python3 - </dev/null; then @@ -162,10 +145,11 @@ jobs: uses: actions/upload-artifact@v4 with: name: models-${{ env.REF }}${{ inputs.artifact_suffix }} + 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/new_arch.txt + ${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/upstream_pkl.txt if-no-files-found: error build_model: @@ -224,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 FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32" + 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" @@ -238,20 +222,20 @@ jobs: NATIVE_PKL=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "*.pkl" -print -quit) - if [ -f "${{ env.MODELS_DIR }}/new_arch.txt" ]; then - echo "Detected new arch" - DOWNLOADED_ONNX=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "*driving*.onnx" -print -quit) - if [ -n "$DOWNLOADED_ONNX" ]; then - echo "Compiling ONNX: $DOWNLOADED_ONNX" - env ${TG_FLAGS} taskset -c 7 python3 openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py \ - "$DOWNLOADED_ONNX" "$OUTPUT_PKL" --device-input "*" --out-of-band --benchmark-runs 1 - - echo "Chunking pkl" - python3 -c "from openpilot.common.file_chunker import chunk_file, get_chunk_targets; import os; p='$OUTPUT_PKL'; chunk_file(p, get_chunk_targets(p, os.path.getsize(p)))" - else - echo "Failed to find downloaded ONNX." - exit 1 - fi + 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 @@ -317,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 \ @@ -329,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 @@ -340,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 7fdea327b9..29e03a8437 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -503,8 +503,8 @@ 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 @@ -573,8 +573,8 @@ 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 @@ -594,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 769fca1051..2345385fca 100644 --- a/.github/workflows/test_models.yml +++ b/.github/workflows/test_models.yml @@ -47,38 +47,22 @@ 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"] - hf_token = os.environ.get("HF_TOKEN", "") - 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") - if hf_token: - f.write(f"header = \"Authorization: Bearer {hf_token}\"\n") - ' - curl -Z --parallel-immediate --parallel-max 12 --retry 3 --retry-all-errors -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" + 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 diff --git a/Jenkinsfile b/Jenkinsfile index 67039d0927..5facce903f 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -161,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" @@ -171,6 +179,36 @@ node { env.GIT_BRANCH = checkout(scm).GIT_BRANCH env.GIT_COMMIT = checkout(scm).GIT_COMMIT + 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('\\*', '.*') @@ -204,9 +242,18 @@ node { } if (env.BRANCH_NAME == '__nightly-chestnut') { - deviceStage("build nightly-chestnut", "mici-chestnut-ci", [], [ - step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 PANDA_DEBUG_BUILD=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), - ]) + 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)) { @@ -261,12 +308,7 @@ node { ]) }, 'chestnut': { - deviceStage("chestnut", "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]), - step("test power draw", "./openpilot/selfdrive/test/test_power_draw.py"), - ]) + 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 2437f99d95..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', @@ -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,13 +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 str(t).endswith('.pkl'): # chunked during release packaging - continue - 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/launch_chffrplus.sh b/launch_chffrplus.sh index 240bc3b648..806e41d4c1 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -56,6 +56,8 @@ function launch { 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 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 b2acec1dc7..5be7507143 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit b2acec1dc7f15cfb258aa48415168485e29cc4ff +Subproject commit 5be750714349624462da012f2f86ea9b686d8b27 diff --git a/openpilot/cereal/custom.capnp b/openpilot/cereal/custom.capnp index cfbbbe472a..b8c833ed80 100644 --- a/openpilot/cereal/custom.capnp +++ b/openpilot/cereal/custom.capnp @@ -196,7 +196,6 @@ struct ModelManagerSP @0xaedffd8f31e7b55d { } struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 { - dec @0 :DynamicExperimentalControl; longitudinalPlanSource @1 :LongitudinalPlanSource; smartCruiseControl @2 :SmartCruiseControl; speedLimit @3 :SpeedLimit; @@ -205,7 +204,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 +345,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 10d5a925d8..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 { @@ -2705,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 78c7bde182..541a06298a 100755 --- a/openpilot/cereal/services.py +++ b/openpilot/cereal/services.py @@ -52,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 472b523e2c..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,7 @@ 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: diff --git a/openpilot/common/file_chunker.py b/openpilot/common/file_chunker.py deleted file mode 100755 index 28a7e1212e..0000000000 --- a/openpilot/common/file_chunker.py +++ /dev/null @@ -1,75 +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}" - Path(manifest_path).unlink(missing_ok=True) - 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) - -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(path): - paths = [path] - elif 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)] - 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/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/power_monitor.py b/openpilot/common/hardware/comma/power_monitor.py index 9ae2c9e138..296290dae8 100755 --- a/openpilot/common/hardware/comma/power_monitor.py +++ b/openpilot/common/hardware/comma/power_monitor.py @@ -9,25 +9,22 @@ from openpilot.common.realtime import Ratekeeper from openpilot.common.filter_simple import FirstOrderFilter -def read_power(panda=None): - if panda is not None and panda.get_type() == panda.HW_TYPE_CUATRO: - health = panda.health() - return health['voltage'] * health['current'] / 1e6 +def read_power(): with open("/sys/bus/i2c/devices/0-0040/hwmon/hwmon1/power1_input") as f: return int(f.read()) / 1e6 -def sample_power(seconds=5, panda=None) -> list[float]: +def sample_power(seconds=5) -> list[float]: rate = 123 rk = Ratekeeper(rate, print_delay_threshold=None) pwrs = [] for _ in range(rate*seconds): - pwrs.append(read_power(panda)) + pwrs.append(read_power()) rk.keep_time() return pwrs -def get_power(seconds=5, panda=None): - pwrs = sample_power(seconds, panda) +def get_power(seconds=5): + pwrs = sample_power(seconds) return np.mean(pwrs) def wait_for_power(min_pwr, max_pwr, min_secs_in_range, timeout): diff --git a/openpilot/common/hardware/comma/updater b/openpilot/common/hardware/comma/updater index 35227d2d79..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:6a7adb302d378dda7b1788a841b89e4f905c872550a70a76650dcde977b4ece0 -size 24709209 +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 8aa09f3b27..7de4780c1b 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -40,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}}, @@ -69,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}}, @@ -139,6 +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}}, + {"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 --- // @@ -254,7 +261,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/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/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/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/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 ddfe383d6e..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,7 +178,7 @@ 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) @@ -214,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) @@ -299,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/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 2aac90fe63..aec903235f 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -1,14 +1,20 @@ +import ctypes.util import glob import os -import time from SCons.Script import Action, Value 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.helpers import chestnut_present from openpilot.system.camerad.cameras.nv12_info import get_nv12_info Import('env', 'arch') + +# 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) @@ -24,64 +30,40 @@ else: # 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' -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_MIN_GLOBALS=32' - # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it - chestnut_lock = File("models/.chestnut.lock").abspath - # tinygrad calls brew which needs a $HOME in the env mac_brew_string = f'HOME={os.path.expanduser("~")}' if arch == 'Darwin' else '' warp_deps = [File("#openpilot/system/camerad/cameras/nv12_info.py")] compiler = Dir('#tinygrad_repo/examples/openpilot').abspath -# CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. -taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' - -def chestnut_action(command): - def do_compile(target, source, env): - 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 warp build") - return - return env.Execute(command) - return Action(do_compile, " [CHESTNUT] $TARGET") +# build on all cores +taskset = 'taskset -c 0-7 ' if arch == 'comma_arm64' else '' 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}" --device-input "*" --out-of-band --benchmark-runs 1') + 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"), ) -compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl') -compile_model('models/driving_supercombo.onnx', 'models/driving_tinygrad.pkl') +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 chestnut in [False, True] if CHESTNUT else [False]: - file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) - for cam_w, cam_h in camera_configs: - warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath - stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) - cmd = (f'{cmd_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}') - action = chestnut_action(cmd) if chestnut else cmd - node = lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], action) - if chestnut: - lenv.SideEffect(chestnut_lock, node) +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 camera_configs: +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" ' diff --git a/openpilot/selfdrive/modeld/dmonitoringmodeld.py b/openpilot/selfdrive/modeld/dmonitoringmodeld.py index 06d0d2e5e7..5a6e9216ad 100755 --- a/openpilot/selfdrive/modeld/dmonitoringmodeld.py +++ b/openpilot/selfdrive/modeld/dmonitoringmodeld.py @@ -16,7 +16,6 @@ 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 SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') @@ -28,7 +27,7 @@ class ModelState: output: np.ndarray def __init__(self, cam_w: int, cam_h: int): - jits = load_oob(open_file_chunked(MODEL_PKL_PATH)) + 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'])) diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 5e7f413c26..9ac772e697 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -1,9 +1,8 @@ -import io -import pickle -import struct +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' @@ -13,15 +12,12 @@ def modeld_pkl_path(chestnut: bool): prefix = 'big_' if chestnut else '' return MODELS_DIR / f'{prefix}driving_tinygrad.pkl' -def load_oob(f): - opcodes = f.read(struct.unpack(' bool: for d in USB_DEVICES_PATH.glob("*"): @@ -34,7 +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: path = modeld_pkl_path(chestnut=True) - return (path.is_file() or Path(get_manifest_path(path)).is_file()) and all( + 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 57e19caacc..7e8c705abc 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -2,11 +2,15 @@ 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 +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 @@ -25,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 @@ -34,9 +39,8 @@ 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.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState -from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_present, chestnut_compiled, 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 @@ -134,8 +138,8 @@ class FrameMeta: def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int) -> Tensor: - view = buffer.view(math.prod(shape), dtype, offset).ensure_allocated() - return Tensor(UOp.from_buffer(view)).reshape(shape) + view = buffer.view(math.prod(shape) * dtype.itemsize, offset).ensure_allocated() + return Tensor(UOp.from_buffer(view, dtype)).reshape(shape) class ModelState(ModelStateBase): @@ -143,7 +147,7 @@ class ModelState(ModelStateBase): def __init__(self, cam_w: int, cam_h: int, chestnut: bool): ModelStateBase.__init__(self) - jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut))) + 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']} @@ -158,7 +162,9 @@ class ModelState(ModelStateBase): 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] @@ -203,6 +209,8 @@ class ModelState(ModelStateBase): 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 = self.outputs['outputs'].numpy()[0] @@ -228,14 +236,15 @@ class ModelState(ModelStateBase): def main(demo=False): cloudlog.warning("modeld init") - CHESTNUT = chestnut_present() and chestnut_compiled() + 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) params.remove("ChestnutActive") - config_realtime_process(7, 54) + gc.disable() # visionipc clients while True: @@ -268,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 @@ -277,7 +287,13 @@ def main(demo=False): loader.start() loader.join(BIG_MODEL_TIMEOUT) model = big_model - params.put_bool("ChestnutActive", model is not None) + 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: @@ -286,10 +302,13 @@ def main(demo=False): 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"] + (["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() @@ -407,7 +426,7 @@ def main(demo=False): 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") @@ -427,6 +446,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 = 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, diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl index 9a99ee2633..afa4866d75 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76cc0a9bc3af7a318889483dcbe126337f8d338f5abcbe664a8988c9b18b6639 -size 776634338 +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/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/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 d89762ba9b..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); 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 2354717a5c..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: @@ -506,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) @@ -577,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] @@ -599,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 @@ -666,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/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 fb46e83ef0..2ebb6f086f 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -80,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): @@ -104,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) @@ -125,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 = [] @@ -150,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: @@ -289,8 +292,7 @@ if __name__ == "__main__": diff_short, diff_long, failed = format_diff(results, log_paths, 'master') if "CI" in os.environ: - if not CHESTNUT: - comment_replay_report(log_msgs, cmp_log, log_msgs) + comment_replay_report(log_msgs, cmp_log, log_msgs) failed = False print(diff_long) print('-------------\n'*5) diff --git a/openpilot/selfdrive/test/process_replay/process_replay.py b/openpilot/selfdrive/test/process_replay/process_replay.py index 3bad4e00a6..af2190797a 100755 --- a/openpilot/selfdrive/test/process_replay/process_replay.py +++ b/openpilot/selfdrive/test/process_replay/process_replay.py @@ -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 @@ -484,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, @@ -555,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(), @@ -641,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/test_onroad.py b/openpilot/selfdrive/test/test_onroad.py index 0b64b3fb39..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 @@ -461,12 +461,14 @@ 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_camera_models(self, subtests): + 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'] @@ -480,9 +482,27 @@ class TestChestnutOnroad(OpenpilotTestCase): 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): @@ -495,8 +515,11 @@ class TestChestnutOnroad(OpenpilotTestCase): 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(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__": diff --git a/openpilot/selfdrive/test/test_power_draw.py b/openpilot/selfdrive/test/test_power_draw.py index d8b234fe60..1d0be24ef0 100755 --- a/openpilot/selfdrive/test/test_power_draw.py +++ b/openpilot/selfdrive/test/test_power_draw.py @@ -5,8 +5,6 @@ import time import unittest import numpy as np from dataclasses import dataclass -from panda import Panda -from openpilot.common.hardware import HARDWARE from openpilot.common.test import OpenpilotTestCase from openpilot.common.utils import tabulate @@ -16,14 +14,11 @@ from opendbc.car.car_helpers import get_demo_car_params from openpilot.common.mock import mock_messages from openpilot.common.params import Params from openpilot.common.hardware.comma.power_monitor import get_power -from openpilot.selfdrive.modeld.helpers import chestnut_present from openpilot.system.manager.process_config import managed_processes from openpilot.system.manager.manager import manager_cleanup SAMPLE_TIME = 2 # seconds to sample power MAX_WARMUP_TIME = 30 # seconds to wait for SAMPLE_TIME consecutive valid samples -MICI = HARDWARE.get_device_type() == "mici" -CHESTNUT = chestnut_present() @dataclass class Proc: @@ -38,10 +33,9 @@ class Proc: return '+'.join(self.procs) -# MICI readings exclude the separately powered Chestnut GPU. PROCS = [ - Proc(['camerad'], 0.85 if MICI else 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']), - Proc(['modeld'], 0.45 if MICI and CHESTNUT else 1.5, atol=0.2, msgs=['modelV2']), + Proc(['camerad'], 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']), + Proc(['modeld'], 1.5, atol=0.2, msgs=['modelV2']), Proc(['dmonitoringmodeld'], 0.65, atol=0.35, msgs=['driverStateV2']), Proc(['encoderd'], 0.23, msgs=[]), ] @@ -52,13 +46,6 @@ class TestPowerDraw(OpenpilotTestCase): def setup_method(self): Params().put("CarParams", get_demo_car_params().to_bytes(), block=True) - self.panda = None - if MICI: - HARDWARE.reset_internal_panda() - self.addCleanup(HARDWARE.reset_internal_panda) - Panda.wait_for_panda(None, 30) - self.panda = Panda(cli=False) - self.addCleanup(self.panda.close) def teardown_method(self): manager_cleanup() @@ -91,7 +78,7 @@ class TestPowerDraw(OpenpilotTestCase): start_time = time.monotonic() while (time.monotonic() - start_time) < MAX_WARMUP_TIME: - power = get_power(1, self.panda) + power = get_power(1) iteration_msg_counts = {} for msg,sock in socks.items(): iteration_msg_counts[msg] = len(messaging.drain_sock_raw(sock)) @@ -110,7 +97,7 @@ class TestPowerDraw(OpenpilotTestCase): @mock_messages(['deviceMotion']) def test_camera_procs(self, subtests): - baseline = get_power(panda=self.panda) + baseline = get_power() prev = baseline used = {} 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 1fa87e31df..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: @@ -109,5 +185,25 @@ class PrimeState: 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 419475a603..3e90a7d1a5 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -2,7 +2,6 @@ from __future__ import annotations import datetime import math -import time from openpilot.cereal import log import pyray as rl @@ -127,13 +126,8 @@ 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", (54, 40)) self._chestnut_loading_icon = IconWidget("icons_mici/chestnut.png", (68, 40)) @@ -146,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, @@ -162,23 +155,6 @@ 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, alert_icon_callback: Callable[[], rl.Texture | None] | None = None): @@ -188,15 +164,13 @@ class MiciHomeLayout(Widget): 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") @@ -249,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 dc294f3185..306feb28c5 100644 --- a/openpilot/selfdrive/ui/mici/layouts/main.py +++ b/openpilot/selfdrive/ui/mici/layouts/main.py @@ -73,6 +73,8 @@ 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), diff --git a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py index 9c90181469..5cdb0ebd7e 100644 --- a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -4,6 +4,7 @@ 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 @@ -232,6 +233,11 @@ class MiciOffroadAlerts(Scroller): 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 = [] diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py index 0b52e48bff..3c11219538 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py @@ -83,6 +83,8 @@ class DeveloperLayoutMici(NavScroller): 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)), description="Show touch locations and the UI frame rate.") @@ -96,6 +98,7 @@ class DeveloperLayoutMici(NavScroller): self._lat_maneuver_toggle, self._alpha_long_toggle, self._debug_mode_toggle, + self._navigation_toggle, ]) # Toggle lists @@ -107,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 @@ -180,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 73% rename from openpilot/selfdrive/ui/mici/layouts/settings/device.py rename to openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py index c038f5fddc..20b0523444 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py @@ -6,17 +6,16 @@ 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 @@ -91,70 +90,56 @@ class EngagedConfirmationButton(BigButton): 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) @@ -163,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) @@ -207,7 +194,7 @@ class DeviceLayoutMici(NavScroller): self._scroller.add_widgets([ DeviceInfoLayoutMici(), - PairBigButton(), + self._pairing_button, review_training_guide_btn, cabin_cam_btn, terms_btn, @@ -217,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/network_layout.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 4203336469..d38d640bdd 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -30,7 +30,7 @@ class NetworkLayoutMici(NavScroller): self._wifi_manager.set_tethering_active(checked) self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback, - description="Share the device’s internet connection through a Wi-Fi hotspot.") + description="Share the device's internet connection through a Wi-Fi hotspot.") def tethering_password_callback(password: str): if password: @@ -61,7 +61,7 @@ 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, - description="Metered prevents large uploads on this Wi-Fi connection. Default uses the network’s detected " + + description="Metered prevents large uploads on this Wi-Fi connection. Default uses the network's detected " + "setting.") self._network_metered_btn.set_enabled(False) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py index 477bd78935..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 +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,10 +47,28 @@ class SettingsLayout(NavScroller): self._scroller.add_widgets([ toggles_btn, network_btn, - device_btn, + self._device_button, software_btn, 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 15be56d36e..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): diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index 78f6085e87..406e0a77d1 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -1,59 +1,27 @@ -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._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._experimental_btn = BigToggle("experimental mode", description_icon=gui_app.texture("icons_mici/experimental_mode.png", 64, 64), - initial_state=ui_state.params.get_bool("ExperimentalMode"), toggle_callback=self._on_experimental_mode, - description="Let the driving model control gas and brakes.\n" + - "Includes stopping for red lights and stop signs.\n" + - "Set speed is a maximum, not a target.\n" + - "These are alpha features. Expect mistakes.\n" + - "The path colors show acceleration and braking.") - is_metric_toggle = BigParamControl("use metric units", "IsMetric") + 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.") @@ -72,7 +40,7 @@ class TogglesLayoutMici(NavScroller): self._scroller.add_widgets([ self._personality_toggle, - self._experimental_btn, + self._initial_cruise_speed, is_metric_toggle, ldw_toggle, always_on_dm_toggle, @@ -83,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), @@ -115,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 4f395ae9bc..c481a7707b 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -241,6 +241,9 @@ class BigButton(BaseButton): 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 @@ -255,7 +258,7 @@ class BigButton(BaseButton): 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: diff --git a/openpilot/selfdrive/ui/mici/widgets/dialog.py b/openpilot/selfdrive/ui/mici/widgets/dialog.py index d5da6178f1..80625aa451 100644 --- a/openpilot/selfdrive/ui/mici/widgets/dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/dialog.py @@ -183,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 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/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/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/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_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 2b7256aeb9..732c2d54a1 100755 --- a/openpilot/sunnypilot/modeld_v2/compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/compile_modeld.py @@ -7,14 +7,12 @@ See the LICENSE.md file in the root directory for more details. """ import argparse -import atexit import codecs import gc import math import multiprocessing as mp import os import pickle -import shutil import tempfile import time from functools import partial @@ -22,8 +20,8 @@ from typing import Any import numpy as np -from openpilot.common.file_chunker import chunk_file, get_chunk_targets, open_file_chunked -from openpilot.sunnypilot.modeld_v2.helpers import dump_oob, load_oob +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 @@ -36,34 +34,7 @@ from tinygrad.tensor import Tensor os.environ['GMMU'] = '0' -def _patch_tinygrad_fetch_fw(): - try: - 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 - except ImportError: - pass -_patch_tinygrad_fetch_fw() - -def _patch_ops_serialization(): - try: - from tinygrad.uop.ops import Ops - def __reduce_ex__(self, proto): - return (getattr, (self.__class__, self.name)) - Ops.__reduce_ex__ = __reduce_ex__ - except ImportError: - pass -_patch_ops_serialization() +patch_fetch_fw() MODEL_TYPES = ('vision_policy', 'supercombo', 'vision_multi_policy') WARP_INPUTS = ['tfm', 'big_tfm'] @@ -72,6 +43,7 @@ POLICY_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs' 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: @@ -150,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) @@ -185,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=device if os.getenv('CHESTNUT') else 'NPY').realize(), + 'packed_npy_inputs': Tensor(packed_npy_inputs, device=device).realize(), } if features_buffer: @@ -197,9 +173,11 @@ def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = D 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='NPY').realize() + 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, @@ -221,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): @@ -282,8 +260,19 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, return run_policy +class _CheckFrameRecorder: + def __init__(self, jit): + self.jit, self.frame = jit, None + + 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 + + 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, return_loaded: bool = True): + 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 @@ -334,22 +323,23 @@ def compile_jit(jit_or_fn, input_keys_or_make_inputs, make_queues=None, make_ran print('capture + replay') gc.collect() - test_val = run_eval(jit, 42, 3) - print(f'pickle round trip ({benchmark_runs} runs per seed)') - with tempfile.TemporaryFile(dir=".") as f: - dump_oob(jit, f) - if clear_refs: - clear_refs() - gc.collect() - f.seek(0) - loaded_jit = load_oob(f) + 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 loaded_jit if return_loaded else jit + return part def _parse_size(size_str: str) -> tuple[int, int]: @@ -357,16 +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 - 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)]: @@ -387,10 +367,7 @@ def _compile_warp_resolution_worker(cam_w, cam_h, model_w, model_h, benchmark_ru 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) - - result_data = (compiled_jit, Device.DEFAULT) - with open(result_path, "wb") as f: - dump_oob(result_data, f) + write_bundle(result_path, {(cam_w, cam_h): compiled_jit}, {'device': Device.DEFAULT}) if __name__ == "__main__": @@ -421,12 +398,6 @@ 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 @@ -449,8 +420,10 @@ if __name__ == "__main__": 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) - compiled_jit = compile_jit(run_model_jit, stock.MODELD_INPUTS, make_model_queues, benchmark_runs=args.benchmark_runs, return_loaded=False) - output_data[(cam_w, cam_h)] = compiled_jit + 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() @@ -518,18 +491,12 @@ if __name__ == "__main__": tmp_files.append((cam_w, cam_h, tmp_res.name)) for cam_w, cam_h, tmp_name in tmp_files: - with open(tmp_name, "rb") as f: - compiled_jit, dev_name = load_oob(f) - output_data[(cam_w, cam_h)] = compiled_jit - output_data['input_devices']['warp'] = dev_name + 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 index a4bfcce313..44ff5fed67 100755 --- a/openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py +++ b/openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py @@ -1,11 +1,59 @@ #!/usr/bin/env python3 -import runpy +import argparse import os +import sys -from openpilot.sunnypilot.modeld_v2.compile_modeld import _patch_ops_serialization +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__": - _patch_ops_serialization() - upstream_script = os.path.join(os.environ.get("TINYGRAD_PATH", "tinygrad_repo"), "examples/openpilot/compile_onnx.py") - runpy.run_path(upstream_script, run_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 _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) + 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(u.tag): u for u in topo if u.op is Ops.ALLOC and u.tag 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/helpers.py b/openpilot/sunnypilot/modeld_v2/helpers.py index 03012be3a9..f45584a611 100644 --- a/openpilot/sunnypilot/modeld_v2/helpers.py +++ b/openpilot/sunnypilot/modeld_v2/helpers.py @@ -5,14 +5,25 @@ This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ +import 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 tempfile -import shutil -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 _patch_system_flock_acquire(): @@ -32,86 +43,289 @@ def _patch_system_flock_acquire(): pass _patch_system_flock_acquire() -def _pad_args(func, args, kwargs): + +def patch_fetch_fw() -> None: 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:] - - 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 + 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 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): - 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 dump_oob(obj, f): - with tempfile.TemporaryFile(dir=".") as tmp: - def buffer_cb(buffer): - data = buffer.raw() - tmp.write(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 _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=dataclasses.replace(parameter.arg, name=name, image=image))) + info = ProgramInfo(global_size=tuple(call["global_size"]), local_size=tuple(call["local_size"]), vars=(), globals=tuple(call["globals"]), + outs=tuple(call["outs"]), ins=tuple(call["ins"]), target=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 index b240895180..5d1e68feef 100644 --- a/openpilot/sunnypilot/modeld_v2/model_adapters.py +++ b/openpilot/sunnypilot/modeld_v2/model_adapters.py @@ -11,20 +11,21 @@ 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, dtypes -from tinygrad.helpers import round_up +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, offset).ensure_allocated() - return Tensor(UOp.from_buffer(view)).reshape(shape) + view = buffer.view(math.prod(shape) * dtype.itemsize, offset).ensure_allocated() + return Tensor(UOp.from_buffer(view, dtype)).reshape(shape) class BaseModelAdapter: @@ -64,16 +65,10 @@ class BaseModelAdapter: self.warp_frame_size = self.nv12_info[3] return self.jits[(self.cam_w, self.cam_h)] - warp_dir = Path(BASEDIR) / "openpilot/sunnypilot/modeld_v2/models" warp_name = f'{"big_" if self.chestnut else ""}driving_warp_{self.cam_w}x{self.cam_h}_tinygrad.pkl' - with open(warp_dir / warp_name, 'rb') as f: - warp_data = pickle.load(f) - run_warp = warp_data['run'] - if 'input_specs' in warp_data and 'input_frame' in warp_data['input_specs']: - self.warp_frame_size = warp_data['input_specs']['input_frame'][0][1] - else: - self.warp_frame_size = self.nv12_info[3] if self.chestnut else self.frame_copy_size - return run_warp + 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): @@ -168,31 +163,22 @@ class NativeTinygradAdapter(BaseModelAdapter): stride, y_height, uv_height, _ = get_nv12_info(self.cam_w, self.cam_h) self.frame_copy_size = stride * (y_height + uv_height) - self.input_shapes_orig = self.jits['metadata']['input_shapes'] - self._vision_input_names = [k for k in self.input_shapes_orig if 'img' in k] + self._vision_input_names = ['img', 'big_img'] self.vision_output_slices = pickle.loads(codecs.decode(self.jits['metadata']['metadata']['output_slices'].encode(), 'base64')) - self.run_warp = self._load_warp() + self.warp_frame_size = self.frame_copy_size self._init_common() self.run_model = self.jits['run'] - warp_frame_size = getattr(self, 'warp_frame_size', self.nv12_info[3]) 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 * warp_frame_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.numpy_inputs = {} - offset = 0 - for name, shape in shapes.items(): - self.numpy_inputs[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.numpy_inputs[name].nbytes, 128) - self.frames = self.packed_input[npy_size:].reshape(2, warp_frame_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')} + 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(): @@ -211,9 +197,7 @@ class NativeTinygradAdapter(BaseModelAdapter): self.input_queues[key].assign(0).realize() def run(self): - 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) + self.run_model(host=self.host, **self.input_queues, output_buffers=self.outputs) return self.outputs['outputs'] diff --git a/openpilot/sunnypilot/modeld_v2/modeld.py b/openpilot/sunnypilot/modeld_v2/modeld.py index 2fd1baf999..dda0f834a4 100755 --- a/openpilot/sunnypilot/modeld_v2/modeld.py +++ b/openpilot/sunnypilot/modeld_v2/modeld.py @@ -7,8 +7,10 @@ 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 @@ -16,7 +18,7 @@ from setproctitle import setproctitle 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 @@ -24,9 +26,9 @@ 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 @@ -42,7 +44,8 @@ from openpilot.sunnypilot.modeld_v2.meta_helper import load_meta_constants from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper 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 @@ -52,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): @@ -108,7 +110,7 @@ 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' @@ -180,6 +182,8 @@ class ModelState(ModelStateBase): 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() @@ -254,16 +258,18 @@ def main(demo=False): 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) @@ -296,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 @@ -305,7 +312,13 @@ def main(demo=False): loader.start() loader.join(BIG_MODEL_TIMEOUT) model = big_model - params.put_bool("ChestnutActive", model is not None) + 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: @@ -314,10 +327,13 @@ 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"] + (["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 = ChestnutGpuState(pm, model.chestnut) if CHESTNUT else None @@ -445,7 +461,7 @@ def main(demo=False): 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("big model failed, fall back to small") params.put_bool("ChestnutActive", False) @@ -464,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, 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 index d942c7ca8d..39c5f9b2ba 100644 --- a/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1344x760_tinygrad.pkl +++ b/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1344x760_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ebac006f5e35ddb9c7da8ab38f9af87d6d0a87a3299b07cd118f88581f0f561f -size 872573 +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 index fdf52b15d8..cef1d05c92 100644 --- a/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1928x1208_tinygrad.pkl +++ b/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1928x1208_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d8eab79e53ebe1bbc3c633acc2a6bee80612cfe7fb5246afff6e0b7259c3a0d3 -size 872317 +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 index 7f59021ab0..cdda8e8f69 100644 --- a/openpilot/sunnypilot/modeld_v2/models/driving_warp_1344x760_tinygrad.pkl +++ b/openpilot/sunnypilot/modeld_v2/models/driving_warp_1344x760_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c48c9150afd01bd7071641415a248308c9a7b3cb47e7095862ef66c9f0429e43 -size 838183 +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 index b7c5251b48..565952f2ff 100644 --- a/openpilot/sunnypilot/modeld_v2/models/driving_warp_1928x1208_tinygrad.pkl +++ b/openpilot/sunnypilot/modeld_v2/models/driving_warp_1928x1208_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:59564a7d214fe13dbb30cdd068437c4bf35ec071789c922288046636f711cdfa -size 837991 +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..01dd292e29 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py @@ -0,0 +1,90 @@ +""" +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: (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 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} + 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": [[name, slot, repr(dtype), [int(x) for x in shape]] for name, slot, dtype, shape in elf.signature], + "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)]} + buffers = [{"nbytes": base.nbytes, "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/tests/helpers.py b/openpilot/sunnypilot/modeld_v2/tests/helpers.py index 2dde6c19c6..82a6180039 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/helpers.py +++ b/openpilot/sunnypilot/modeld_v2/tests/helpers.py @@ -188,10 +188,8 @@ def make_pkl_data(archetype): def write_pkl(tmp_path, archetype): - from openpilot.sunnypilot.modeld_v2.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 @@ -211,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 8b4c4e32a1..db0eaf8703 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py @@ -90,7 +90,6 @@ class TestStockEquivalence(OpenpilotTestCase): def test_unified_run_model(self, tmp_path, monkeypatch, patch_modeld): from openpilot.common.hardware import hw - from openpilot.sunnypilot.modeld_v2.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)} import codecs @@ -102,8 +101,7 @@ class TestStockEquivalence(OpenpilotTestCase): (CAM_W, CAM_H): tests_helpers._noop_jit, 'run_policy': tests_helpers._noop_jit, } - with open(tmp_path / 'driving_test_tinygrad.pkl', 'wb') as f: - dump_oob(pkl_data, f) + (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))) diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py index 239c498e7d..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 diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py b/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py index 08054210f1..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.sunnypilot.modeld_v2.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 8f1cdedbd6..d24b629e77 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_models.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_models.py @@ -7,71 +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" - - def traverse_jits(d): - if hasattr(d, "captured") and d.captured is not None: - linear_attr = getattr(d.captured, "_linear", None) - if linear_attr is None: - try: - linear_attr = getattr(d.captured, "linear", None) - except AssertionError as e: - if "alloc size must be positive" in str(e): - return - raise - - if linear_attr is not None: - stack = [linear_attr] - visited = set() - while stack: - node = stack.pop() - if id(node) in visited: - continue - visited.add(id(node)) - if hasattr(node, "src") and node.src is not None: - for child in node.src: - stack.append(child) - elif isinstance(d, dict): - for v in d.values(): - traverse_jits(v) - elif isinstance(d, (list, tuple)): - for v in d: - traverse_jits(v) - - if "run_model" in obj: - traverse_jits(obj["run_model"]) - if "run_policy" in obj: - traverse_jits(obj["run_policy"]) +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/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 2a3fc14cb2..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_v23.json" - MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v27.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 49539c5745..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 = 20 +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 b313bccbc2..66b0fe28ab 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -109,50 +109,6 @@ class ModelManagerSP: # Clean up start time after download completes del self._download_start_times[model.fileName] - async def _download_chunked(self, base_url: str, base_path: str, artifact, 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) - self._download_start_times[artifact.fileName] = time.monotonic() - - # 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 - - 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) - del self._download_start_times[artifact.fileName] - async def _process_artifact(self, artifact, destination_path: str) -> None: if not artifact.downloadUri.uri: return None @@ -165,28 +121,7 @@ class ModelManagerSP: full_path = os.path.join(destination_path, filename) try: - # progress counts only valid chunks so a resumed download continues the - # bar from where verification left it, instead of falling back to zero - is_cached = False - valid_chunks: set[int] = set() - if len(artifact.chunks) > 0: - from openpilot.common.file_chunker import get_chunk_name - num_chunks = len(artifact.chunks) - 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: + if await verify_file(full_path, expected_hash): artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached artifact.downloadProgress.progress = 100 artifact.downloadProgress.eta = 0 @@ -194,17 +129,9 @@ class ModelManagerSP: self._report_status() 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}") + 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}") artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloaded artifact.downloadProgress.progress = 100 @@ -213,7 +140,6 @@ class ModelManagerSP: self._report_status() except DownloadCancelled: - # a cancel keeps whatever is on disk: complete chunks resume the next attempt self._download_start_times.pop(artifact.fileName, None) artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed artifact.downloadProgress.eta = 0 @@ -225,9 +151,8 @@ class ModelManagerSP: 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) + if os.path.isfile(full_path): # noqa: ASYNC240 + os.remove(full_path) artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed artifact.downloadProgress.eta = 0 self._sync_artifact_progress(artifact) @@ -360,12 +285,11 @@ class ModelManagerSP: if model.artifact.fileName: active_files.append(model.artifact.fileName) - # Remove all files except active ones (including their chunk files) + # Remove all files except active ones 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 fdf999a680..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 = "Cinque Terre Model v2" -DEFAULT_BIG_MODEL_REF = "37bfa1413edcdc2e8844984b83727c33f81d8f46" +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/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..0ae0273ec4 100644 --- a/openpilot/sunnypilot/models/tests/test_manager_download.py +++ b/openpilot/sunnypilot/models/tests/test_manager_download.py @@ -17,11 +17,9 @@ 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 @@ -30,7 +28,6 @@ from openpilot.sunnypilot.models.helpers import (get_active_bundle, get_active_s resolve_bundle_by_ref, validate_active_bundles) from openpilot.sunnypilot.models.manager import ModelManagerSP -CHUNK_BODIES = [b'A' * 5000, b'B' * 5000, b'C' * 3000] WHOLE_BODY = b'Z' * 9000 @@ -49,12 +46,7 @@ class DownloadHandler(http.server.BaseHTTPRequestHandler): 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 + return WHOLE_BODY if path.endswith('.pkl') else None def do_GET(self): type(self).request_paths.append(self.path) @@ -119,35 +111,19 @@ class ManagerDownloadTestBase(OpenpilotTestCase): if artifact is not None: self.reported.append(float(artifact.downloadProgress.progress)) - 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.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl' + artifact.downloadUri.sha256 = sha256(WHOLE_BODY) self._bundle = bundle self.artifact = artifact return artifact - def chunk_paths(self, base_path): - return [get_chunk_name(base_path, i, len(CHUNK_BODIES)) for i in range(len(CHUNK_BODIES))] - - def assert_no_partials(self, base_path): - leftovers = [p for p in [base_path, get_manifest_path(base_path)] + self.chunk_paths(base_path) - if os.path.isfile(p)] - assert leftovers == [], f"partial files left behind: {leftovers}" - - class TestManagerDownload(ManagerDownloadTestBase): - """Exercises the real _download_file / _download_chunked against a local server.""" + """Exercises the real _download_file against a local server.""" def run_with_server(self, fn): with http_server_context(handler=DownloadHandler) as (host, port): @@ -156,7 +132,7 @@ class TestManagerDownload(ManagerDownloadTestBase): def test_download_file_writes_exact_bytes(self): def body(): - artifact = self.make_artifact(chunked=False) + artifact = self.make_artifact() 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: @@ -166,111 +142,67 @@ class TestManagerDownload(ManagerDownloadTestBase): assert artifact.fileName not in self.manager._download_start_times self.run_with_server(body) - def test_download_chunked_writes_all_chunks_and_manifest(self): - def body(): - artifact = self.make_artifact(chunked=True) - base_path = os.path.join(self.dest, artifact.fileName) - asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) - - for i, expected in enumerate(CHUNK_BODIES): - with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f: - assert f.read() == expected, f"chunk {i} body mismatch" - - with open(get_manifest_path(base_path)) as f: - assert f.read() == str(len(CHUNK_BODIES)) - - assert not os.path.isfile(base_path), "base file should be removed after chunking" - assert artifact.fileName not in self.manager._download_start_times - self.run_with_server(body) - def test_progress_is_monotonic_and_bounded(self): def body(): - artifact = self.make_artifact(chunked=True) + artifact = self.make_artifact() base_path = os.path.join(self.dest, artifact.fileName) - asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + asyncio.run(self.manager._download_file(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) + artifact = self.make_artifact() 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} + DownloadHandler.fail_paths = {'/' + artifact.fileName: 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)) + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) + assert not os.path.isfile(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) + artifact = self.make_artifact() 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)) + asyncio.run(self.manager._download_file(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) + artifact = self.make_artifact() 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 + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) + with open(base_path, 'rb') as f: + assert f.read() == WHOLE_BODY 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) + artifact = self.make_artifact() 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)) + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) + assert os.path.isfile(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) + artifact = self.make_artifact() base_path = os.path.join(self.dest, artifact.fileName) checks = {"n": 0} @@ -283,20 +215,19 @@ class TestManagerDownload(ManagerDownloadTestBase): 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)) + asyncio.run(self.manager._download_file(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) + artifact = self.make_artifact() 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)) + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) + assert os.path.isfile(base_path) self.run_with_server(body) def test_replaced_download_ref_is_kept(self): @@ -315,11 +246,10 @@ class TestManagerDownload(ManagerDownloadTestBase): 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) + artifact = self.make_artifact() 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) + with open(base_path, 'wb') as f: + f.write(WHOLE_BODY) self._bundle.ref = "test-ref" params, store = self._make_params_with_store() store["ModelManager_DownloadRef"] = None # removed -> cancelled @@ -329,42 +259,7 @@ class TestManagerDownload(ManagerDownloadTestBase): 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 + assert os.path.isfile(base_path), "cancel must not delete the cached model" self.run_with_server(body) def _make_params_with_store(self): @@ -384,7 +279,7 @@ class TestManagerDownload(ManagerDownloadTestBase): 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) + artifact = self.make_artifact() self._bundle.ref = "test-ref" self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION params, store = self._make_params_with_store() @@ -396,15 +291,13 @@ class TestManagerDownload(ManagerDownloadTestBase): 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}" + assert os.path.isfile(os.path.join(self.dest, artifact.fileName)) self.run_with_server(body) def test_download_writes_chestnut_slot(self): """A download resolved to the chestnut source writes the chestnut active bundle slot only.""" def body(): - self.make_artifact(chunked=True) + self.make_artifact() self._bundle.ref = "big-ref" self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION params, store = self._make_params_with_store() @@ -782,7 +675,7 @@ class TestEffectiveSource(OpenpilotTestCase): @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.""" + """Every artifact URL in the published manifest must resolve.""" def test_all_manifest_urls_available(self): from openpilot.sunnypilot.models.fetcher import ModelFetcher @@ -794,10 +687,7 @@ class TestLiveModelManifest(OpenpilotTestCase): for bundle in manifest.get('bundles', []): for model in bundle.get('models', []): artifact = model['artifact'] - url = artifact['download_uri']['url'] - chunks = artifact.get('chunks', []) - urls = ([url] if not chunks - else [get_chunk_name(url, i, len(chunks)) for i in range(len(chunks))]) + urls = [artifact['download_uri']['url']] for u in urls: try: r = session.head(u, timeout=15, allow_redirects=True) 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 13f67357ed..264f5168d6 100644 --- a/openpilot/sunnypilot/selfdrive/car/interfaces.py +++ b/openpilot/sunnypilot/selfdrive/car/interfaces.py @@ -81,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/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/status.py b/openpilot/system/hardware/chestnut/status.py index f108c3d62b..270576406d 100644 --- a/openpilot/system/hardware/chestnut/status.py +++ b/openpilot/system/hardware/chestnut/status.py @@ -5,7 +5,7 @@ from openpilot.common.version import get_build_metadata, CHESTNUT_BRANCHES from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_compiled -CHESTNUT_RELEASE_BRANCHES = ("release-chestnut", "release-chestnut-staging", "nightly-chestnut") +CHESTNUT_RELEASE_BRANCHES = ("release-chestnut", "release-chestnut-staging", "nightly-chestnut", "nightly-chestnut-dev") CHESTNUT_POWERED_VOLTAGE = 5000 GPU_TEMP_LIMIT = 100. MEMORY_TEMP_LIMIT = 95. @@ -24,6 +24,7 @@ class ChestnutStatus: self.link_failures = 0 self.model_loading_seen = False self.model_attempted = False + self.model_failed = False self.overheated = False self.usb_seen = False self.usb_failed = False @@ -43,14 +44,18 @@ class ChestnutStatus: self.link_failures = 0 self.model_loading_seen = False self.model_attempted = False + self.model_failed = False self.usb_seen = firmware_ok self.usb_failed = False self.model_loading_seen |= model_loading self.model_attempted |= self.model_loading_seen and not model_loading and model_active is not None + self.model_failed |= not offroad and self.model_attempted and model_active is False and len(detected) > 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 58e502c537..375e206889 100755 --- a/openpilot/system/hardware/hardwared.py +++ b/openpilot/system/hardware/hardwared.py @@ -41,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 @@ -52,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 @@ -73,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 @@ -222,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) @@ -313,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 @@ -360,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() 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/process_config.py b/openpilot/system/manager/process_config.py index 62377d37a1..8afc5ba9b7 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 @@ -127,6 +130,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), @@ -176,6 +181,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/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/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/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 e79c4592fc..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 @@ -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): 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/SConscript b/openpilot/tools/cabana/SConscript index 0249a019bb..b1ef7602a5 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -1,14 +1,23 @@ import os +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') opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (env.Dir("../../../opendbc_repo/opendbc/dbc").abspath) +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): data = open(str(source[0]), 'rb').read() @@ -52,6 +61,7 @@ if arch == "Darwin": 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'): cabana_core_test_env = env.Clone() @@ -67,7 +77,8 @@ if GetOption('extras'): cabana_core_test_env.Object('tests/cabana_core_routes', 'routes.cc'), cabana_core_test_env.Object('tests/cabana_core_qtstate', 'ui/qtstate.cc'), ] - cabana_core_test_env.Program('tests/test_cabana', cabana_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 = ui_env.Command('#' + output_json_file, 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/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/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 268aa9a86d..5f6b8da31f 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -11,6 +11,7 @@ #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" @@ -413,7 +414,30 @@ void test_pixel_envelope() { 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(); diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 1c319b4faf..57d00a2ee2 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -475,7 +475,7 @@ void ChartView::showTip(double sec) { 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(s.color), .name = name, .value = value, .min = min, .max = max}); + text_list.push_back({.has_marker = true, .marker = toImU32(graphicColor(s.color)), .name = name, .value = value, .min = min, .max = max}); } } if (x < 0) { @@ -620,9 +620,9 @@ void ChartView::drawLegend() { 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(s.color)); + 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(s.color)); + drawColorMarker(painter, r.Min, toImU32(graphicColor(s.color))); } float x = r.Min.x + marker_size + ImGui::GetStyle().ItemInnerSpacing.x; @@ -655,7 +655,7 @@ void ChartView::drawSeries() { const std::string label = "##sig" + std::to_string(i); ImPlotSpec spec; - spec.LineColor = toImVec4(s.color); + 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; @@ -682,7 +682,7 @@ void ChartView::drawSeries() { // show points when zoomed in enough if ((num_points == 1 || pixels_per_point > 20) && first != last) { ImPlotSpec dots; - dots.LineColor = toImVec4(s.color); + dots.LineColor = toImVec4(graphicColor(s.color)); dots.Stride = sizeof(ImPlotPoint); dots.Marker = ImPlotMarker_Circle; dots.MarkerSize = 4; @@ -700,12 +700,12 @@ void ChartView::drawForeground() { 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(s.color.darker(125))); + 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 = IM_COL32(0x80, 0x80, 0x80, 0xff); + 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); } diff --git a/openpilot/tools/cabana/ui/chart/sparkline.cc b/openpilot/tools/cabana/ui/chart/sparkline.cc index 1e804f2765..d9e5702fb5 100644 --- a/openpilot/tools/cabana/ui/chart/sparkline.cc +++ b/openpilot/tools/cabana/ui/chart/sparkline.cc @@ -100,7 +100,7 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi } size = sz; - color_ = toImU32(sparklineColor(color)); + color_ = color; draw_individual_points_ = draw_individual_points; window_end_ = window_end; xscale_ = xscale; @@ -108,7 +108,8 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos, ImU32 color) const { if (render_points_.empty()) return; - if (color == 0) color = color_; + // 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 diff --git a/openpilot/tools/cabana/ui/chart/sparkline.h b/openpilot/tools/cabana/ui/chart/sparkline.h index 991ecb8d4c..9d5420f4b6 100644 --- a/openpilot/tools/cabana/ui/chart/sparkline.h +++ b/openpilot/tools/cabana/ui/chart/sparkline.h @@ -26,7 +26,7 @@ private: std::vector points_; std::vector render_points_; - ImU32 color_ = 0; + 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; diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc index e669ac5fd5..665edb7c98 100644 --- a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -6,7 +6,7 @@ #include #include "json11/json11.hpp" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" #include "tools/cabana/ui/theme.h" #include "imgui.h" @@ -157,7 +157,7 @@ void RoutesDialog::signIn(const std::string &provider) { 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 = PyDownloader::authenticate(provider, abort.get()); + const std::string result = PyTools::authenticate(provider, abort.get()); utils::runOnMainThread(utils::guarded(alive.lock(), [this, abort, result]() { if (*abort) return; auth_abort_.reset(); diff --git a/openpilot/tools/cabana/ui/helpoverlay.cc b/openpilot/tools/cabana/ui/helpoverlay.cc index bc64f99196..f89e8b0b7e 100644 --- a/openpilot/tools/cabana/ui/helpoverlay.cc +++ b/openpilot/tools/cabana/ui/helpoverlay.cc @@ -181,7 +181,9 @@ void HelpOverlay::draw() { float x = min.x + 8.0f; for (const auto &r : line) { const float w = run_width(r); - const ImU32 color = r.color ? r.color : ImGui::GetColorU32(ImGuiCol_Text); + 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 { diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 674f2c3aa1..80ae6a3da2 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -25,7 +25,7 @@ #include "tools/cabana/ui/util.h" #include "tools/cabana/utils/export.h" #include "tools/cabana/utils/util.h" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" #include "tools/replay/util.h" namespace { diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc index 14d0949835..eda301a566 100644 --- a/openpilot/tools/cabana/ui/theme.cc +++ b/openpilot/tools/cabana/ui/theme.cc @@ -12,34 +12,30 @@ namespace fs = std::filesystem; namespace { constexpr Palette DARK_PALETTE = { - .text = rgb(0xbbbbbb), .text_disabled = rgb(0x777777), .text_selected = rgb(0xbbbbbb), - .window = rgb(0x353535), .surface = rgb(0x3c3f41), - .frame = rgb(0x3c3f41), .frame_hovered = rgb(0x484b4d), .frame_active = rgb(0x505355), - .button = rgb(0x484b4d), .button_hovered = rgb(0x535658), .button_active = rgb(0x3c3f41), - .header = rgb(0x2f65ca), .header_hovered = rgb(0x414e65), .header_active = rgb(0x2f65ca), - .accent = rgb(0x2f65ca), - .border = rgb(0x282828), .separator = rgb(0x353535), .scrollbar_grab = rgb(0x484b4d), - .slider_track = rgb(0x777777), - .tab = rgb(0x353535), .tab_hovered = rgb(0x484b4d), .table_header = rgb(0x484b4d), - .grid = rgb(0xbbbbbb, 50.0f / 255.0f), .badge = rgb(0x808080), - .bit_background = rgb(0xffffff, 20.0f / 255.0f), - .heatmap_signal_alpha = 70.0, .heatmap_bit_alpha = 28.0, .heatmap_gamma = 0.6, + .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(0xbebebe), .text_selected = rgb(0xffffff), + .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(0x308cc6), .header_hovered = rgb(0xe7f3fb), .header_active = rgb(0x308cc6), - .accent = rgb(0x308cc6), - .border = rgb(0xb6b6b6), .separator = rgb(0xd0d0d0), .scrollbar_grab = rgb(0xb6b6b6), - .slider_track = rgb(0xd0d0d0), - .tab = rgb(0xe5e5e5), .tab_hovered = rgb(0xefefef), .table_header = rgb(0xefefef), - .grid = rgb(0x000000, 50.0f / 255.0f), .badge = rgb(0xa0a0a4), - .bit_background = rgb(0xffffff, 0.0f), - .heatmap_signal_alpha = 25.0, .heatmap_bit_alpha = 10.0, .heatmap_gamma = 1.0, + .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, }; @@ -71,6 +67,36 @@ ImFont *addFont(const fs::path &path, float size) { 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 @@ -127,7 +153,8 @@ void applyTheme(int theme) { c[ImGuiCol_FrameBgHovered] = p.frame_hovered; c[ImGuiCol_FrameBgActive] = p.frame_active; c[ImGuiCol_Button] = p.button; - c[ImGuiCol_ButtonHovered] = c[ImGuiCol_SliderGrab] = p.button_hovered; + 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; @@ -142,7 +169,7 @@ void applyTheme(int theme) { 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.surface; + c[ImGuiCol_TabSelected] = c[ImGuiCol_TabDimmedSelected] = p.tab_selected; c[ImGuiCol_TabDimmedSelectedOverline] = none; c[ImGuiCol_TableHeaderBg] = p.table_header; c[ImGuiCol_TableRowBgAlt] = none; @@ -161,10 +188,54 @@ void applyTheme(int theme) { 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 CabanaColor::fromHsv(h, std::min(1.0f, s * p.sparkline_saturation), v * p.sparkline_value, color.a / 255.0f); + return graphicColor(CabanaColor::fromHsv(h, std::min(1.0f, s * p.sparkline_saturation), v * p.sparkline_value)); } ImFont *boldFont() { return g_bold_font; } diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h index 9c8653cfe9..4655219505 100644 --- a/openpilot/tools/cabana/ui/theme.h +++ b/openpilot/tools/cabana/ui/theme.h @@ -6,20 +6,18 @@ #include "tools/cabana/core/color.h" struct Palette { - ImVec4 text, text_disabled, text_selected; + 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; // selections + 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, table_header; + ImVec4 tab, tab_hovered, tab_selected, table_header; ImVec4 grid; ImVec4 badge; // the fill behind the time labels drawn over a chart - ImVec4 bit_background; // overlay beneath heatmap bits without a signal - double heatmap_signal_alpha, heatmap_bit_alpha, heatmap_gamma; float sparkline_saturation, sparkline_value; // HSV multipliers for signal colors }; @@ -30,6 +28,10 @@ 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); } @@ -46,6 +48,12 @@ 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(); diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index b11ecf28fa..2252355144 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -94,9 +94,10 @@ bool selectable(const char *label, bool selected, ImGuiSelectableFlags flags, co 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(2); + if (selected) ImGui::PopStyleColor(3); return clicked; } @@ -382,7 +383,7 @@ int tableHeadersRow() { 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, ImGui::GetColorU32(ImGuiCol_Header)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, selected ? palette().header : palette().header_active); const bool clicked = selectable(label, selected, flags, size); ImGui::PopStyleColor(2); return clicked; 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 index 84f5a29fd8..818f8b42a8 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -10,20 +10,20 @@ #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 VERTICAL_HEADER_WIDTH = 30; +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 paletteHighlight() { return ImGui::GetColorU32(ImGuiCol_Header); } inline ImU32 paletteBase() { return ImGui::GetColorU32(ImGuiCol_ChildBg); } -inline ImU32 paletteText(bool active) { return ImGui::GetColorU32(active ? ImGuiCol_Text : ImGuiCol_TextDisabled); } -const ImU32 DARK_GRAY = IM_COL32(128, 128, 128, 255); +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. @@ -56,6 +56,13 @@ void fillBDiagPattern(ImDrawList *p, const ImRect &r, ImU32 col) { } // 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(); })); } @@ -63,6 +70,8 @@ BinaryView::BinaryView() { 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 , @@ -113,11 +122,11 @@ void BinaryView::addShortcuts() { } ImVec2 BinaryView::minimumSizeHint() const { - // widest fixed-font glyph plus the header margins - pushMonoFont(); - const float min_section_size = ImGui::CalcTextSize("W").x + 8.0f; + // 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 + 1) * 9 + VERTICAL_HEADER_WIDTH + 2, + return {min_section_size * GRID_COLUMN_COUNT + 2, static_cast(CELL_HEIGHT * std::min(row_count_, 10) + 2)}; } @@ -164,6 +173,8 @@ void BinaryView::highlightPosition(const ImVec2 &pos) { auto item = &cellAt(index); const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); highlight(sig); + } else { + highlight(nullptr); } } @@ -205,35 +216,46 @@ void BinaryView::refresh() { hovered_sig_ = nullptr; bit_flip_tracker_ = {}; cells_.clear(); - if (auto dbc_msg = dbc()->msg(msg_id_)) { - row_count_ = dbc_msg->size; - cells_.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 >= cells_.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 ? 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; - - auto &sigs = cells_[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(); - cells_.resize(row_count_ * COLUMN_COUNT); - } + 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; @@ -272,18 +294,18 @@ std::tuple BinaryView::getSelection(BinaryIndex index) { } BinaryIndex BinaryView::indexAt(const ImVec2 &pos) const { - if (column_width_ <= 0 || pos.x < grid_pos_.x + VERTICAL_HEADER_WIDTH || pos.y < grid_pos_.y) return {}; - int column = static_cast((pos.x - grid_pos_.x - VERTICAL_HEADER_WIDTH) / column_width_); + 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 { - // sections are integral: round the edges so neighboring cells share them exactly and the cells are - // painted edge to edge with no grid line between them - const float x0 = grid_pos_.x + VERTICAL_HEADER_WIDTH + IM_ROUND(index.column * column_width_); - const float x1 = grid_pos_.x + VERTICAL_HEADER_WIDTH + IM_ROUND((index.column + 1) * column_width_); + // 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); } @@ -297,17 +319,19 @@ void BinaryView::draw() { 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; - const float width = std::max(ImGui::GetContentRegionAvail().x, VERTICAL_HEADER_WIDTH + min_column_width * COLUMN_COUNT); - column_width_ = std::max(min_column_width, (width - VERTICAL_HEADER_WIDTH) / COLUMN_COUNT); + 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 + VERTICAL_HEADER_WIDTH, grid_pos_.y + (row + 1) * CELL_HEIGHT); - painter->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); // plain header background - drawText(painter, r, std::to_string(row).c_str(), ImGui::GetColorU32(ImGuiCol_Text)); + 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) { @@ -316,6 +340,29 @@ void BinaryView::draw() { } } + // 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(); @@ -359,6 +406,9 @@ void BinaryView::updateState() { 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) { @@ -367,13 +417,15 @@ void BinaryView::updateState() { } } - const Palette &p = palette(); + 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 = p.heatmap_signal_alpha; // Base alpha for small flip counts - const double min_alpha_no_signal = p.heatmap_bit_alpha; // Base alpha for small flip counts for no signal bits - const double alpha_gamma = p.heatmap_gamma; + 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(log_factor * max_bit_flip_count); + 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) { @@ -384,7 +436,6 @@ void BinaryView::updateState() { uint32_t flip_count = bit_flips[i][j]; if (flip_count > 0) { double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler; - normalized_alpha = max_alpha * std::pow(std::clamp(normalized_alpha / max_alpha, 0.0, 1.0), alpha_gamma); double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal; alpha = std::clamp(normalized_alpha, min_alpha, max_alpha); } @@ -393,87 +444,68 @@ void BinaryView::updateState() { color.a = static_cast(alpha); setCell(i, j, bit_val, color); } - setCell(i, HEX_COLUMN, binary[i], last_msg.colors[i]); + 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_.time_range == time_range && !bit_flip_tracker_.flip_counts.empty()) - return bit_flip_tracker_.flip_counts; + 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_.flip_counts.assign(msg_size, std::array{}); - + bit_flip_tracker_.counts = HeatmapCounts(msg_size); 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; -} - -bool BinaryView::hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const { - if (!index.isValid()) return false; - int idx = (index.row + dy) * COLUMN_COUNT + index.column + dx; - if (idx < 0 || idx >= (int)cells_.size()) return false; - auto &s = cells_[idx].sigs; - return std::find(s.begin(), s.end(), sig) != s.end(); + 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 = ImGui::GetFontSize(); - ImU32 pen = paletteText(is_message_active_); + 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(); + pushMonoFont(CELL_FONT_SIZE); font = ImGui::GetFont(); font_size = ImGui::GetFontSize(); popMonoFont(); - painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + painter->AddRectFilled(rect.Min, rect.Max, toImU32(byteColor(item->bg_color))); } } else if (isSelected(index)) { - painter->AddRectFilled(rect.Min, rect.Max, resize_sig_ ? toImU32(resize_sig_->color) : paletteHighlight()); - if (resize_sig_) pen = IM_COL32_WHITE; + 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) { - if (s == hovered_sig_) { - painter->AddRectFilled(rect.Min, rect.Max, toImU32(s->color.darker(125))); // 4/5x brightness - } else { - drawSignalCell(painter, rect, index, s); - } + 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) { - painter->AddRectFilled(rect.Min, rect.Max, ImGui::GetColorU32(palette().bit_background)); - if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalFill(item->bg_color, false))); } - bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); - pen = bright ? IM_COL32_WHITE : paletteText(is_message_active_); + 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, DARK_GRAY); + fillDense7Pattern(painter, rect, pattern); } else if (!item->valid) { - fillBDiagPattern(painter, rect, DARK_GRAY); + fillBDiagPattern(painter, rect, pattern); } + if (item->valid) { if (index.column == HEX_COLUMN) { drawBoldText(painter, rect, utils::hexByte(item->val), pen, font, font_size); @@ -487,64 +519,38 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary } } -// Draw border on edge of signal -void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &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); +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(); +} - const int spacing = 2; - ImRect rc(rect.Min.x + draw_left * 3, rect.Min.y + draw_top * spacing, rect.Max.x - draw_right * 3, rect.Max.y - draw_bottom * spacing); - std::vector subtract; - if (!draw_top) { - if (!draw_left && !hasSignal(index, -1, -1, sig)) { - subtract.emplace_back(rc.Min.x, rc.Min.y, rc.Min.x + 3, rc.Min.y + spacing); - } else if (!draw_right && !hasSignal(index, 1, -1, sig)) { - subtract.emplace_back(rc.Max.x - 3, rc.Min.y, rc.Max.x, rc.Min.y + spacing); - } - } - if (!draw_bottom) { - if (!draw_left && !hasSignal(index, -1, 1, sig)) { - subtract.emplace_back(rc.Min.x, rc.Max.y - spacing, rc.Min.x + 3, rc.Max.y); - } else if (!draw_right && !hasSignal(index, 1, 1, sig)) { - subtract.emplace_back(rc.Max.x - 3, rc.Max.y - spacing, rc.Max.x, rc.Max.y); - } - } - // rc split into horizontal bands with the notch corners removed: at most one notch in the top band and - // one in the bottom band - const ImRect *top_notch = !subtract.empty() && subtract.front().Min.y == rc.Min.y ? &subtract.front() : nullptr; - const ImRect *bottom_notch = !subtract.empty() && subtract.back().Min.y != rc.Min.y ? &subtract.back() : nullptr; - std::vector region; - auto band = [&](const ImRect *notch, float y0, float y1) { - const float x0 = notch && notch->Min.x == rc.Min.x ? notch->Max.x : rc.Min.x; - const float x1 = notch && notch->Min.x != rc.Min.x ? notch->Min.x : rc.Max.x; - if (x1 > x0 && y1 > y0) region.emplace_back(x0, y0, x1, y1); +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; }; - if (top_notch) band(top_notch, rc.Min.y, rc.Min.y + spacing); - band(nullptr, rc.Min.y + (top_notch ? spacing : 0), rc.Max.y - (bottom_notch ? spacing : 0)); - if (bottom_notch) band(bottom_notch, rc.Max.y - spacing, rc.Max.y); + 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); +} - auto item = &cellAt(index); +// 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 = item->bg_color.alpha(); - const ImU32 edge = toImU32(sig->color.darker(125)); - - for (const ImRect &clip : region) { - painter->PushClipRect(clip.Min, clip.Max, true); - // mix the signal color with the background to fade it - painter->AddRectFilled(rc.Min, rc.Max, paletteBase()); - painter->AddRectFilled(rc.Min, rc.Max, toImU32(color)); - - if (draw_left) painter->AddLine(ImVec2(rc.Min.x + 0.5f, rc.Min.y), ImVec2(rc.Min.x + 0.5f, rc.Max.y), edge, 1.0f); - if (draw_right) painter->AddLine(ImVec2(rc.Max.x - 0.5f, rc.Min.y), ImVec2(rc.Max.x - 0.5f, rc.Max.y), edge, 1.0f); - if (draw_bottom) painter->AddLine(ImVec2(rc.Min.x, rc.Max.y - 0.5f), ImVec2(rc.Max.x, rc.Max.y - 0.5f), edge, 1.0f); - if (draw_top) painter->AddLine(ImVec2(rc.Min.x, rc.Min.y + 0.5f), ImVec2(rc.Max.x, rc.Min.y + 0.5f), edge, 1.0f); - - // fill gaps inside corners: the 2px stroke is clipped to the region, only the half outside the notch is painted - for (auto &r : subtract) { - painter->AddRect(r.Min, r.Max, edge, 0.0f, 0, 2.0f); - } - painter->PopClipRect(); - } + 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 index 1ca3c17bab..4f1afa1032 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.h +++ b/openpilot/tools/cabana/ui/widgets/binaryview.h @@ -9,6 +9,7 @@ #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" @@ -39,6 +40,7 @@ public: std::string whatsThis() const; Observable signalClicked; + Observable<> signalsChanged; Observable signalHovered; Observable editSignal; Observable showChart; @@ -53,6 +55,7 @@ private: 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); @@ -73,15 +76,18 @@ private: 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; - std::vector> flip_counts; + HeatmapCounts counts; + bool valid = false; } bit_flip_tracker_; BinaryIndex anchor_index_; diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 830a51b889..6378d2f8ca 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -48,6 +48,7 @@ DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { })); 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); @@ -60,6 +61,7 @@ DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { 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); })); @@ -74,8 +76,8 @@ DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { char text[64]; if (range) snprintf(text, sizeof(text), "%.3f - %.3f", range->first, range->second); heatmap_all_text_ = range ? text : "All"; - const bool live = !range; - if (std::exchange(heatmap_live_, live) != live) binary_view_->setHeatmapLiveMode(live); + heatmap_live_ = !range && can->liveStreaming(); + binary_view_->setHeatmapLiveMode(heatmap_live_); })); } @@ -174,23 +176,30 @@ void DetailWidget::restoreTabs(const std::string &active_msg_id, const std::vect } 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) { - if (msg_id_.source == INVALID_SOURCE) { + // 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 (" + std::to_string(msg->size) + ") is incorrect."); + 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."); } } - 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; - if (!warnings.empty()) { warning_label_.clear(); for (size_t i = 0; i < warnings.size(); ++i) { @@ -210,6 +219,7 @@ void DetailWidget::updateState(const std::set *msgs) { binary_view_->updateState(); else history_log_->updateState(); + updateWarnings(); } void DetailWidget::editMsg(float parent_width) { @@ -223,16 +233,29 @@ void DetailWidget::drawTabWidget() { page_tabs_.draw(); if (tab_widget_index_ == 0) { if (heatmap_visible_) { - // Keep most of a short window available for signal rows. + // 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 - style.ItemSpacing.y - SignalView::minimumHeight(), 1.0f); - const float height = std::min(binary_view_->minimumSizeHint().y, max_height); - ImGui::BeginChild("binary_view", ImVec2(0, std::max(height, 1.0f)), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); + 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); } - ImGui::BeginChild("signal_view", ImVec2(0, 0), ImGuiChildFlags_None, + 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(); @@ -244,8 +267,7 @@ void DetailWidget::drawTabWidget() { void DetailWidget::draw() { tabbar_.draw(); - ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding, - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding); drawToolBar(); if (warning_widget_visible_) { @@ -387,7 +409,7 @@ void CenterWidget::drawWelcomeWidget() { ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - size.x) * 0.5f, y)); ImGui::TextUnformatted(text); }; - ImGui::PushStyleColor(ImGuiCol_Text, colorRgb(169, 169, 169)); + ImGui::PushStyleColor(ImGuiCol_Text, palette().text_disabled); float y = origin.y + avail.y * 0.5f - 90.0f; pushLargeFont(); centered("CABANA", y); diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.h b/openpilot/tools/cabana/ui/widgets/detailwidget.h index 4745ed5c84..f752488ee1 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.h +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.h @@ -75,6 +75,7 @@ private: 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; @@ -87,6 +88,7 @@ private: 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_; diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.cc b/openpilot/tools/cabana/ui/widgets/messagebytes.cc index 63e31d6f83..fb66726b70 100644 --- a/openpilot/tools/cabana/ui/widgets/messagebytes.cc +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.cc @@ -26,13 +26,13 @@ ImVec2 bytesCellSize(int n, bool multiple_lines) { ImU32 cellTextColor(bool selected, bool inactive) { if (selected) { const ImU32 text = ImGui::GetColorU32(palette().text_selected); - return inactive ? withAlpha(text, 100) : text; + return text; } - return ImGui::GetColorU32(inactive ? ImGuiCol_TextDisabled : ImGuiCol_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) { - drawElidedText(dl, rect, text, cellTextColor(selected, inactive)); +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, @@ -48,14 +48,11 @@ void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vectorsize() && (*colors)[i].alpha() > 0) { - if (selected) { - pen = ImGui::GetColorU32(ImGuiCol_Text); - dl->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); - } - dl->AddRectFilled(r.Min, r.Max, toImU32((*colors)[i])); + // 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); } diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.h b/openpilot/tools/cabana/ui/widgets/messagebytes.h index 7c7aabc984..163a5805ae 100644 --- a/openpilot/tools/cabana/ui/widgets/messagebytes.h +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.h @@ -15,6 +15,6 @@ 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); +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 index a3cacea680..e648113526 100644 --- a/openpilot/tools/cabana/ui/widgets/messageswidget.cc +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -23,11 +23,12 @@ 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, no 0x prefix +// 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(); @@ -505,7 +506,9 @@ void MessagesWidget::drawRow(int row) { if (column == MessageList::DATA && seen) { drawBytesCell(ImGui::GetWindowDrawList(), rect, m.dat, &m.colors, selected, inactive, multiple_lines); } else { - drawTextCell(ImGui::GetWindowDrawList(), rect, cellText(item, column), selected, inactive); + 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)); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index bc8b9c4d23..6562903935 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -267,7 +267,7 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const 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->sig->color.darker(item->highlight ? 125 : 0)), ImGui::GetStyle().FrameRounding); + 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); @@ -276,7 +276,7 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const 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, IM_COL32(160, 160, 164, 255), ImGui::GetStyle().FrameRounding); + 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; } @@ -909,35 +909,36 @@ void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - H_MARGIN - button * 2 - spacing, rect.Min.y + (rect.GetHeight() - button) * 0.5f)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); 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](const char *id, const char *glyph) { - if (selected) { - ImGui::PushStyleColor(ImGuiCol_Text, palette().text_selected); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, palette().header_active); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, palette().header_active); - } + 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); - if (selected) ImGui::PopStyleColor(3); + ImGui::PopStyleColor(5); + ImGui::PopStyleVar(); return clicked; }; - if (checked) ImGui::PushStyleColor(ImGuiCol_Button, selected ? palette().header_active : ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); - if (row_button("plot", icon::GRAPH_UP) && !editor_open_on_press_) { + 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); } - if (checked) ImGui::PopStyleColor(); 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"); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(); } ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions) { diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 5c8a9ede3c..37b4348fd3 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -425,7 +425,7 @@ void Slider::paint(double thumbnail_time) { 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, timeline_colors[(int)TimelineType::None], groove_height * 0.5f); + 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; @@ -444,7 +444,7 @@ void Slider::paint(double thumbnail_time) { if (auto replay = getReplay()) { for (const auto &entry : *replay->getTimeline()) { - fillRange(entry.start_time, entry.end_time, timeline_colors[(int)entry.type]); + 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); @@ -531,8 +531,9 @@ void StreamCameraView::draw(const ImVec2 &size, double thumbnail_time) { const char *text = "PAUSED"; const ImVec2 text_size = font->CalcTextSizeA(POINT_16_FONT_SIZE, FLT_MAX, 0.0f, text); const ImVec2 center = rect().GetCenter(); - p->AddText(font, POINT_16_FONT_SIZE, ImVec2(center.x - text_size.x / 2, center.y - text_size.y / 2), - IM_COL32(200, 200, 200, static_cast(255 * 0.7f)), text); + 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); } } @@ -583,13 +584,16 @@ void StreamCameraView::drawTime(ImDrawList *p, const ImRect &rect, double second 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 - p->AddText(font, POINT_10_FONT_SIZE, ImVec2(rect.GetCenter().x - text_size.x / 2, rect.Max.y - THUMBNAIL_MARGIN - text_size.y), - IM_COL32_WHITE, text); + 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; - ImU32 color = withAlpha(timeline_colors[int(alert.type)], 128); + // 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; 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_config.py b/openpilot/tools/lib/auth_config.py index 954225d4d3..cc29b3ba1a 100644 --- a/openpilot/tools/lib/auth_config.py +++ b/openpilot/tools/lib/auth_config.py @@ -21,10 +21,3 @@ def set_token(token): os.makedirs(Paths.config_root(), exist_ok=True) 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 fd517d7ada..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,6 +19,8 @@ import os import shutil import sys import tempfile +import traceback +from urllib.parse import urlparse from openpilot.common.hardware.hw import Paths @@ -100,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 @@ -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/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_process.cc similarity index 57% rename from openpilot/tools/replay/py_downloader.cc rename to openpilot/tools/replay/py_process.cc index 286d8a7319..1f46dbf384 100644 --- a/openpilot/tools/replay/py_downloader.cc +++ b/openpilot/tools/replay/py_process.cc @@ -1,10 +1,9 @@ -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_process.h" #include -#include #include #include -#include +#include #include #ifdef __APPLE__ #include @@ -16,29 +15,15 @@ #include "tools/replay/util.h" -namespace { +namespace PyProcess { -constexpr const char *AUTH_MODULE = "openpilot.tools.lib.auth"; -constexpr const char *DOWNLOADER_MODULE = "openpilot.tools.lib.file_downloader"; - -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 char *module, const std::vector &args, std::atomic *abort = nullptr) { +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); + argv.push_back(module.c_str()); for (const auto &a : args) { argv.push_back(a.c_str()); } @@ -56,17 +41,17 @@ std::string runPython(const char *module, const std::vector &args, }; int stdout_pipe[2], stderr_pipe[2]; if (open_pipe(stdout_pipe) != 0) { - rWarning("py_downloader: pipe() failed"); + rWarning("py_process: pipe() failed"); return {}; } if (open_pipe(stderr_pipe) != 0) { - rWarning("py_downloader: pipe() failed"); + 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 segment download: both can stall rendering even from a worker thread. + // every spawn: both can stall rendering even from a worker thread. std::vector environment; #ifdef __APPLE__ char **parent_environment = *_NSGetEnviron(); @@ -103,7 +88,7 @@ std::string runPython(const char *module, const std::vector &args, if (attributes_initialized) posix_spawnattr_destroy(&attributes); if (actions_initialized) posix_spawn_file_actions_destroy(&actions); if (error) { - rWarning("py_downloader: posix_spawnp() failed: %s", strerror(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 {}; @@ -113,8 +98,8 @@ std::string runPython(const char *module, const std::vector &args, 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]]() { + // 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); @@ -123,8 +108,8 @@ std::string runPython(const char *module, const std::vector &args, char *line = nullptr; size_t cap = 0; while (getline(&line, &cap, f) > 0) { - if (strncmp(line, "PROGRESS:", 9) == 0) { - reportProgress(line); + if (cb) { + cb(line); } else { fputs(line, stderr); } @@ -182,76 +167,23 @@ std::string runPython(const char *module, const std::vector &args, WIFSIGNALED(status); if (failed) { if (expected_sigterm) { - // Route/camera teardown cancels outstanding downloader subprocesses. - // Keep that expected shutdown path quiet. + // Caller signaled abort; expected shutdown path. } else if (WIFEXITED(status) && WEXITSTATUS(status) != 0) { - rWarning("py_downloader: process exited with code %d", WEXITSTATUS(status)); + rWarning("py_process: %s exited with code %d", module.c_str(), 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); + rWarning("py_process: %s killed by signal %d", module.c_str(), WTERMSIG(status)); } return {}; } // Trim trailing newline - while (!stdout_data.empty() && (stdout_data.back() == '\n' || stdout_data.back() == '\r')) { - stdout_data.pop_back(); + if (trim) { + 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(DOWNLOADER_MODULE, args, abort); -} - -std::string decompress(const std::string &path, std::atomic *abort) { - return runPython(DOWNLOADER_MODULE, {"decompress", path}, abort); -} - -std::string getRouteFiles(const std::string &route) { - return runPython(DOWNLOADER_MODULE, {"route-files", route}); -} - -std::string authenticate(const std::string &provider, std::atomic *abort) { - return runPython(AUTH_MODULE, {provider, "--json"}, abort); -} - -std::string getDevices() { - return runPython(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 runPython(DOWNLOADER_MODULE, args); -} - -} // namespace PyDownloader +} // 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 73% rename from openpilot/tools/replay/py_downloader.h rename to openpilot/tools/replay/py_tools.h index fac66dabd5..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,6 +18,9 @@ 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); @@ -27,4 +30,7 @@ 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/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/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/panda b/panda index 4230530f5e..8a16e089b8 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 4230530f5ec4c7f11971f868ae9930b3547346eb +Subproject commit 8a16e089b88593914823743df29e1c30b7dd3e00 diff --git a/release/ci/model_generator.py b/release/ci/model_generator.py index 3b5603c4ae..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,22 +66,9 @@ 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 = [f for f in sorted(model_dir.glob("*.onnx")) if f.stat().st_size > 1024] if not onnx_files: @@ -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, @@ -193,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) + _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/upload_default_model.py b/release/ci/upload_default_model.py index 2abd5087c0..8fce46a381 100644 --- a/release/ci/upload_default_model.py +++ b/release/ci/upload_default_model.py @@ -79,8 +79,6 @@ def main(): 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( diff --git a/tinygrad_repo b/tinygrad_repo index fe5d3169ba..69fd4d34d4 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit fe5d3169ba4f41d0947ad174925f413cbea9d056 +Subproject commit 69fd4d34d4430036378aba2b43f9d339cdd6ea73 diff --git a/tools/release/README.md b/tools/release/README.md index 7e2e7e2faf..25ae32f704 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -21,7 +21,7 @@ - [ ] update from new release -> previous release - [ ] fresh install with `openpilot-test.comma.ai` - [ ] drive on fresh install - - [ ] no submodules or LFS + - [ ] no submodules; only the Chestnut big driving model uses LFS - [ ] check MTBF, etc. - [ ] stress test passes in production - [ ] publish the blog post diff --git a/tools/release/build_release.sh b/tools/release/build_release.sh index dd6c22e4cf..d83bcc7ec1 100755 --- a/tools/release/build_release.sh +++ b/tools/release/build_release.sh @@ -39,6 +39,7 @@ cd "$SOURCE_DIR" # in the directory 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. @@ -58,9 +59,10 @@ else scons panda/ fi -find openpilot/selfdrive/modeld/models -name '*.pkl' -size +95M -exec ./openpilot/common/file_chunker.py {} \; if [ -n "$INCLUDE_BIG_MODEL" ]; then - test -f openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest + 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 @@ -96,6 +98,7 @@ 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" @@ -108,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 5177ac2592..c854aeb18e 100755 --- a/tools/release/build_stripped.sh +++ b/tools/release/build_stripped.sh @@ -39,7 +39,7 @@ cd "$SOURCE_DIR" cd "$TARGET_DIR" rm -rf .git/modules/ -find openpilot/selfdrive/modeld/models -name '*.pkl' -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) @@ -61,29 +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" - # Reset hooks since releases exclude .venv - git config --local core.hooksPath .git/hooks - git lfs update --force # 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_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 bcf2d148a3..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_tinygrad.pkl"): + 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/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_dependencies.sh b/tools/setup_dependencies.sh index c9e635836b..6f023f7b53 100755 --- a/tools/setup_dependencies.sh +++ b/tools/setup_dependencies.sh @@ -72,7 +72,8 @@ function install_linux_deps() { 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" @@ -89,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 }