mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-11-11 02:53:45 +08:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c24b931ca | |||
| 3b016e36ce | |||
| 0b5620ef67 | |||
| 7bbb2472ce | |||
| 5cc028dbb4 | |||
| 94058fe486 | |||
| 5ae8e98f9b | |||
| 5bcd182f4d | |||
| 103ee81db1 | |||
| 1bf5473451 | |||
| 2c94b99ee8 | |||
| 87e56f8294 | |||
| cb97631449 | |||
| 848c8c1af9 | |||
| 3ed5849abb | |||
| 24112edb95 | |||
| e77f239ace | |||
| e972ee35c0 | |||
| 5f7a17ed7b | |||
| 8bea2d48c1 | |||
| 3d8f211d4b | |||
| 02ef6e4ce0 | |||
| a1a28e467e | |||
| 932ff06d93 | |||
| 8cf6ff9a33 | |||
| a86979d2b2 | |||
| 6f7a2c94c1 | |||
| 52600eccbf | |||
| 24f531b83c | |||
| 97135965ef | |||
| 90a541287f | |||
| db27a433fc | |||
| a06366ce92 | |||
| 861fd118a1 | |||
| 8e740e0ac6 | |||
| 9d029d683d | |||
| f78c6c2377 | |||
| 1aa3a430fb | |||
| ddd7b14ebc |
@@ -14,3 +14,8 @@ openpilot/sunnypilot/modeld_v2/models/*.pkl filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
openpilot/selfdrive/car/tests/test_models_segs.txt filter=lfs diff=lfs merge=lfs -text
|
||||
openpilot/common/hardware/comma/updater filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Achievements icons are kept out of LFS on this dev branch (no push access to the LFS store).
|
||||
# Move them into LFS before this goes upstream.
|
||||
openpilot/sunnypilot/selfdrive/assets/icons_mici/achievements.png -filter binary
|
||||
openpilot/sunnypilot/selfdrive/assets/offroad/icon_achievements.png -filter binary
|
||||
|
||||
@@ -34,6 +34,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: 'qcom'
|
||||
on_device:
|
||||
description: 'Compile on the tici / chestnut runner instead of on a hosted runner with tinygrad''s qcom / amd mock'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
hf_repo:
|
||||
description: 'Hugging Face dataset repository (e.g. sunnypilot/sunnypilot_models_v1)'
|
||||
required: false
|
||||
@@ -107,6 +112,11 @@ on:
|
||||
options:
|
||||
- qcom
|
||||
- chestnut
|
||||
on_device:
|
||||
description: 'Compile on the tici / chestnut runner instead of on a hosted runner with tinygrad''s qcom / amd mock'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
hf_repo:
|
||||
description: 'Hugging Face dataset repository'
|
||||
required: false
|
||||
@@ -130,6 +140,7 @@ jobs:
|
||||
is_20hz: ${{ inputs.is_20hz }}
|
||||
artifact_suffix: ${{ inputs.artifact_suffix }}
|
||||
target_hardware: ${{ inputs.target_hardware }}
|
||||
on_device: ${{ inputs.on_device || false }}
|
||||
secrets: inherit
|
||||
|
||||
publish_model:
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Chestnut mock build parity
|
||||
description: >-
|
||||
Convert one commaai precompiled chestnut model (LS2) on this runner under tinygrad's amd mock, as a hosted chestnut build does, and require the
|
||||
exact bytes the chestnut runner built (the published artifact). Needs setup-amd-mock, numpy and zstandard; tinygrad_repo checked out.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Key this check by everything its result depends on
|
||||
id: key
|
||||
shell: bash
|
||||
env:
|
||||
SOURCES: ${{ hashFiles('tinygrad_repo/tinygrad/**/*.py', 'tinygrad_repo/test/mockgpu/**', 'openpilot/sunnypilot/modeld_v2/*.py', '.github/workflows/chestnut-build-parity/action.yml') }}
|
||||
run: echo "key=chestnut-build-parity-$SOURCES-$(sha256sum "$LLVM_PATH" | cut -c1-16)-$(python3 -c 'import platform; print(platform.python_version())')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Passed before with all of it identical?
|
||||
id: passed
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: ~/.chestnut_build_parity.passed
|
||||
key: ${{ steps.key.outputs.key }}
|
||||
|
||||
- name: Rebuild LS2 on the mock and require the chestnut runner's artifact byte for byte
|
||||
if: steps.passed.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
REF: b92fc4828664f8767bce5e92f1630ed319cbe509 # commaai/openpilot: precompiled big_driving_tinygrad.pkl, pins tinygrad ec91b74b5b
|
||||
PKL_OID: b4dead3ee091d66ab002a6b8e716c26f4f79e3c931e4be5f48e017ba92f6e960
|
||||
PKL_SIZE: "767228907"
|
||||
TG_SHA: ec91b74b5b6b0fc924b72d3f27689679ca2438b9
|
||||
WANT: 680a8d629785f2b1d1a2b0e40d0dfbcd0f7291c21e20c51259f1d768a22ec217 # driving_light_speed_2_november_04_2026_tinygrad.pkl, recompiled46
|
||||
run: |
|
||||
set -euo pipefail
|
||||
work="$RUNNER_TEMP/chestnut_parity" && rm -rf "$work" && mkdir -p "$work"
|
||||
href=$(curl -fsSL --retry 3 -X POST -H "Accept: application/vnd.git-lfs+json" -H "Content-Type: application/vnd.git-lfs+json" \
|
||||
https://huggingface.co/commaai/openpilot-lfs.git/info/lfs/objects/batch \
|
||||
-d "{\"operation\":\"download\",\"transfer\":[\"basic\"],\"objects\":[{\"oid\":\"$PKL_OID\",\"size\":$PKL_SIZE}]}" | jq -r '.objects[0].actions.download.href')
|
||||
curl -fsSL --retry 3 -o "$work/big_driving_tinygrad.pkl" "$href"
|
||||
echo "$PKL_OID $work/big_driving_tinygrad.pkl" | sha256sum -c -
|
||||
tg="$work/tinygrad" && git init -q "$tg" && git -C "$tg" fetch -q --depth 1 https://github.com/tinygrad/tinygrad "$TG_SHA" && git -C "$tg" checkout -q FETCH_HEAD
|
||||
CHECK_OOB=0 GMMU=0 PYTHONPATH="$GITHUB_WORKSPACE/tinygrad_repo" python3 tinygrad_repo/test/mockgpu/usb_server.py \
|
||||
env DEV=USB+AMD PYTHONPATH="$tg:$GITHUB_WORKSPACE" python3 openpilot/sunnypilot/modeld_v2/convert_upstream_pkl.py "$work/big_driving_tinygrad.pkl" "$work/LS2.pkl"
|
||||
got=$(sha256sum "$work/LS2.pkl" | cut -d' ' -f1)
|
||||
if [ "$got" != "$WANT" ]; then
|
||||
echo "::error::the mock's LS2 build ($got) is not the chestnut runner's ($WANT): this runner's LLVM or the mock no longer builds what the chestnut does"
|
||||
exit 1
|
||||
fi
|
||||
echo "LS2 rebuilt on the mock: byte-identical to the chestnut runner's build"
|
||||
touch ~/.chestnut_build_parity.passed
|
||||
|
||||
- name: Remember the pass
|
||||
if: steps.passed.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: ~/.chestnut_build_parity.passed
|
||||
key: ${{ steps.key.outputs.key }}
|
||||
|
||||
- name: Already passed
|
||||
if: steps.passed.outputs.cache-hit == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "LS2 rebuild skipped: it already matched the chestnut runner's build with this exact mock, converter, LLVM and python"
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Set up tinygrad's amd mock
|
||||
description: LLVM 20 and ROCm comgr, as tinygrad_repo/.github/actions/setup-tinygrad installs them for the mock gpu
|
||||
inputs:
|
||||
emu_cache:
|
||||
description: >-
|
||||
Keep tinygrad's cache db (the emulator's compiled kernels and gate results) between runs under this name, e.g. the model's file name.
|
||||
Empty: no cache.
|
||||
default: ''
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install LLVM 20 and ROCm comgr
|
||||
shell: bash
|
||||
run: |
|
||||
. /etc/os-release
|
||||
curl -fsSL https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc > /dev/null
|
||||
echo "deb http://apt.llvm.org/$VERSION_CODENAME/ llvm-toolchain-$VERSION_CODENAME-20 main" | sudo tee /etc/apt/sources.list.d/llvm.list
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://repo.radeon.com/rocm/rocm.gpg.key | gpg --dearmor | sudo tee /etc/apt/keyrings/rocm.gpg > /dev/null
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/7.1 $VERSION_CODENAME main" | sudo tee /etc/apt/sources.list.d/rocm.list
|
||||
printf 'Package: *\nPin: release o=repo.radeon.com\nPin-Priority: 600\n' | sudo tee /etc/apt/preferences.d/rocm-pin-600
|
||||
sudo apt-get -qq update && sudo apt-get -qq install -y --no-install-recommends libllvm20 clang-20 lld-20 comgr
|
||||
printf '/opt/rocm/lib\n/opt/rocm/lib64\n' | sudo tee /etc/ld.so.conf.d/rocm.conf && sudo ldconfig
|
||||
echo "/usr/lib/llvm-20/bin" >> "$GITHUB_PATH"
|
||||
llvm=$(ls /usr/lib/llvm-20/lib/libLLVM*.so* /usr/lib/*-linux-gnu/libLLVM-20.so* 2>/dev/null | head -1 || true)
|
||||
[ -n "$llvm" ] || { echo "::error::libLLVM 20 not found"; exit 1; }
|
||||
echo "LLVM_PATH=$llvm" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Cache the chestnut's firmware
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/tinygrad/downloads/fw
|
||||
key: chestnut-fw-${{ hashFiles('tinygrad_repo/tinygrad/runtime/autogen/am/fw.py') }}
|
||||
|
||||
- name: Fetch the chestnut's firmware (gitlab times out now and then; the mock boots from it)
|
||||
shell: bash
|
||||
run: |
|
||||
for i in 1 2 3 4 5; do
|
||||
PYTHONPATH="$GITHUB_WORKSPACE/tinygrad_repo" python3 -c "
|
||||
from tinygrad.helpers import fetch_fw
|
||||
from tinygrad.runtime.autogen.am import fw
|
||||
for n, h in fw.hashes.items():
|
||||
if n.startswith(('psp_14_0_2_', 'smu_14_0_2.', 'sdma_7_0_0.', 'gc_12_0_0_')): fetch_fw('amdgpu', n, h)" && exit 0
|
||||
sleep $((i * 10))
|
||||
done
|
||||
echo "::error::could not fetch the chestnut's firmware from gitlab"; exit 1
|
||||
|
||||
- name: runner cpu
|
||||
if: inputs.emu_cache != ''
|
||||
id: cpu
|
||||
shell: bash
|
||||
run: echo "cpu=$( (grep -m1 '^model name' /proc/cpuinfo; grep -m1 '^flags' /proc/cpuinfo) | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Keep the emulator's compiled kernels between runs
|
||||
if: inputs.emu_cache != ''
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/tinygrad/cache.db
|
||||
key: emu-${{ steps.cpu.outputs.cpu }}-${{ hashFiles('tinygrad_repo/tinygrad/**/*.py', 'tinygrad_repo/test/mockgpu/**') }}-${{ inputs.emu_cache }}
|
||||
restore-keys: emu-${{ steps.cpu.outputs.cpu }}-${{ hashFiles('tinygrad_repo/tinygrad/**/*.py', 'tinygrad_repo/test/mockgpu/**') }}-
|
||||
@@ -35,6 +35,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: 'qcom'
|
||||
on_device:
|
||||
description: 'Compile on the tici / chestnut runner instead of on a hosted runner with tinygrad''s qcom / amd mock'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upstream_branch:
|
||||
@@ -59,6 +64,11 @@ on:
|
||||
- qcom
|
||||
- chestnut
|
||||
default: 'qcom'
|
||||
on_device:
|
||||
description: 'Compile on the tici / chestnut runner instead of on a hosted runner with tinygrad''s qcom / amd mock'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
|
||||
run-name: Build model [${{ inputs.custom_name || inputs.upstream_branch }}] from ref [${{ inputs.upstream_branch }}]
|
||||
@@ -153,18 +163,67 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
build_model:
|
||||
runs-on: [self-hosted, "${{ inputs.target_hardware == 'chestnut' && 'chestnut' || 'tici' }}"]
|
||||
runs-on: ${{ inputs.on_device && fromJSON(inputs.target_hardware == 'chestnut' && '["self-hosted","chestnut"]' || '["self-hosted","tici"]') || 'ubuntu-24.04' }}
|
||||
needs: get_model
|
||||
env:
|
||||
MODEL_NAME: ${{ inputs.custom_name || inputs.upstream_branch }} (${{ needs.get_model.outputs.model_date }})
|
||||
REF: ${{ inputs.upstream_branch }}
|
||||
MOCK_BUILD: ${{ !inputs.on_device }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up the hosted mock build
|
||||
if: env.MOCK_BUILD == 'true'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install LLVM 20 and ROCm comgr for the amd mock
|
||||
if: env.MOCK_BUILD == 'true' && inputs.target_hardware == 'chestnut'
|
||||
uses: ./.github/workflows/setup-amd-mock
|
||||
with:
|
||||
emu_cache: build
|
||||
|
||||
- name: Install the amd mock build dependencies
|
||||
if: env.MOCK_BUILD == 'true' && inputs.target_hardware == 'chestnut'
|
||||
run: |
|
||||
python3 -m pip install -q numpy zstandard
|
||||
|
||||
- name: Install the qcom mock build dependencies
|
||||
if: env.MOCK_BUILD == 'true' && inputs.target_hardware != 'chestnut'
|
||||
id: qcom_deps
|
||||
run: |
|
||||
sudo apt-get -qq update && sudo apt-get -qq install -y --no-install-recommends qemu-user-static aria2
|
||||
python3 -m pip install -q numpy zstandard tinymesa==25.2.7.2
|
||||
echo "qemu=$(dpkg-query -W -f='${Version}' qemu-user-static)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Compiler check passed before with all of it identical?
|
||||
if: env.MOCK_BUILD == 'true' && inputs.target_hardware != 'chestnut'
|
||||
id: qcom_compiler
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: ~/.qcom_compiler_parity.passed
|
||||
key: qcom-compiler-parity-${{ steps.qcom_deps.outputs.qemu }}-${{ hashFiles('tinygrad_repo/test/device/qcom/compiler_parity.py', 'tinygrad_repo/test/mockgpu/qcom/a630_compiled.pkl.xz', 'tinygrad_repo/tinygrad/runtime/support/compiler_qcom.py') }}
|
||||
|
||||
- name: Check the off-device qcom compiler builds the a630's bytes
|
||||
if: env.MOCK_BUILD == 'true' && inputs.target_hardware != 'chestnut' && steps.qcom_compiler.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
# every kernel an a630 has compiled must compile to the same bytes here before a model is built here
|
||||
PYTHONPATH="$TINYGRAD_PATH" python3 "$TINYGRAD_PATH/test/device/qcom/compiler_parity.py" check "$TINYGRAD_PATH/test/mockgpu/qcom/a630_compiled.pkl.xz"
|
||||
touch ~/.qcom_compiler_parity.passed
|
||||
|
||||
- name: Remember the compiler check passed
|
||||
if: env.MOCK_BUILD == 'true' && inputs.target_hardware != 'chestnut' && steps.qcom_compiler.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: ~/.qcom_compiler_parity.passed
|
||||
key: ${{ steps.qcom_compiler.outputs.cache-primary-key }}
|
||||
|
||||
- name: Set environment variables
|
||||
if: env.MOCK_BUILD != 'true'
|
||||
id: set-env
|
||||
run: |
|
||||
# Set up common environment
|
||||
@@ -177,6 +236,7 @@ jobs:
|
||||
printenv >> $GITHUB_ENV
|
||||
|
||||
- name: Setup build environment
|
||||
if: env.MOCK_BUILD != 'true'
|
||||
run: |
|
||||
mkdir -p "${BUILD_DIR}/"
|
||||
sudo find $BUILD_DIR/ -mindepth 1 -delete
|
||||
@@ -198,21 +258,28 @@ jobs:
|
||||
|
||||
- name: Build Model
|
||||
run: |
|
||||
source /etc/profile
|
||||
export UV_PROJECT_ENVIRONMENT=${HOME}/venv
|
||||
export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
|
||||
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
|
||||
if [ "$MOCK_BUILD" = "true" ]; then
|
||||
TASKSET=""
|
||||
export CHECK_OOB=0 GMMU=0 # as test/mockgpu runs the amd mock
|
||||
else
|
||||
source /etc/profile
|
||||
export UV_PROJECT_ENVIRONMENT=${HOME}/venv
|
||||
export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
|
||||
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
|
||||
TASKSET="taskset -c 7"
|
||||
fi
|
||||
export PYTHONPATH="${PYTHONPATH}:${{ env.TINYGRAD_PATH }}:${{ github.workspace }}"
|
||||
|
||||
COMPILE_MODELD="${{ github.workspace }}/openpilot/sunnypilot/modeld_v2/compile_modeld.py"
|
||||
MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')")
|
||||
CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')")
|
||||
|
||||
TG_FLAGS_QCOM="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 OPENPILOT_HACKS=1"
|
||||
MOCK=$([ "$MOCK_BUILD" = "true" ] && echo MOCK || true)
|
||||
TG_FLAGS_QCOM="DEV=${MOCK:+MOCK+}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 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32"
|
||||
TG_FLAGS="DEBUG=1 DEV=${MOCK}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"
|
||||
@@ -227,14 +294,16 @@ jobs:
|
||||
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
|
||||
ON_MOCK=""
|
||||
if [ "$MOCK_BUILD" = "true" ]; then ON_MOCK="env PYTHONPATH=$TINYGRAD_PATH python3 $TINYGRAD_PATH/test/mockgpu/usb_server.py"; fi
|
||||
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 \
|
||||
$ON_MOCK env DEV=USB+AMD PYTHONPATH="$UPSTREAM_TG:${{ github.workspace }}" $TASKSET 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 \
|
||||
env ${TG_FLAGS} $TASKSET 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."
|
||||
@@ -284,7 +353,7 @@ jobs:
|
||||
|
||||
if [ -n "$MODEL_TYPE" ]; then
|
||||
echo "Detected: $MODEL_TYPE -> $OUTPUT_PKL"
|
||||
env ${TG_FLAGS} taskset -c 7 python3 "$COMPILE_MODELD" \
|
||||
env ${TG_FLAGS} $TASKSET python3 "$COMPILE_MODELD" \
|
||||
--model-type $MODEL_TYPE \
|
||||
--model-size $MODEL_SIZE \
|
||||
--camera-resolutions $CAMERA_RES \
|
||||
@@ -293,6 +362,13 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Replay its check frame on the mock and require the result bit for bit
|
||||
if: env.MOCK_BUILD == 'true'
|
||||
env:
|
||||
DEV: ${{ inputs.target_hardware == 'chestnut' && 'MOCKUSB+AMD' || 'MOCK+QCOM' }}
|
||||
PYTHONPATH: ${{ env.TINYGRAD_PATH }}:${{ github.workspace }}
|
||||
run: python3 -m openpilot.sunnypilot.modeld_v2.mock_parity check "${{ env.MODELS_DIR }}/${{ inputs.target_hardware == 'chestnut' && 'big_' || '' }}driving_tinygrad.pkl"
|
||||
|
||||
- name: Prepare Output
|
||||
run: |
|
||||
sudo rm -rf ${{ env.OUTPUT_DIR }}
|
||||
@@ -304,7 +380,7 @@ jobs:
|
||||
--include='/${{ inputs.target_hardware == 'chestnut' && 'big_' || '' }}driving_tinygrad.pkl' \
|
||||
--exclude='*' \
|
||||
--delete-excluded \
|
||||
--chown=comma:comma \
|
||||
$([ "$MOCK_BUILD" = "true" ] || echo --chown=comma:comma) \
|
||||
${{ env.MODELS_DIR }}/ ${{ env.OUTPUT_DIR }}/
|
||||
|
||||
python3 "${{ github.workspace }}/release/ci/model_generator.py" \
|
||||
@@ -333,7 +409,7 @@ jobs:
|
||||
path: ${{ env.OUTPUT_DIR }}/artifact_name.txt
|
||||
|
||||
- name: Re-enable powersave
|
||||
if: always()
|
||||
if: always() && env.MOCK_BUILD != 'true'
|
||||
run: |
|
||||
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
|
||||
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
|
||||
|
||||
@@ -3,13 +3,74 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'tinygrad_repo'
|
||||
- 'openpilot/sunnypilot/modeld_v2/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CI: 1
|
||||
GIT_CONFIG_COUNT: 1
|
||||
GIT_CONFIG_KEY_0: lfs.fetchexclude
|
||||
GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl
|
||||
|
||||
jobs:
|
||||
plain-artifact:
|
||||
name: Plain artifact round trip and pickle converter
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}
|
||||
TINYGRAD_TESTS: 1
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- run: ./tools/op.sh setup
|
||||
- name: Build openpilot
|
||||
run: scons -j$(nproc)
|
||||
- name: Compile, load, cache and fuse a model with the pinned tinygrad
|
||||
env:
|
||||
DEV: CPU
|
||||
run: tools/op.sh test -v openpilot/sunnypilot/modeld_v2/tests/test_plain_artifact.py
|
||||
- uses: ./.github/workflows/setup-amd-mock
|
||||
- name: Convert a pickled USB+AMD jit and replay it under tinygrad's amd mock
|
||||
env:
|
||||
DEV: MOCKUSB+AMD
|
||||
CHECK_OOB: 0
|
||||
GMMU: 0
|
||||
run: tools/op.sh test -v openpilot/sunnypilot/modeld_v2/tests/test_convert_upstream_pkl.py
|
||||
- name: Resolve commaai's tinygrad pin, which their precompiled chestnut model is built and converted with
|
||||
id: upstream-tg
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
read -r sha url < <(gh api repos/commaai/openpilot/contents/tinygrad_repo --jq '.sha + " " + .submodule_git_url')
|
||||
echo "commaai/openpilot master pins $url@$sha, ours is $(git rev-parse HEAD:tinygrad_repo)"
|
||||
dir="$RUNNER_TEMP/upstream_tinygrad"
|
||||
git init -q "$dir" && git -C "$dir" fetch -q --depth 1 "$url" "$sha" && git -C "$dir" checkout -q FETCH_HEAD
|
||||
echo "path=$dir" >> "$GITHUB_OUTPUT"
|
||||
- name: Convert a pickled USB+AMD jit under commaai's tinygrad, as the chestnut model build does
|
||||
env:
|
||||
DEV: MOCKUSB+AMD
|
||||
CHECK_OOB: 0
|
||||
GMMU: 0
|
||||
PYTHONPATH: ${{ steps.upstream-tg.outputs.path }}:${{ github.workspace }}
|
||||
run: tools/op.sh test -v openpilot/sunnypilot/modeld_v2/tests/test_convert_upstream_pkl.py
|
||||
- name: Where tinygrad moved, for the helper or failure point named above
|
||||
if: failure() && github.event_name == 'pull_request'
|
||||
run: |
|
||||
git fetch -q --depth=1 origin "${{ github.base_ref }}"
|
||||
old=$(git rev-parse "origin/${{ github.base_ref }}:tinygrad_repo") new=$(git rev-parse HEAD:tinygrad_repo)
|
||||
git -C tinygrad_repo fetch -q origin "$old" || true
|
||||
echo "tinygrad_repo $old -> $new, in what helpers.py and plain_artifact_compiler.py read:"
|
||||
git -C tinygrad_repo diff "$old" "$new" -- tinygrad/uop/ops.py tinygrad/engine/jit.py tinygrad/engine/realize.py tinygrad/device.py \
|
||||
tinygrad/helpers.py tinygrad/dtype.py tinygrad/renderer/__init__.py
|
||||
echo "and in what convert_upstream_pkl.py decodes:"
|
||||
git -C tinygrad_repo diff --stat "$old" "$new" -- tinygrad/runtime/autogen/am/pm4_nv.py tinygrad/runtime/autogen/amd_gpu.py
|
||||
git -C tinygrad_repo diff "$old" "$new" -- tinygrad/runtime/support/hcq2.py tinygrad/runtime/support/usb.py tinygrad/runtime/ops_amd.py
|
||||
|
||||
generate-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
models: ${{ steps.set-matrix.outputs.models }}
|
||||
chestnut: ${{ steps.set-matrix.outputs.chestnut }}
|
||||
qcom: ${{ steps.set-matrix.outputs.qcom }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Fetch and Parse json
|
||||
@@ -21,16 +82,22 @@ jobs:
|
||||
with open("openpilot/sunnypilot/models/fetcher.py", "r") as f:
|
||||
urls = re.findall(r"MODEL_URL(?:_CHESTNUT)?\s*=\s*[\"'"'"']([^\"'"'"']+)[\"'"'"']", f.read())
|
||||
|
||||
artifacts = []
|
||||
artifacts, chestnut, qcom = [], [], []
|
||||
for url in urls:
|
||||
data = json.loads(urllib.request.urlopen(url).read())
|
||||
for bundle in data.get("bundles", []):
|
||||
for model in bundle.get("models", []):
|
||||
if "artifact" in model:
|
||||
artifacts.append(model["artifact"])
|
||||
if "chestnut" in url:
|
||||
chestnut.append(model["artifact"])
|
||||
elif "tinygrad" in model["artifact"]["file_name"]:
|
||||
qcom.append(model["artifact"])
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
||||
f.write(f"models={json.dumps(artifacts)}\n")
|
||||
f.write(f"chestnut={json.dumps(chestnut)}\n")
|
||||
f.write(f"qcom={json.dumps(qcom)}\n")
|
||||
'
|
||||
|
||||
test-model:
|
||||
@@ -67,3 +134,81 @@ jobs:
|
||||
export PYTHONPATH="$GITHUB_WORKSPACE/tinygrad_repo:$GITHUB_WORKSPACE:$PYTHONPATH"
|
||||
python3 -m pip uninstall -y tinygrad || uv pip uninstall -y tinygrad --system || true
|
||||
python3 -m pytest openpilot/sunnypilot/modeld_v2/tests/test_models.py
|
||||
|
||||
chestnut-mock-parity:
|
||||
name: Mock parity ${{ matrix.artifact.file_name }}
|
||||
needs: generate-matrix
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
artifact: ${{ fromJson(needs.generate-matrix.outputs.chestnut) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: ./.github/workflows/setup-amd-mock
|
||||
with:
|
||||
emu_cache: ${{ matrix.artifact.file_name }}
|
||||
- run: python3 -m pip install -q numpy
|
||||
- name: Download model
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
MODEL_URL: ${{ matrix.artifact.download_uri.url }}
|
||||
MODEL_SHA: ${{ matrix.artifact.download_uri.sha256 }}
|
||||
shell: bash
|
||||
run: |
|
||||
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 "$RUNNER_TEMP/model.pkl" "$MODEL_URL"
|
||||
if [ -n "$MODEL_SHA" ]; then echo "$MODEL_SHA $RUNNER_TEMP/model.pkl" | sha256sum -c -; fi
|
||||
- name: Replay its check frame on the amd mock and require the chestnut's result bit for bit
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}
|
||||
run: python3 -m openpilot.sunnypilot.modeld_v2.mock_parity check "$RUNNER_TEMP/model.pkl"
|
||||
|
||||
qcom-mock-parity:
|
||||
name: QCOM mock parity ${{ matrix.artifact.file_name }}
|
||||
needs: generate-matrix
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
artifact: ${{ fromJson(needs.generate-matrix.outputs.qcom) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- run: python3 -m pip install -q numpy tinymesa==25.2.7.2
|
||||
- name: Download model
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
MODEL_URL: ${{ matrix.artifact.download_uri.url }}
|
||||
MODEL_SHA: ${{ matrix.artifact.download_uri.sha256 }}
|
||||
shell: bash
|
||||
run: |
|
||||
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 "$RUNNER_TEMP/model.pkl" "$MODEL_URL"
|
||||
if [ -n "$MODEL_SHA" ]; then echo "$MODEL_SHA $RUNNER_TEMP/model.pkl" | sha256sum -c -; fi
|
||||
- name: Replay its check frame on the qcom mock and require the comma device's result bit for bit
|
||||
env:
|
||||
DEV: MOCK+QCOM
|
||||
PYTHONPATH: ${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}
|
||||
run: python3 -m openpilot.sunnypilot.modeld_v2.mock_parity check "$RUNNER_TEMP/model.pkl"
|
||||
|
||||
chestnut-mock-build-parity:
|
||||
name: Chestnut build on the amd mock = chestnut runner build
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: ./.github/workflows/setup-amd-mock
|
||||
with:
|
||||
emu_cache: chestnut-build-parity
|
||||
- run: python3 -m pip install -q numpy zstandard
|
||||
- uses: ./.github/workflows/chestnut-build-parity
|
||||
|
||||
@@ -50,6 +50,12 @@ jobs:
|
||||
timeout-minutes: 3
|
||||
run: TARGET_DIR=$STRIPPED_DIR tools/release/build_stripped.sh
|
||||
- run: ./tools/op.sh setup
|
||||
- name: Restore scons cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: /tmp/scons_cache
|
||||
key: scons-${{ runner.os }}-${{ github.job }}-${{ github.sha }}
|
||||
restore-keys: scons-${{ runner.os }}-${{ github.job }}-
|
||||
- name: Build openpilot and run checks
|
||||
timeout-minutes: 30
|
||||
working-directory: ${{ env.STRIPPED_DIR }}
|
||||
@@ -89,6 +95,12 @@ jobs:
|
||||
FILTERED=$(echo "$PATH" | tr ':' '\n' | grep -v '/opt/homebrew' | tr '\n' ':')
|
||||
echo "PATH=${FILTERED}/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" >> $GITHUB_ENV
|
||||
- run: ./tools/op.sh setup
|
||||
- name: Restore scons cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: /tmp/scons_cache
|
||||
key: scons-${{ runner.os }}-${{ github.job }}-${{ github.sha }}
|
||||
restore-keys: scons-${{ runner.os }}-${{ github.job }}-
|
||||
- name: Building openpilot
|
||||
run: scons
|
||||
|
||||
@@ -121,6 +133,12 @@ jobs:
|
||||
- name: Install raylib headless dependencies
|
||||
if: ${{ !contains(runner.name, 'nsc') }}
|
||||
run: sudo apt-get install -y --no-install-recommends libgles2 libegl1
|
||||
- name: Restore scons cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: /tmp/scons_cache
|
||||
key: scons-${{ runner.os }}-${{ github.job }}-${{ github.sha }}
|
||||
restore-keys: scons-${{ runner.os }}-${{ github.job }}-
|
||||
- name: Build openpilot
|
||||
run: scons
|
||||
- name: Run unit tests
|
||||
@@ -140,6 +158,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- run: ./tools/op.sh setup
|
||||
- name: Restore scons cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: /tmp/scons_cache
|
||||
key: scons-${{ runner.os }}-${{ github.job }}-${{ github.sha }}
|
||||
restore-keys: scons-${{ runner.os }}-${{ github.job }}-
|
||||
- name: Build openpilot
|
||||
run: scons
|
||||
- name: Run replay
|
||||
@@ -228,6 +252,12 @@ jobs:
|
||||
- name: Install raylib headless dependencies
|
||||
if: ${{ !contains(runner.name, 'nsc') }}
|
||||
run: sudo apt-get install -y --no-install-recommends libgles2 libegl1
|
||||
- name: Restore scons cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: /tmp/scons_cache
|
||||
key: scons-${{ runner.os }}-${{ github.job }}-${{ github.sha }}
|
||||
restore-keys: scons-${{ runner.os }}-${{ github.job }}-
|
||||
- name: Build openpilot
|
||||
run: scons
|
||||
- name: Create UI Report
|
||||
|
||||
+115
-108
@@ -4,10 +4,11 @@
|
||||
|
||||
A supported vehicle is one that just works when you install a comma device. All supported cars provide a better experience than any stock system. Supported vehicles reference the US market unless otherwise specified.
|
||||
|
||||
# 345 Supported Cars
|
||||
# 351 Supported Cars
|
||||
|
||||
|Make|Model|Supported Package|ACC|No ACC accel below|No ALC below|Steering Torque|Resume from stop|<a href="##"><img width=2000></a>Hardware Needed<br> |Video|Setup Video|
|
||||
|---|---|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
|Acura|ADX 2025-26|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Acura ADX 2025-26">Buy Here</a></sub></details>|||
|
||||
|Acura|ILX 2016-18|Technology Plus Package or AcuraWatch Plus|openpilot|26 mph|25 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Acura ILX 2016-18">Buy Here</a></sub></details>|||
|
||||
|Acura|ILX 2019|All|openpilot|26 mph|25 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Acura ILX 2019">Buy Here</a></sub></details>|||
|
||||
|Acura|MDX 2022-24|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|43 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Acura MDX 2022-24">Buy Here</a></sub></details>|||
|
||||
@@ -16,12 +17,12 @@ A supported vehicle is one that just works when you install a comma device. All
|
||||
|Acura|RDX 2019-21|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|3 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Acura RDX 2019-21">Buy Here</a></sub></details>|||
|
||||
|Acura|TLX 2021-22|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Acura TLX 2021-22">Buy Here</a></sub></details>|||
|
||||
|Acura|TLX 2025|All|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch C connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Acura TLX 2025">Buy Here</a></sub></details>|||
|
||||
|Audi[<sup>12</sup>](#footnotes)|A3 2014-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Audi A3 2014-19">Buy Here</a></sub></details>|||
|
||||
|Audi[<sup>12</sup>](#footnotes)|A3 Sportback e-tron 2017-18|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Audi A3 Sportback e-tron 2017-18">Buy Here</a></sub></details>|||
|
||||
|Audi[<sup>12</sup>](#footnotes)|Q2 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Audi Q2 2018">Buy Here</a></sub></details>|||
|
||||
|Audi[<sup>12</sup>](#footnotes)|Q3 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Audi Q3 2019-24">Buy Here</a></sub></details>|||
|
||||
|Audi[<sup>12</sup>](#footnotes)|RS3 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Audi RS3 2018">Buy Here</a></sub></details>|||
|
||||
|Audi[<sup>12</sup>](#footnotes)|S3 2015-17|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Audi S3 2015-17">Buy Here</a></sub></details>|||
|
||||
|Audi[<sup>13</sup>](#footnotes)|A3 2014-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Audi A3 2014-19">Buy Here</a></sub></details>|||
|
||||
|Audi[<sup>13</sup>](#footnotes)|A3 Sportback e-tron 2017-18|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Audi A3 Sportback e-tron 2017-18">Buy Here</a></sub></details>|||
|
||||
|Audi[<sup>13</sup>](#footnotes)|Q2 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Audi Q2 2018">Buy Here</a></sub></details>|||
|
||||
|Audi[<sup>13</sup>](#footnotes)|Q3 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Audi Q3 2019-24">Buy Here</a></sub></details>|||
|
||||
|Audi[<sup>13</sup>](#footnotes)|RS3 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Audi RS3 2018">Buy Here</a></sub></details>|||
|
||||
|Audi[<sup>13</sup>](#footnotes)|S3 2015-17|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Audi S3 2015-17">Buy Here</a></sub></details>|||
|
||||
|Chevrolet|Bolt EUV 2022-23|Premier or Premier Redline Trim, without Super Cruise Package|openpilot available[<sup>1</sup>](#footnotes)|3 mph|6 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 GM connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Chevrolet Bolt EUV 2022-23">Buy Here</a></sub></details>|<a href="https://youtu.be/xvwzGMUA210" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Chevrolet|Bolt EV 2022-23|2LT Trim with Adaptive Cruise Control Package|openpilot available[<sup>1</sup>](#footnotes)|3 mph|6 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 GM connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Chevrolet Bolt EV 2022-23">Buy Here</a></sub></details>|||
|
||||
|Chevrolet|Equinox 2019-22|Adaptive Cruise Control (ACC)|openpilot available[<sup>1</sup>](#footnotes)|3 mph|6 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 GM connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Chevrolet Equinox 2019-22">Buy Here</a></sub></details>|||
|
||||
@@ -33,8 +34,8 @@ A supported vehicle is one that just works when you install a comma device. All
|
||||
|Chrysler|Pacifica Hybrid 2017-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 FCA connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Chrysler Pacifica Hybrid 2017-18">Buy Here</a></sub></details>|||
|
||||
|Chrysler|Pacifica Hybrid 2019-25|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 FCA connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Chrysler Pacifica Hybrid 2019-25">Buy Here</a></sub></details>|||
|
||||
|comma|body|All|openpilot|0 mph|0 mph|[](##)|[](##)|None|<a href="https://youtu.be/VT-i3yRsX2s?t=2736" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|CUPRA[<sup>12</sup>](#footnotes)|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=CUPRA Ateca 2018-23">Buy Here</a></sub></details>|||
|
||||
|CUPRA|Born 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[<sup>16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW MEB connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=CUPRA Born 2021-23">Buy Here</a></sub></details>|||
|
||||
|CUPRA[<sup>13</sup>](#footnotes)|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=CUPRA Ateca 2018-23">Buy Here</a></sub></details>|||
|
||||
|CUPRA|Born 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[<sup>17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW MEB connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=CUPRA Born 2021-23">Buy Here</a></sub></details>|||
|
||||
|Dodge|Durango 2020-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 FCA connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Dodge Durango 2020-21">Buy Here</a></sub></details>|||
|
||||
|Ford|Bronco Sport 2021-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Ford Q3 connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Ford Bronco Sport 2021-24">Buy Here</a></sub></details>|||
|
||||
|Ford|Escape 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Ford Q3 connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Ford Escape 2020-22">Buy Here</a></sub></details>|||
|
||||
@@ -86,7 +87,7 @@ A supported vehicle is one that just works when you install a comma device. All
|
||||
|Honda|Civic 2022-26|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic 2022-26">Buy Here</a></sub></details>|<a href="https://youtu.be/ytiOT5lcp6Q" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Honda|Civic Hatchback 2017-18|Honda Sensing|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|12 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback 2017-18">Buy Here</a></sub></details>|||
|
||||
|Honda|Civic Hatchback 2019-21|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|12 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback 2019-21">Buy Here</a></sub></details>|||
|
||||
|Honda|Civic Hatchback 2022-24|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback 2022-24">Buy Here</a></sub></details>|<a href="https://youtu.be/ytiOT5lcp6Q" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Honda|Civic Hatchback 2022-26|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback 2022-26">Buy Here</a></sub></details>|<a href="https://youtu.be/ytiOT5lcp6Q" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Honda|Civic Hatchback Hybrid 2025-26|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback Hybrid 2025-26">Buy Here</a></sub></details>|||
|
||||
|Honda|Civic Hatchback Hybrid (Europe only) 2023|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback Hybrid (Europe only) 2023">Buy Here</a></sub></details>|||
|
||||
|Honda|Civic Hybrid 2025-26|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hybrid 2025-26">Buy Here</a></sub></details>|||
|
||||
@@ -94,7 +95,7 @@ A supported vehicle is one that just works when you install a comma device. All
|
||||
|Honda|CR-V 2017-22|Honda Sensing|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|15 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda CR-V 2017-22">Buy Here</a></sub></details>|||
|
||||
|Honda|CR-V 2023-26|All|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch C connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda CR-V 2023-26">Buy Here</a></sub></details>|||
|
||||
|Honda|CR-V Hybrid 2017-22|Honda Sensing|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|12 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda CR-V Hybrid 2017-22">Buy Here</a></sub></details>|||
|
||||
|Honda|CR-V Hybrid 2023-26|All|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch C connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda CR-V Hybrid 2023-26">Buy Here</a></sub></details>|||
|
||||
|Honda|CR-V Hybrid 2023-27|All|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch C connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda CR-V Hybrid 2023-27">Buy Here</a></sub></details>|||
|
||||
|Honda|e 2020|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|3 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda e 2020">Buy Here</a></sub></details>|||
|
||||
|Honda|Fit 2018-20|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Fit 2018-20">Buy Here</a></sub></details>|||
|
||||
|Honda|Freed 2020|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Freed 2020">Buy Here</a></sub></details>|||
|
||||
@@ -111,7 +112,9 @@ A supported vehicle is one that just works when you install a comma device. All
|
||||
|Honda|Passport 2026|All|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch C connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Passport 2026">Buy Here</a></sub></details>|||
|
||||
|Honda|Pilot 2016-22|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Pilot 2016-22">Buy Here</a></sub></details>|||
|
||||
|Honda|Pilot 2023-25|All|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch C connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Pilot 2023-25">Buy Here</a></sub></details>|||
|
||||
|Honda|Ridgeline 2017-25|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Ridgeline 2017-25">Buy Here</a></sub></details>|||
|
||||
|Honda|Pilot 2026|All|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch C connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Pilot 2026">Buy Here</a></sub></details>|||
|
||||
|Honda|Prelude 2026|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Prelude 2026">Buy Here</a></sub></details>|||
|
||||
|Honda|Ridgeline 2017-26|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Ridgeline 2017-26">Buy Here</a></sub></details>|||
|
||||
|Hyundai|Azera 2022|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai K connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Hyundai Azera 2022">Buy Here</a></sub></details>|||
|
||||
|Hyundai|Azera Hybrid 2019|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai C connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Hyundai Azera Hybrid 2019">Buy Here</a></sub></details>|||
|
||||
|Hyundai|Azera Hybrid 2020|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai K connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Hyundai Azera Hybrid 2020">Buy Here</a></sub></details>|||
|
||||
@@ -163,6 +166,7 @@ A supported vehicle is one that just works when you install a comma device. All
|
||||
|Kia|Carnival 2022-24|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Carnival 2022-24">Buy Here</a></sub></details>|||
|
||||
|Kia|Carnival (China only) 2023|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai K connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Carnival (China only) 2023">Buy Here</a></sub></details>|||
|
||||
|Kia|Ceed 2019-21|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai E connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Ceed 2019-21">Buy Here</a></sub></details>|||
|
||||
|Kia|Ceed Plug-in Hybrid 2021|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Ceed Plug-in Hybrid 2021">Buy Here</a></sub></details>|||
|
||||
|Kia|EV6 (Southeast Asia only) 2022-24|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai P connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia EV6 (Southeast Asia only) 2022-24">Buy Here</a></sub></details>|||
|
||||
|Kia|EV6 (with HDA II) 2022-24|Highway Driving Assist II|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai P connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia EV6 (with HDA II) 2022-24">Buy Here</a></sub></details>|||
|
||||
|Kia|EV6 (without HDA II) 2022-24|Highway Driving Assist|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai L connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia EV6 (without HDA II) 2022-24">Buy Here</a></sub></details>|||
|
||||
@@ -188,7 +192,7 @@ A supported vehicle is one that just works when you install a comma device. All
|
||||
|Kia|Niro Plug-in Hybrid 2022|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai F connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Niro Plug-in Hybrid 2022">Buy Here</a></sub></details>|||
|
||||
|Kia|Optima 2017|Advanced Smart Cruise Control|Stock|0 mph|32 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Optima 2017">Buy Here</a></sub></details>|||
|
||||
|Kia|Optima 2019-20|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai G connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Optima 2019-20">Buy Here</a></sub></details>|||
|
||||
|Kia|Optima Hybrid 2019|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai H connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Optima Hybrid 2019">Buy Here</a></sub></details>|||
|
||||
|Kia|Optima Hybrid 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai H connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Optima Hybrid 2019">Buy Here</a></sub></details>|||
|
||||
|Kia|Seltos 2021|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Seltos 2021">Buy Here</a></sub></details>|||
|
||||
|Kia|Sorento 2018|Advanced Smart Cruise Control & LKAS|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai E connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Sorento 2018">Buy Here</a></sub></details>|<a href="https://www.youtube.com/watch?v=Fkh3s6WHJz8" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Kia|Sorento 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai E connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Sorento 2019">Buy Here</a></sub></details>|<a href="https://www.youtube.com/watch?v=Fkh3s6WHJz8" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
@@ -197,6 +201,7 @@ A supported vehicle is one that just works when you install a comma device. All
|
||||
|Kia|Sorento Plug-in Hybrid 2022-23|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Sorento Plug-in Hybrid 2022-23">Buy Here</a></sub></details>|||
|
||||
|Kia|Sportage 2023-24|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai N connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Sportage 2023-24">Buy Here</a></sub></details>|||
|
||||
|Kia|Sportage Hybrid 2023|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai N connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Sportage Hybrid 2023">Buy Here</a></sub></details>|||
|
||||
|Kia|Sportage Plug-in Hybrid 2023|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai N connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Sportage Plug-in Hybrid 2023">Buy Here</a></sub></details>|||
|
||||
|Kia|Stinger 2018-20|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai C connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Stinger 2018-20">Buy Here</a></sub></details>|<a href="https://www.youtube.com/watch?v=MJ94qoofYw0" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Kia|Stinger 2022-23|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai K connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Stinger 2022-23">Buy Here</a></sub></details>|||
|
||||
|Kia|Telluride 2020-22|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Hyundai H connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Telluride 2020-22">Buy Here</a></sub></details>|||
|
||||
@@ -225,51 +230,52 @@ A supported vehicle is one that just works when you install a comma device. All
|
||||
|Lexus|UX Hybrid 2019-24|All|openpilot|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Lexus UX Hybrid 2019-24">Buy Here</a></sub></details>|||
|
||||
|Lincoln|Aviator 2020-24|Co-Pilot360 Plus|openpilot|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Ford Q3 connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Lincoln Aviator 2020-24">Buy Here</a></sub></details>|||
|
||||
|Lincoln|Aviator Plug-in Hybrid 2020-24|Co-Pilot360 Plus|openpilot|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Ford Q3 connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Lincoln Aviator Plug-in Hybrid 2020-24">Buy Here</a></sub></details>|||
|
||||
|MAN[<sup>12</sup>](#footnotes)|eTGE 2020-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|31 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=MAN eTGE 2020-24">Buy Here</a></sub></details>|<a href="https://youtu.be/4100gLeabmo" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|MAN[<sup>12</sup>](#footnotes)|TGE 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|31 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=MAN TGE 2017-24">Buy Here</a></sub></details>|<a href="https://youtu.be/4100gLeabmo" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Mazda|CX-5 2022-25|All|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Mazda connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Mazda CX-5 2022-25">Buy Here</a></sub></details>|||
|
||||
|Mazda|CX-9 2021-23|All|Stock|0 mph|28 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Mazda connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Mazda CX-9 2021-23">Buy Here</a></sub></details>|<a href="https://youtu.be/dA3duO4a0O4" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Nissan[<sup>6</sup>](#footnotes)|Altima 2019-24|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Nissan B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Nissan Altima 2019-24">Buy Here</a></sub></details>|||
|
||||
|Nissan[<sup>6</sup>](#footnotes)|Leaf 2018-23|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Nissan A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Nissan Leaf 2018-23">Buy Here</a></sub></details>|<a href="https://youtu.be/vaMbtAh_0cY" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Nissan[<sup>6</sup>](#footnotes)|Leaf IC 2018-23|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Nissan A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Nissan Leaf IC 2018-23">Buy Here</a></sub></details>|<a href="https://youtu.be/vaMbtAh_0cY" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Nissan[<sup>6</sup>](#footnotes)|Rogue 2018-20|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Nissan A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Nissan Rogue 2018-20">Buy Here</a></sub></details>|||
|
||||
|Nissan[<sup>6</sup>](#footnotes)|X-Trail 2017|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Nissan A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Nissan X-Trail 2017">Buy Here</a></sub></details>|||
|
||||
|MAN[<sup>13</sup>](#footnotes)|eTGE 2020-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|31 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=MAN eTGE 2020-24">Buy Here</a></sub></details>|<a href="https://youtu.be/4100gLeabmo" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|MAN[<sup>13</sup>](#footnotes)|TGE 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|31 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=MAN TGE 2017-24">Buy Here</a></sub></details>|<a href="https://youtu.be/4100gLeabmo" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Mazda[<sup>6</sup>](#footnotes)|CX-5 2022-25|All|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Mazda connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Mazda CX-5 2022-25">Buy Here</a></sub></details>|||
|
||||
|Mazda[<sup>6</sup>](#footnotes)|CX-9 2021-23|All|Stock|0 mph|28 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Mazda connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Mazda CX-9 2021-23">Buy Here</a></sub></details>|<a href="https://youtu.be/dA3duO4a0O4" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Nissan[<sup>7</sup>](#footnotes)|Altima 2019-24|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Nissan B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Nissan Altima 2019-24">Buy Here</a></sub></details>|||
|
||||
|Nissan[<sup>7</sup>](#footnotes)|Leaf 2018-23|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Nissan A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Nissan Leaf 2018-23">Buy Here</a></sub></details>|<a href="https://youtu.be/vaMbtAh_0cY" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Nissan[<sup>7</sup>](#footnotes)|Leaf IC 2018-23|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Nissan A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Nissan Leaf IC 2018-23">Buy Here</a></sub></details>|<a href="https://youtu.be/vaMbtAh_0cY" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Nissan[<sup>7</sup>](#footnotes)|Rogue 2018-20|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Nissan A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Nissan Rogue 2018-20">Buy Here</a></sub></details>|||
|
||||
|Nissan[<sup>7</sup>](#footnotes)|X-Trail 2017|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 Nissan A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Nissan X-Trail 2017">Buy Here</a></sub></details>|||
|
||||
|Ram|1500 2019-24|Adaptive Cruise Control (ACC)|Stock|32 mph|1 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Ram connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Ram 1500 2019-24">Buy Here</a></sub></details>|||
|
||||
|Ram|2500 2020-24|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Ram connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Ram 2500 2020-24">Buy Here</a></sub></details>|||
|
||||
|Ram|3500 2019-22|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Ram connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Ram 3500 2019-22">Buy Here</a></sub></details>|||
|
||||
|Rivian|R1S 2022-24|All|openpilot|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Rivian A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Rivian R1S 2022-24">Buy Here</a></sub></details>|<a href="https://youtu.be/dflSSGQwYNc" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>|<a href="https://youtu.be/uaISd1j7Z4U" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>|
|
||||
|Rivian|R1S 2025|All|openpilot|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Rivian B connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Rivian R1S 2025">Buy Here</a></sub></details>|||
|
||||
|Rivian|R1T 2022-24|All|openpilot|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Rivian A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Rivian R1T 2022-24">Buy Here</a></sub></details>|<a href="https://youtu.be/dflSSGQwYNc" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>|<a href="https://youtu.be/uaISd1j7Z4U" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>|
|
||||
|Rivian|R1T 2025|All|openpilot|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Rivian B connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Rivian R1T 2025">Buy Here</a></sub></details>|||
|
||||
|SEAT[<sup>12</sup>](#footnotes)|Ateca 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=SEAT Ateca 2016-23">Buy Here</a></sub></details>|||
|
||||
|SEAT[<sup>12</sup>](#footnotes)|Leon 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=SEAT Leon 2014-20">Buy Here</a></sub></details>|||
|
||||
|Subaru|Ascent 2019-21|All[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Ascent 2019-21">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Crosstrek 2018-19|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Crosstrek 2018-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|<a href="https://youtu.be/Agww7oE1k-s?t=26" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Subaru|Crosstrek 2020-23|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Crosstrek 2020-23">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Forester 2017-18|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Forester 2017-18">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Forester 2019-21|All[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Forester 2019-21">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Impreza 2017-19|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Impreza 2017-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Impreza 2020-22|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Impreza 2020-22">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Legacy 2015-18|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Legacy 2015-18">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Legacy 2020-22|All[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru B connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Legacy 2020-22">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Outback 2015-17|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Outback 2015-17">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Outback 2018-19|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Outback 2018-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Outback 2020-22|All[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru B connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Outback 2020-22">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|XV 2018-19|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru XV 2018-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|<a href="https://youtu.be/Agww7oE1k-s?t=26" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Subaru|XV 2020-21|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru XV 2020-21">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Škoda|Fabia 2022-23[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Fabia 2022-23">Buy Here</a></sub></details>[<sup>17</sup>](#footnotes)|||
|
||||
|Škoda|Kamiq 2021-23[<sup>13,15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Kamiq 2021-23">Buy Here</a></sub></details>[<sup>17</sup>](#footnotes)|||
|
||||
|Škoda[<sup>12</sup>](#footnotes)|Karoq 2019-23[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Karoq 2019-23">Buy Here</a></sub></details>|||
|
||||
|Škoda[<sup>12</sup>](#footnotes)|Kodiaq 2017-23[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Kodiaq 2017-23">Buy Here</a></sub></details>|||
|
||||
|Škoda[<sup>12</sup>](#footnotes)|Octavia 2015-19[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Octavia 2015-19">Buy Here</a></sub></details>|||
|
||||
|Škoda[<sup>12</sup>](#footnotes)|Octavia RS 2016[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Octavia RS 2016">Buy Here</a></sub></details>|||
|
||||
|Škoda[<sup>12</sup>](#footnotes)|Octavia Scout 2017-19[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Octavia Scout 2017-19">Buy Here</a></sub></details>|||
|
||||
|Škoda|Scala 2020-23[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Scala 2020-23">Buy Here</a></sub></details>[<sup>17</sup>](#footnotes)|||
|
||||
|Škoda[<sup>12</sup>](#footnotes)|Superb 2015-22[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Superb 2015-22">Buy Here</a></sub></details>|||
|
||||
|Tesla[<sup>10</sup>](#footnotes)|Model 3 (with HW3) 2019-23[<sup>9</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Tesla A connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model 3 (with HW3) 2019-23">Buy Here</a></sub></details>|||
|
||||
|Tesla[<sup>10</sup>](#footnotes)|Model 3 (with HW4) 2024-25[<sup>9</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Tesla B connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model 3 (with HW4) 2024-25">Buy Here</a></sub></details>|||
|
||||
|Tesla[<sup>10</sup>](#footnotes)|Model Y (with HW3) 2020-24[<sup>9</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Tesla A connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model Y (with HW3) 2020-24">Buy Here</a></sub></details>|||
|
||||
|Tesla[<sup>10</sup>](#footnotes)|Model Y (with HW4) 2023-25[<sup>9</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Tesla B connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model Y (with HW4) 2023-25">Buy Here</a></sub></details>|||
|
||||
|Ram|3500 2019-24|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Ram connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Ram 3500 2019-24">Buy Here</a></sub></details>|||
|
||||
|Rivian|R1S 2022-24|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Rivian A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Rivian R1S 2022-24">Buy Here</a></sub></details>|<a href="https://youtu.be/dflSSGQwYNc" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>|<a href="https://youtu.be/uaISd1j7Z4U" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>|
|
||||
|Rivian|R1S 2025|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Rivian B connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Rivian R1S 2025">Buy Here</a></sub></details>|||
|
||||
|Rivian|R1T 2022-24|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Rivian A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Rivian R1T 2022-24">Buy Here</a></sub></details>|<a href="https://youtu.be/dflSSGQwYNc" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>|<a href="https://youtu.be/uaISd1j7Z4U" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>|
|
||||
|Rivian|R1T 2025|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Rivian B connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Rivian R1T 2025">Buy Here</a></sub></details>|||
|
||||
|SEAT[<sup>13</sup>](#footnotes)|Ateca 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=SEAT Ateca 2016-23">Buy Here</a></sub></details>|||
|
||||
|SEAT[<sup>13</sup>](#footnotes)|Leon 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=SEAT Leon 2014-20">Buy Here</a></sub></details>|||
|
||||
|Subaru|Ascent 2019-21|All[<sup>8</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Ascent 2019-21">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Crosstrek 2018-19|EyeSight Driver Assistance[<sup>8</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Crosstrek 2018-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|<a href="https://youtu.be/Agww7oE1k-s?t=26" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Subaru|Crosstrek 2020-23|EyeSight Driver Assistance[<sup>8</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Crosstrek 2020-23">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Forester 2017-18|EyeSight Driver Assistance[<sup>8</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Forester 2017-18">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Forester 2019-21|All[<sup>8</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Forester 2019-21">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Impreza 2017-19|EyeSight Driver Assistance[<sup>8</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Impreza 2017-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Impreza 2020-22|EyeSight Driver Assistance[<sup>8</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Impreza 2020-22">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Legacy 2015-18|EyeSight Driver Assistance[<sup>8</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Legacy 2015-18">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Legacy 2020-22|All[<sup>8</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru B connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Legacy 2020-22">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Outback 2015-17|EyeSight Driver Assistance[<sup>8</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Outback 2015-17">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Outback 2018-19|EyeSight Driver Assistance[<sup>8</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Outback 2018-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|Outback 2020-22|All[<sup>8</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru B connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Outback 2020-22">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Subaru|XV 2018-19|EyeSight Driver Assistance[<sup>8</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru XV 2018-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|<a href="https://youtu.be/Agww7oE1k-s?t=26" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Subaru|XV 2020-21|EyeSight Driver Assistance[<sup>8</sup>](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru XV 2020-21">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|
||||
|Škoda|Fabia 2022-23[<sup>16</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Fabia 2022-23">Buy Here</a></sub></details>[<sup>18</sup>](#footnotes)|||
|
||||
|Škoda|Kamiq 2021-23[<sup>14,16</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Kamiq 2021-23">Buy Here</a></sub></details>[<sup>18</sup>](#footnotes)|||
|
||||
|Škoda[<sup>13</sup>](#footnotes)|Karoq 2019-23[<sup>16</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Karoq 2019-23">Buy Here</a></sub></details>|||
|
||||
|Škoda[<sup>13</sup>](#footnotes)|Kodiaq 2017-23[<sup>16</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Kodiaq 2017-23">Buy Here</a></sub></details>|||
|
||||
|Škoda[<sup>13</sup>](#footnotes)|Octavia 2015-19[<sup>16</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Octavia 2015-19">Buy Here</a></sub></details>|||
|
||||
|Škoda[<sup>13</sup>](#footnotes)|Octavia RS 2016[<sup>16</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Octavia RS 2016">Buy Here</a></sub></details>|||
|
||||
|Škoda[<sup>13</sup>](#footnotes)|Octavia Scout 2017-19[<sup>16</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Octavia Scout 2017-19">Buy Here</a></sub></details>|||
|
||||
|Škoda|Scala 2020-23[<sup>16</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Scala 2020-23">Buy Here</a></sub></details>[<sup>18</sup>](#footnotes)|||
|
||||
|Škoda[<sup>13</sup>](#footnotes)|Superb 2015-22[<sup>16</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Superb 2015-22">Buy Here</a></sub></details>|||
|
||||
|Tesla[<sup>11</sup>](#footnotes)|Model 3 (with HW2.5) 2019[<sup>10</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Tesla A connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model 3 (with HW2.5) 2019">Buy Here</a></sub></details>|||
|
||||
|Tesla[<sup>11</sup>](#footnotes)|Model 3 (with HW3) 2019-23[<sup>10</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Tesla A connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model 3 (with HW3) 2019-23">Buy Here</a></sub></details>|||
|
||||
|Tesla[<sup>11</sup>](#footnotes)|Model 3 (with HW4) 2024-25[<sup>10</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Tesla B connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model 3 (with HW4) 2024-25">Buy Here</a></sub></details>|||
|
||||
|Tesla[<sup>11</sup>](#footnotes)|Model Y (with HW3) 2020-24[<sup>10</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Tesla A connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model Y (with HW3) 2020-24">Buy Here</a></sub></details>|||
|
||||
|Tesla[<sup>11</sup>](#footnotes)|Model Y (with HW4) 2023-25[<sup>10</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Tesla B connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model Y (with HW4) 2023-25">Buy Here</a></sub></details>|||
|
||||
|Toyota|Alphard 2019-20|All|openpilot|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Alphard 2019-20">Buy Here</a></sub></details>|||
|
||||
|Toyota|Alphard Hybrid 2021|All|openpilot|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Alphard Hybrid 2021">Buy Here</a></sub></details>|||
|
||||
|Toyota|Avalon 2016|Toyota Safety Sense P|Stock|19 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Avalon 2016">Buy Here</a></sub></details>|||
|
||||
@@ -282,8 +288,8 @@ A supported vehicle is one that just works when you install a comma device. All
|
||||
|Toyota|C-HR 2021|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota C-HR 2021">Buy Here</a></sub></details>|||
|
||||
|Toyota|C-HR Hybrid 2017-20|All|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota C-HR Hybrid 2017-20">Buy Here</a></sub></details>|||
|
||||
|Toyota|C-HR Hybrid 2021-22|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota C-HR Hybrid 2021-22">Buy Here</a></sub></details>|||
|
||||
|Toyota|Camry 2018-20|All|Stock|0 mph[<sup>11</sup>](#footnotes)|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Camry 2018-20">Buy Here</a></sub></details>|<a href="https://www.youtube.com/watch?v=fkcjviZY9CM" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Toyota|Camry 2021-24|All|openpilot|0 mph[<sup>11</sup>](#footnotes)|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Camry 2021-24">Buy Here</a></sub></details>|||
|
||||
|Toyota|Camry 2018-20|All|Stock|0 mph[<sup>12</sup>](#footnotes)|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Camry 2018-20">Buy Here</a></sub></details>|<a href="https://www.youtube.com/watch?v=fkcjviZY9CM" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Toyota|Camry 2021-24|All|openpilot|0 mph[<sup>12</sup>](#footnotes)|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Camry 2021-24">Buy Here</a></sub></details>|||
|
||||
|Toyota|Camry Hybrid 2018-20|All|Stock|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Camry Hybrid 2018-20">Buy Here</a></sub></details>|<a href="https://www.youtube.com/watch?v=Q2DYY0AWKgk" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Toyota|Camry Hybrid 2021-24|All|openpilot|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Camry Hybrid 2021-24">Buy Here</a></sub></details>|||
|
||||
|Toyota|Corolla 2017-19|All|Stock|19 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Corolla 2017-19">Buy Here</a></sub></details>|||
|
||||
@@ -315,44 +321,44 @@ A supported vehicle is one that just works when you install a comma device. All
|
||||
|Toyota|RAV4 Hybrid 2022|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota RAV4 Hybrid 2022">Buy Here</a></sub></details>|<a href="https://youtu.be/U0nH9cnrFB0" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Toyota|RAV4 Hybrid 2023-25|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota RAV4 Hybrid 2023-25">Buy Here</a></sub></details>|<a href="https://youtu.be/4eIsEq4L4Ng" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Toyota|Sienna 2018-20|All|Stock|19 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Toyota A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Sienna 2018-20">Buy Here</a></sub></details>|<a href="https://www.youtube.com/watch?v=q1UPOo4Sh68" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Arteon 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Arteon 2018-23">Buy Here</a></sub></details>|<a href="https://youtu.be/FAomFKPFlDA" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Arteon eHybrid 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Arteon eHybrid 2020-23">Buy Here</a></sub></details>|<a href="https://youtu.be/FAomFKPFlDA" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Arteon R 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Arteon R 2020-23">Buy Here</a></sub></details>|<a href="https://youtu.be/FAomFKPFlDA" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Arteon Shooting Brake 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Arteon Shooting Brake 2020-23">Buy Here</a></sub></details>|<a href="https://youtu.be/FAomFKPFlDA" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Atlas 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Atlas 2018-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Atlas Cross Sport 2020-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Atlas Cross Sport 2020-22">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|California 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|31 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen California 2021-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Caravelle 2020|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|31 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Caravelle 2020">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|CC 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen CC 2018-22">Buy Here</a></sub></details>|<a href="https://youtu.be/FAomFKPFlDA" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Crafter 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|31 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Crafter 2017-24">Buy Here</a></sub></details>|<a href="https://youtu.be/4100gLeabmo" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|e-Crafter 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|31 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen e-Crafter 2018-24">Buy Here</a></sub></details>|<a href="https://youtu.be/4100gLeabmo" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|e-Golf 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen e-Golf 2014-20">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Golf 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf 2015-20">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Golf Alltrack 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf Alltrack 2015-19">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Golf GTD 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf GTD 2015-20">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Golf GTE 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf GTE 2015-20">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Golf GTI 2015-21|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf GTI 2015-21">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf R 2015-19">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf SportsVan 2015-20">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|31 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Grand California 2019-24">Buy Here</a></sub></details>|<a href="https://youtu.be/4100gLeabmo" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen|ID.4 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[<sup>16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW MEB connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen ID.4 2021-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen|ID.4 2024-25|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[<sup>16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW MEB connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen ID.4 2024-25">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Jetta 2019-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Jetta 2019-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Jetta GLI 2021-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen|Passat 2015-22[<sup>14</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Passat 2015-22">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Passat Alltrack 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Passat Alltrack 2015-22">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Passat GTE 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Passat GTE 2015-22">Buy Here</a></sub></details>|||
|
||||
|Volkswagen|Polo 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Polo 2018-23">Buy Here</a></sub></details>[<sup>17</sup>](#footnotes)|||
|
||||
|Volkswagen|Polo GTI 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Polo GTI 2018-23">Buy Here</a></sub></details>[<sup>17</sup>](#footnotes)|||
|
||||
|Volkswagen|T-Cross 2021|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen T-Cross 2021">Buy Here</a></sub></details>[<sup>17</sup>](#footnotes)|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|T-Roc 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen T-Roc 2018-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Taos 2022-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Taos 2022-24">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Teramont 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Teramont 2018-22">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Teramont Cross Sport 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Teramont Cross Sport 2021-22">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Teramont X 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Teramont X 2021-22">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Tiguan 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Tiguan 2018-24">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Tiguan eHybrid 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Tiguan eHybrid 2021-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>12</sup>](#footnotes)|Touran 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Touran 2016-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Arteon 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Arteon 2018-23">Buy Here</a></sub></details>|<a href="https://youtu.be/FAomFKPFlDA" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Arteon eHybrid 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Arteon eHybrid 2020-23">Buy Here</a></sub></details>|<a href="https://youtu.be/FAomFKPFlDA" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Arteon R 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Arteon R 2020-23">Buy Here</a></sub></details>|<a href="https://youtu.be/FAomFKPFlDA" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Arteon Shooting Brake 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Arteon Shooting Brake 2020-23">Buy Here</a></sub></details>|<a href="https://youtu.be/FAomFKPFlDA" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Atlas 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Atlas 2018-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Atlas Cross Sport 2020-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Atlas Cross Sport 2020-22">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|California 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|31 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen California 2021-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Caravelle 2020|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|31 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Caravelle 2020">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|CC 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen CC 2018-22">Buy Here</a></sub></details>|<a href="https://youtu.be/FAomFKPFlDA" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Crafter 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|31 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Crafter 2017-24">Buy Here</a></sub></details>|<a href="https://youtu.be/4100gLeabmo" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|e-Crafter 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|31 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen e-Crafter 2018-24">Buy Here</a></sub></details>|<a href="https://youtu.be/4100gLeabmo" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|e-Golf 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen e-Golf 2014-20">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Golf 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf 2015-20">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Golf Alltrack 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf Alltrack 2015-19">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Golf GTD 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf GTD 2015-20">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Golf GTE 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf GTE 2015-20">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Golf GTI 2015-21|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf GTI 2015-21">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf R 2015-19">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf SportsVan 2015-20">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|31 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Grand California 2019-24">Buy Here</a></sub></details>|<a href="https://youtu.be/4100gLeabmo" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|
||||
|Volkswagen|ID.4 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[<sup>17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW MEB connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen ID.4 2021-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen|ID.4 2024-25|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[<sup>17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW MEB connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen ID.4 2024-25">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Jetta 2019-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Jetta 2019-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Jetta GLI 2021-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen|Passat 2015-22[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Passat 2015-22">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Passat Alltrack 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Passat Alltrack 2015-22">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Passat GTE 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Passat GTE 2015-22">Buy Here</a></sub></details>|||
|
||||
|Volkswagen|Polo 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Polo 2018-23">Buy Here</a></sub></details>[<sup>18</sup>](#footnotes)|||
|
||||
|Volkswagen|Polo GTI 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Polo GTI 2018-23">Buy Here</a></sub></details>[<sup>18</sup>](#footnotes)|||
|
||||
|Volkswagen|T-Cross 2021|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen T-Cross 2021">Buy Here</a></sub></details>[<sup>18</sup>](#footnotes)|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|T-Roc 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen T-Roc 2018-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Taos 2022-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Taos 2022-24">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Teramont 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Teramont 2018-22">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Teramont Cross Sport 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Teramont Cross Sport 2021-22">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Teramont X 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Teramont X 2021-22">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Tiguan 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Tiguan 2018-24">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Tiguan eHybrid 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Tiguan eHybrid 2021-23">Buy Here</a></sub></details>|||
|
||||
|Volkswagen[<sup>13</sup>](#footnotes)|Touran 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,17</sup>](#footnotes)|0 mph|0 mph|[](##)|[](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Touran 2016-23">Buy Here</a></sub></details>|||
|
||||
|
||||
### Footnotes
|
||||
<sup>1</sup>openpilot Longitudinal Control (Alpha) is available behind a toggle; the toggle is only available in non-release branches such as `nightly-dev`. <br />
|
||||
@@ -360,18 +366,19 @@ A supported vehicle is one that just works when you install a comma device. All
|
||||
<sup>3</sup>See more setup details for <a href="https://github.com/commaai/openpilot/wiki/gm" target="_blank">GM</a>. <br />
|
||||
<sup>4</sup>2019 Honda Civic 1.6L Diesel Sedan does not have ALC below 12mph. <br />
|
||||
<sup>5</sup>Enabling longitudinal control (alpha) will disable all CMBS functionality, including AEB and FCW. <br />
|
||||
<sup>6</sup>See more setup details for <a href="https://github.com/commaai/openpilot/wiki/nissan" target="_blank">Nissan</a>. <br />
|
||||
<sup>7</sup>In the non-US market, openpilot requires the car to come equipped with EyeSight with Lane Keep Assistance. <br />
|
||||
<sup>8</sup>Enabling longitudinal control (alpha) will disable all EyeSight functionality, including AEB, LDW, and RAB. <br />
|
||||
<sup>9</sup>Model years 2023 and 2024 can have either hardware type, depending on build date and factory. To check which hardware type your vehicle has, look for <b>Autopilot computer</b> under <b>Software -> Additional Vehicle Information</b> on your vehicle's touchscreen. See <a href="https://www.notateslaapp.com/news/2173/how-to-check-if-your-tesla-has-hardware-4-ai4-or-hardware-3">this page</a> for more information. <br />
|
||||
<sup>10</sup>See more setup details for <a href="https://github.com/commaai/openpilot/wiki/tesla" target="_blank">Tesla</a>. <br />
|
||||
<sup>11</sup>openpilot operates above 28mph for Camry 4CYL L, 4CYL LE and 4CYL SE which don't have Full-Speed Range Dynamic Radar Cruise Control. <br />
|
||||
<sup>12</sup>The J533 harness plugs in at the CAN gateway under the dashboard, just above the steering column. More information can be found at <a href="https://docs.howtocomma.com/docs/j533-harness-install" target="_blank">this guide</a>. <br />
|
||||
<sup>13</sup>Not including the China market Kamiq, which is based on the (currently) unsupported PQ34 platform. <br />
|
||||
<sup>14</sup>Refers only to the MQB-based European B8 Passat, not the NMS Passat in the USA/China/Mideast markets. <br />
|
||||
<sup>15</sup>Some Škoda vehicles are equipped with heated windshields, which are known to block GPS signal needed for some comma four functionality. <br />
|
||||
<sup>16</sup>Only available for vehicles using a gateway (J533) harness. At this time, vehicles using a camera harness are limited to using stock ACC. <br />
|
||||
<sup>17</sup>Model-years 2022 and beyond may have a combined CAN gateway and BCM, which is supported by openpilot in software, but doesn't yet have a harness available from the comma store. <br />
|
||||
<sup>6</sup>Mazda vehicles <b>require</b> comma power installation. <br />
|
||||
<sup>7</sup>See more setup details for <a href="https://github.com/commaai/openpilot/wiki/nissan" target="_blank">Nissan</a>. <br />
|
||||
<sup>8</sup>In the non-US market, openpilot requires the car to come equipped with EyeSight with Lane Keep Assistance. <br />
|
||||
<sup>9</sup>Enabling longitudinal control (alpha) will disable all EyeSight functionality, including AEB, LDW, and RAB. <br />
|
||||
<sup>10</sup>Model year 2019 can have HW2.5 or HW3, and model years 2023 and 2024 can have HW3 or HW4, depending on build date and factory. To check which hardware type your vehicle has, look for <b>Autopilot computer</b> under <b>Software -> Additional Vehicle Information</b> on your vehicle's touchscreen. See <a href="https://www.notateslaapp.com/news/2173/how-to-check-if-your-tesla-has-hardware-4-ai4-or-hardware-3">this page</a> for more information. <br />
|
||||
<sup>11</sup>See more setup details for <a href="https://github.com/commaai/openpilot/wiki/tesla" target="_blank">Tesla</a>. <br />
|
||||
<sup>12</sup>openpilot operates above 28mph for Camry 4CYL L, 4CYL LE and 4CYL SE which don't have Full-Speed Range Dynamic Radar Cruise Control. <br />
|
||||
<sup>13</sup>The J533 harness plugs in at the CAN gateway under the dashboard, just above the steering column. More information can be found at <a href="https://docs.howtocomma.com/docs/j533-harness-install" target="_blank">this guide</a>. <br />
|
||||
<sup>14</sup>Not including the China market Kamiq, which is based on the (currently) unsupported PQ34 platform. <br />
|
||||
<sup>15</sup>Refers only to the MQB-based European B8 Passat, not the NMS Passat in the USA/China/Mideast markets. <br />
|
||||
<sup>16</sup>Some Škoda vehicles are equipped with heated windshields, which are known to block GPS signal needed for some comma four functionality. <br />
|
||||
<sup>17</sup>Only available for vehicles using a gateway (J533) harness. At this time, vehicles using a camera harness are limited to using stock ACC. <br />
|
||||
<sup>18</sup>Model-years 2022 and beyond may have a combined CAN gateway and BCM, which is supported by openpilot in software, but doesn't yet have a harness available from the comma store. <br />
|
||||
|
||||
## Community Maintained Cars
|
||||
Although they're not upstream, the community has openpilot running on other makes and models. See the 'Community Supported Models' section of each make [on our wiki](https://wiki.comma.ai/).
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Achievements: phase 1, on the device
|
||||
|
||||
## Status: built, tested off-device, not yet run on a car
|
||||
|
||||
Phase 1 of the sunnypilot community plan. The device counts driving stats and unlocks achievements, and shows them on
|
||||
an Achievements screen. Nothing leaves the device yet; sharing, the community site, leaderboards, groups, the map and
|
||||
contests come in later phases on a separate backend.
|
||||
|
||||
## Where it fits
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph device[On the device: this phase]
|
||||
cereal[cereal: carState, selfdriveState,<br/>selfdriveStateSP, modelV2,<br/>driverMonitoringState, GPS] --> d[achievementsd<br/>10 Hz, read-only]
|
||||
d --> f[(state.json<br/>/data/media/0/achievements)]
|
||||
f --> ui[Achievements screen<br/>comma four and comma 3X]
|
||||
ui --> seen[(seen.json)]
|
||||
end
|
||||
f -. later phase: opt-in .-> backend[Achievements backend] --> site[Community site] & forum[Forum badges]
|
||||
```
|
||||
|
||||
## How a drive is counted
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
s[Sample every 0.1 s] --> g{gap over 1 s or<br/>speed outside 0-70 m/s?}
|
||||
g -- yes --> skip[count nothing]
|
||||
g -- no --> e{engaged?<br/>selfdriveState.active<br/>or MADS active}
|
||||
e -- no --> lc
|
||||
e -- yes --> add[add distance, time,<br/>day, model, area, MADS-only km]
|
||||
add --> lc{lane change reached<br/>finishing?}
|
||||
lc --> u[check every tier,<br/>record new unlocks]
|
||||
```
|
||||
|
||||
- **Engaged** means `selfdriveState.active` (including driver override) or MADS lateral active. MADS-only distance
|
||||
is MADS active without `selfdriveState.active`.
|
||||
- **Days** (Regular, Streak, Road Trip, Four Seasons) need a valid wall clock. Before the clock is set, distance still
|
||||
counts but no day does. The calendar day comes from GPS longitude (15 degrees per hour), since the device clock runs
|
||||
in UTC; without GPS it falls back to the system zone. Southern-hemisphere fixes flip the seasons.
|
||||
- **Explorer areas** are geohash cells of about 5 km, salted with a random per-device salt and SHA-256 hashed before
|
||||
they are stored. No coordinates are written anywhere.
|
||||
- **Attentive** is the longest engaged stretch without a red (level three) driver monitoring alert.
|
||||
- **Dev Branch Brave** counts on any channel that is not a release channel, plus staging channels.
|
||||
|
||||
## Catalog
|
||||
|
||||
`openpilot/sunnypilot/achievements/catalog.json`, 14 achievements and 32 tiers. Unlock ids are `odometer.silver`, or
|
||||
just `first_light` for single-tier ones, so a later backend can map them one-to-one to badges.
|
||||
|
||||
| Achievement | Measures | Tiers |
|
||||
| --- | --- | --- |
|
||||
| First Light | engaged km | 0.1 |
|
||||
| Odometer | engaged km | 100 / 1,000 / 10,000 / 50,000 |
|
||||
| Captain's Hours | engaged hours | 10 / 100 / 1,000 |
|
||||
| Regular | days engaged | 7 / 30 / 100 / 365 |
|
||||
| Streak | days in a row | 7 / 30 |
|
||||
| Steady Hands | MADS-only km | 100 / 1,000 |
|
||||
| Smooth Merger | assisted lane changes | 10 / 100 / 1,000 |
|
||||
| Road Trip | km in one day | 500 |
|
||||
| Explorer | 5 km areas | 5 / 25 / 100 |
|
||||
| Model Hopper | models with 50+ km | 3 / 5 / 10 |
|
||||
| Dev Branch Brave | km on dev or staging | 100 |
|
||||
| Attentive | hours without a red DM alert | 10 / 50 / 100 |
|
||||
| Four Seasons | seasons driven | 4 |
|
||||
| Linked Up | sunnylink on and registered | yes |
|
||||
|
||||
Safety rules for anything added here: never reward speed, hard driving, night or fatigue driving, or fewer
|
||||
takeovers, and never show anything while driving.
|
||||
|
||||
## Why a state file, not Params
|
||||
|
||||
This is a prebuilt branch: a new Params key means rebuilding the ARM64 `libparams_c.so` (see
|
||||
`openpilot/tools/params/README.md`). Progress lives in `/data/media/0/achievements/state.json` instead, written
|
||||
atomically every 30 s and when the drive ends. It survives updates. The UI keeps its own `seen.json`, so the daemon and
|
||||
the UI never write the same file. A corrupt state file is moved to `state.corrupt.json` instead of being overwritten.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
| --- | --- |
|
||||
| `sunnypilot/achievements/catalog.json`, `catalog.py` | the goals, and display text for both UIs |
|
||||
| `sunnypilot/achievements/engine.py` | pure Python counting and unlocks, no cereal or Params |
|
||||
| `sunnypilot/achievements/achievementsd.py` | onroad process that feeds the engine from cereal |
|
||||
| `system/manager/process_config.py` | registers `achievementsd` as `only_onroad` |
|
||||
| `selfdrive/ui/sunnypilot/achievements_store.py` | shared reader for both screens |
|
||||
| `selfdrive/ui/sunnypilot/mici/layouts/achievements.py` | comma four screen, plus the settings button with "N new" |
|
||||
| `selfdrive/ui/sunnypilot/layouts/settings/achievements.py` | comma 3X settings panel |
|
||||
|
||||
## Testing
|
||||
|
||||
`pytest openpilot/sunnypilot/achievements/tests openpilot/selfdrive/ui/sunnypilot/tests/test_achievements_store.py`
|
||||
(26 tests, no device libraries needed). The screens and achievementsd were syntax- and lint-checked only: the UI and
|
||||
cereal libraries on this branch are ARM64 builds, so neither has run yet. First drive to check: the process starts
|
||||
onroad, `state.json` appears within 30 s of engaging, and the comma four settings show "achievements" with
|
||||
"2 new" (First Light, and Linked Up if sunnylink is on) after the drive.
|
||||
+1
-1
Submodule opendbc_repo updated: 5be7507143...48611ae9d6
@@ -138,6 +138,9 @@ struct ModelManagerSP @0xaedffd8f31e7b55d {
|
||||
status @0 :DownloadStatus;
|
||||
progress @1 :Float32;
|
||||
eta @2 :UInt32;
|
||||
speed @3 :Float32;
|
||||
canceling @4 :Bool;
|
||||
resuming @5 :Bool;
|
||||
}
|
||||
|
||||
struct Chunk {
|
||||
@@ -465,6 +468,13 @@ struct ModelDataV2SP @0xa1680744031fdb2d {
|
||||
laneTurnDirection @0 :TurnDirection;
|
||||
leftLaneChangeEdgeBlock @1 :Bool;
|
||||
rightLaneChangeEdgeBlock @2 :Bool;
|
||||
blendfulCurvature @3 :Float32;
|
||||
blendfulTrust @4 :Float32;
|
||||
modelCurvature @5 :Float32;
|
||||
blendfulGoingAround @6 :Bool;
|
||||
blendfulLeftProb @7 :Float32;
|
||||
blendfulRightProb @8 :Float32;
|
||||
blendfulLaneWidth @9 :Float32;
|
||||
|
||||
enum TurnDirection {
|
||||
none @0;
|
||||
|
||||
@@ -94,7 +94,7 @@ _services: dict[str, tuple] = {
|
||||
"carControlSP": (True, 100., 10),
|
||||
"carStateSP": (True, 100., 10),
|
||||
"liveMapDataSP": (True, 1., 1),
|
||||
"modelDataV2SP": (True, 20., None, QueueSize.BIG),
|
||||
"modelDataV2SP": (True, 20., 10, QueueSize.BIG),
|
||||
"liveLocationKalman": (True, 20.),
|
||||
|
||||
# debug
|
||||
|
||||
@@ -149,6 +149,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"Version", {PERSISTENT, STRING}},
|
||||
|
||||
// --- sunnypilot params --- //
|
||||
{"AccelBoostSP", {PERSISTENT | BACKUP, BOOL, "1"}},
|
||||
{"ApiCache_DriveStats", {PERSISTENT, JSON}},
|
||||
{"AutoLaneChangeBsmDelay", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"AutoLaneChangeTimer", {PERSISTENT | BACKUP, INT, "0"}},
|
||||
@@ -246,6 +247,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"TeslaMadsScreenButton", {PERSISTENT | BACKUP, INT, "0"}},
|
||||
{"ToyotaEnforceStockLongitudinal", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ToyotaStopAndGoHack", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ToyotaTSS2Smooth", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
|
||||
{"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
|
||||
@@ -256,6 +258,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"LagdValueCache", {PERSISTENT, FLOAT, "0.2"}},
|
||||
{"LaneTurnDesire", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"LaneTurnValue", {PERSISTENT | BACKUP, FLOAT, "19.0"}},
|
||||
{"Blendful", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"PlanplusControl", {PERSISTENT | BACKUP, FLOAT, "1.0"}},
|
||||
|
||||
// mapd
|
||||
|
||||
@@ -28,6 +28,8 @@ class DesireHelper:
|
||||
self.alc = AutoLaneChangeController(self)
|
||||
self.lane_turn_controller = LaneTurnController(self)
|
||||
self.lane_turn_direction = TurnDirection.none
|
||||
self.turn_frames = 0
|
||||
self.turn_repulse = 98
|
||||
|
||||
@staticmethod
|
||||
def get_lane_change_direction(CS):
|
||||
@@ -64,16 +66,19 @@ class DesireHelper:
|
||||
((carstate.steeringTorque > 0 and self.lane_change_direction == LaneChangeDirection.left) or
|
||||
(carstate.steeringTorque < 0 and self.lane_change_direction == LaneChangeDirection.right))
|
||||
|
||||
blindspot_detected = (((carstate.leftBlindspot or left_edge_detected) and self.lane_change_direction == LaneChangeDirection.left) or
|
||||
((carstate.rightBlindspot or right_edge_detected) and self.lane_change_direction == LaneChangeDirection.right))
|
||||
blindspot_detected = ((carstate.leftBlindspot and self.lane_change_direction == LaneChangeDirection.left) or
|
||||
(carstate.rightBlindspot and self.lane_change_direction == LaneChangeDirection.right))
|
||||
|
||||
self.alc.update_lane_change(blindspot_detected, carstate.brakePressed)
|
||||
edge_detected = ((left_edge_detected and self.lane_change_direction == LaneChangeDirection.left) or
|
||||
(right_edge_detected and self.lane_change_direction == LaneChangeDirection.right))
|
||||
|
||||
self.alc.update_lane_change(blindspot_detected, edge_detected, carstate.brakePressed, torque_applied)
|
||||
|
||||
if not one_blinker or below_lane_change_speed:
|
||||
self.lane_change_state = LaneChangeState.off
|
||||
self.lane_change_direction = LaneChangeDirection.none
|
||||
self.lane_change_timer = 0.0
|
||||
elif (torque_applied or self.alc.auto_lane_change_allowed) and not blindspot_detected:
|
||||
elif (torque_applied or self.alc.auto_lane_change_allowed) and not self.alc.lane_change_blocked:
|
||||
self.lane_change_state = LaneChangeState.laneChangeStarting
|
||||
self.lane_change_timer = 0.0
|
||||
|
||||
@@ -92,8 +97,10 @@ class DesireHelper:
|
||||
self.prev_one_blinker = one_blinker and lateral_active
|
||||
|
||||
if self.lane_turn_direction != TurnDirection.none:
|
||||
self.desire = TURN_DESIRES[self.lane_turn_direction]
|
||||
self.turn_frames += 1
|
||||
self.desire = TURN_DESIRES[self.lane_turn_direction] if self.turn_frames % self.turn_repulse else log.Desire.none
|
||||
else:
|
||||
self.turn_frames = 0
|
||||
self.desire = log.Desire.none
|
||||
if self.lane_change_state == LaneChangeState.laneChangeStarting:
|
||||
if self.lane_change_direction == LaneChangeDirection.left:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:65a08adc31d5c456219687d99b7bf5e44d61dae2d49ea67850e76105c7248cce
|
||||
size 60918562
|
||||
oid sha256:b043d3bbc556e3a872c0edde4081f99b5ba92cbfd98eaea2ee2cbb87e9f277ec
|
||||
size 82539558
|
||||
|
||||
@@ -141,6 +141,7 @@ class ModelRenderer(Widget, ModelRendererSP):
|
||||
if ui_state.status != UIStatus.DISENGAGED:
|
||||
self._draw_lane_lines()
|
||||
self._draw_path()
|
||||
self.blendful.draw(self._rect)
|
||||
|
||||
if render_lead_indicator:
|
||||
self._update_leads(sm)
|
||||
@@ -219,6 +220,7 @@ class ModelRenderer(Widget, ModelRendererSP):
|
||||
# Update road edges using raw points
|
||||
for road_edge in self._road_edges:
|
||||
road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, line_width_factor, 0.0, max_idx)
|
||||
self.blendful.update(self, max_idx)
|
||||
|
||||
# Update path using raw points
|
||||
if lead and lead.present:
|
||||
|
||||
@@ -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 * rect.width / 476
|
||||
torque_bg_angle_span = self._torque_line_alpha_filter.x * TORQUE_ANGLE_SPAN * min(1.0, rect.width / (476 * self._scale))
|
||||
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)
|
||||
|
||||
@@ -123,6 +123,7 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP):
|
||||
# Draw elements
|
||||
self._draw_lane_lines()
|
||||
self._draw_path()
|
||||
self.blendful.draw(self._rect)
|
||||
|
||||
if render_lead_indicator and radar_state:
|
||||
self._draw_lead_indicator()
|
||||
@@ -172,6 +173,7 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP):
|
||||
# Update road edges using raw points
|
||||
for road_edge in self._road_edges:
|
||||
road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, 0.025, 0.0, max_idx, max_distance)
|
||||
self.blendful.update(self, max_idx, max_distance)
|
||||
|
||||
# Update path using raw points
|
||||
if lead and lead.present:
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
What both Achievements screens and the settings buttons read: the state file
|
||||
achievementsd writes, plus the UI's own list of unlocks already seen.
|
||||
No pyray here, so it can be tested without a display.
|
||||
"""
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.sunnypilot.achievements.catalog import AchievementStatus, Catalog, format_amount, load_catalog, statuses
|
||||
from openpilot.sunnypilot.achievements.engine import State, load_seen, load_state, save_seen, seen_path, state_path
|
||||
|
||||
REFRESH_INTERVAL_S = 5.0
|
||||
|
||||
|
||||
class AchievementsStore:
|
||||
def __init__(self, state_file: Path | None = None, seen_file: Path | None = None, catalog: Catalog | None = None):
|
||||
self._state_file = state_file
|
||||
self._seen_file = seen_file
|
||||
self.catalog = catalog or load_catalog()
|
||||
self.state = State()
|
||||
self._seen: set[str] = set()
|
||||
self._mtime: float | None = None
|
||||
self._last_check = 0.0
|
||||
self.version = 0 # bumps on every reload or seen change, so several screens can share one store
|
||||
|
||||
def _state_path(self) -> Path:
|
||||
return self._state_file or state_path()
|
||||
|
||||
def _seen_path(self) -> Path:
|
||||
return self._seen_file or seen_path()
|
||||
|
||||
def refresh(self, force: bool = False) -> bool:
|
||||
"""Reload if the state file changed. Cheap enough to call every frame. Returns True when reloaded."""
|
||||
now = time.monotonic()
|
||||
if not force and now - self._last_check < REFRESH_INTERVAL_S:
|
||||
return False
|
||||
self._last_check = now
|
||||
try:
|
||||
mtime = self._state_path().stat().st_mtime
|
||||
except OSError:
|
||||
mtime = None
|
||||
if not force and mtime == self._mtime:
|
||||
return False
|
||||
self._mtime = mtime
|
||||
try:
|
||||
self.state = load_state(self._state_path()) if mtime is not None else State()
|
||||
except OSError:
|
||||
return False
|
||||
self._seen = load_seen(self._seen_path())
|
||||
self.version += 1
|
||||
return True
|
||||
|
||||
def statuses(self) -> list[AchievementStatus]:
|
||||
return statuses(self.catalog, self.state.metrics(), self.state.unlocked)
|
||||
|
||||
def status(self, achievement_id: str) -> AchievementStatus | None:
|
||||
return next((s for s in self.statuses() if s.achievement.id == achievement_id), None)
|
||||
|
||||
def unlock_ids(self, achievement_id: str) -> list[str]:
|
||||
a = next((a for a in self.catalog.achievements if a.id == achievement_id), None)
|
||||
return [a.unlock_id(t) for t in a.tiers] if a else []
|
||||
|
||||
def unlocked_count(self) -> tuple[int, int]:
|
||||
all_ids = self.catalog.all_unlock_ids()
|
||||
return sum(1 for uid in all_ids if uid in self.state.unlocked), len(all_ids)
|
||||
|
||||
def new_ids(self) -> set[str]:
|
||||
return {uid for uid in self.state.unlocked if uid not in self._seen and uid in set(self.catalog.all_unlock_ids())}
|
||||
|
||||
def mark_all_seen(self) -> None:
|
||||
new = self.new_ids()
|
||||
if not new:
|
||||
return
|
||||
self._seen |= new
|
||||
self.version += 1
|
||||
try:
|
||||
save_seen(self._seen, self._seen_path())
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def totals_text(self, is_metric: bool = True) -> str:
|
||||
m = self.state.metrics()
|
||||
unlocked, total = self.unlocked_count()
|
||||
return "\n".join([
|
||||
f"{format_amount(m['engaged_km'], 'km', is_metric)} engaged",
|
||||
f"{format_amount(m['engaged_h'], 'h')} engaged",
|
||||
f"{int(m['active_days'])} days, best streak {int(m['best_streak_days'])}",
|
||||
f"{unlocked} of {total} unlocked",
|
||||
])
|
||||
|
||||
|
||||
achievements_store = AchievementsStore()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
comma 3X Achievements panel: a totals row, then one row per achievement.
|
||||
Tap a row to read what it is for. Opening the panel marks new unlocks as seen.
|
||||
"""
|
||||
from openpilot.selfdrive.ui.sunnypilot.achievements_store import AchievementsStore, achievements_store
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.list_view import text_item
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
|
||||
|
||||
class AchievementsLayout(Widget):
|
||||
def __init__(self, store: AchievementsStore = achievements_store):
|
||||
super().__init__()
|
||||
self._store = store
|
||||
self._new: set[str] = set()
|
||||
|
||||
unlocked = text_item(tr("Unlocked"), self._unlocked_text, description=lambda: self._store.totals_text(ui_state.is_metric))
|
||||
items = [unlocked]
|
||||
for a in self._store.catalog.achievements:
|
||||
items.append(text_item(a.name, lambda aid=a.id: self._row_value(aid), description=a.description))
|
||||
self._scroller = Scroller(items, line_separator=True, spacing=0)
|
||||
|
||||
def _unlocked_text(self) -> str:
|
||||
unlocked, total = self._store.unlocked_count()
|
||||
return f"{unlocked} / {total}"
|
||||
|
||||
def _row_value(self, achievement_id: str) -> str:
|
||||
status = self._store.status(achievement_id)
|
||||
if status is None:
|
||||
return ""
|
||||
is_new = any(uid in self._new for uid in self._store.unlock_ids(achievement_id))
|
||||
return (tr("NEW") + " · " if is_new else "") + status.summary(ui_state.is_metric)
|
||||
|
||||
def show_event(self):
|
||||
self._scroller.show_event()
|
||||
self._store.refresh(force=True)
|
||||
# remembered for this visit, so the NEW labels stay up while the panel is open
|
||||
self._new = self._store.new_ids()
|
||||
self._store.mark_all_seen()
|
||||
|
||||
def _update_state(self):
|
||||
self._store.refresh()
|
||||
|
||||
def _render(self, rect):
|
||||
self._scroller.render(rect)
|
||||
@@ -57,6 +57,12 @@ class CruiseLayout(Widget):
|
||||
description=tr("Use map data to estimate the appropriate speed to drive through turns ahead."),
|
||||
param="SmartCruiseControlMap")
|
||||
|
||||
self.accel_boost_toggle = toggle_item_sp(
|
||||
title=tr("Acceleration Boost"),
|
||||
description=tr("Adds a small push on top of sunnypilot's own acceleration, stronger in Aggressive and gentler in Relaxed. " +
|
||||
"Eases off in curves and when sunnypilot slows or stops. Never goes past your set speed, and lead-car following still comes first."),
|
||||
param="AccelBoostSP")
|
||||
|
||||
self.custom_acc_toggle = toggle_item_sp(
|
||||
title=tr("Custom ACC Speed Increments"),
|
||||
description="",
|
||||
@@ -86,6 +92,7 @@ class CruiseLayout(Widget):
|
||||
self.icbm_toggle,
|
||||
self.scc_v_toggle,
|
||||
self.scc_m_toggle,
|
||||
self.accel_boost_toggle,
|
||||
self.custom_acc_toggle,
|
||||
self.custom_acc_short_increment,
|
||||
self.custom_acc_long_increment,
|
||||
@@ -136,6 +143,8 @@ class CruiseLayout(Widget):
|
||||
self.icbm_toggle.set_description(new_desc)
|
||||
self.icbm_toggle.show_description(True)
|
||||
|
||||
self.accel_boost_toggle.action_item.set_enabled(has_long)
|
||||
|
||||
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.scc_v_toggle.action_item.set_enabled(True)
|
||||
@@ -151,6 +160,7 @@ class CruiseLayout(Widget):
|
||||
else:
|
||||
has_icbm = has_long = False
|
||||
self.icbm_toggle.action_item.set_enabled(False)
|
||||
self.accel_boost_toggle.action_item.set_enabled(False)
|
||||
self.icbm_toggle.set_description(tr(ONROAD_ONLY_DESCRIPTION))
|
||||
|
||||
show_custom_acc_desc = False
|
||||
|
||||
@@ -102,13 +102,18 @@ class ModelsLayout(Widget):
|
||||
|
||||
self.lagd_toggle = toggle_item_sp(tr("Live Learning Steer Delay"), "", param="LagdToggle")
|
||||
|
||||
self._blendful_desc = tr("Adds a gentle nudge toward lane center on top of the model's own steering, stronger when lane lines are clear." +
|
||||
" Yields around obstacles. Not available with the Default model.")
|
||||
self.blendful_toggle = toggle_item_sp(tr("Blendful (Alpha)"), self._blendful_desc, param="Blendful")
|
||||
|
||||
self.camera_offset = option_item_sp(tr("Adjust Camera Offset"), "CameraOffset", -35, 35,
|
||||
tr("Virtually shift camera's perspective to move model's center to Left(+ values) or Right (- values)"),
|
||||
1, None, True, "", style.BUTTON_ACTION_WIDTH, None, True,
|
||||
lambda v: f"{v / 100:.2f} m")
|
||||
|
||||
self.items = [self.small_model_item, self.big_model_item, self.cancel_download_item, self.download_item, self.refresh_item, self.clear_cache_item,
|
||||
self.lane_turn_desire_toggle, self.lane_turn_value_control, self.lagd_toggle, self.delay_control, self.camera_offset]
|
||||
self.blendful_toggle, self.lane_turn_desire_toggle, self.lane_turn_value_control, self.lagd_toggle, self.delay_control,
|
||||
self.camera_offset]
|
||||
|
||||
def _update_lagd_description(self, lagd_toggle: bool):
|
||||
desc = tr("Enable this for the car to learn and adapt its steering response time. Disable to use a fixed steering response time. " +
|
||||
@@ -235,13 +240,17 @@ class ModelsLayout(Widget):
|
||||
progress = sum(p.progress for p in progresses) / len(progresses)
|
||||
ds = custom.ModelManagerSP.DownloadStatus
|
||||
|
||||
if any(p.canceling for p in progresses):
|
||||
return {"name": name, "downloading": True, "progress": progress, "status_text": tr("canceling...")}
|
||||
if ds.failed in statuses:
|
||||
# close.png is authored black and a tint cannot lift it, hence close2
|
||||
return {"name": name, "status_text": tr("download failed"), "text_color": rl.RED, "icon": "icons/close2.png"}
|
||||
if ds.verifying in statuses:
|
||||
return {"name": name, "downloading": True, "progress": progress, "status_text": tr("verifying")}
|
||||
if ds.downloading in statuses:
|
||||
return {"name": name, "downloading": True, "progress": progress}
|
||||
speed = max((p.speed for p in progresses), default=0.0)
|
||||
status_text = f"{speed / 1024 ** 2:.1f} MB/s" if speed > 0 else "--.- MB/s"
|
||||
return {"name": name, "downloading": True, "progress": progress, "status_text": status_text}
|
||||
if statuses <= {ds.downloaded, ds.cached}:
|
||||
return {"name": name, "text_color": ON_COLOR, "icon": "icons/checkmark.png"}
|
||||
# circled_slash is authored grey; tinting it again only darkens it
|
||||
@@ -318,6 +327,21 @@ class ModelsLayout(Widget):
|
||||
self.lane_turn_desire_toggle.action_item.set_state(turn_desire)
|
||||
self.lane_turn_value_control.set_visible(turn_desire and advanced_controls)
|
||||
self.lagd_toggle.action_item.set_state(live_delay)
|
||||
|
||||
blendful_allowed = ui_state.active_runner_tinygrad
|
||||
if blendful_allowed:
|
||||
self.blendful_toggle.action_item.set_state(ui_state.params.get_bool("Blendful"))
|
||||
self.blendful_toggle.action_item.set_enabled(True)
|
||||
else:
|
||||
self.blendful_toggle.action_item.set_state(False)
|
||||
self.blendful_toggle.action_item.set_enabled(False)
|
||||
|
||||
blendful_desc = self._blendful_desc if blendful_allowed else \
|
||||
f"<b>{tr('Select a non-Default model to use Blendful.')}</b><br><br>{self._blendful_desc}"
|
||||
if self.blendful_toggle.description != blendful_desc:
|
||||
self.blendful_toggle.set_description(blendful_desc)
|
||||
self.blendful_toggle.show_description(not blendful_allowed)
|
||||
|
||||
self.delay_control.set_visible(not live_delay and advanced_controls)
|
||||
new_step = int(round(100 / CV.MPH_TO_KPH)) if ui_state.is_metric else 100
|
||||
if self.lane_turn_value_control.action_item is not None and self.lane_turn_value_control.action_item.value_change_step != new_step:
|
||||
|
||||
@@ -11,6 +11,7 @@ import pyray as rl
|
||||
from openpilot.selfdrive.ui.layouts.settings import settings as OP
|
||||
from openpilot.selfdrive.ui.layouts.settings.firehose import FirehoseLayout
|
||||
from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.achievements import AchievementsLayout
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.cruise import CruiseLayout
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.developer import DeveloperLayoutSP
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.device import DeviceLayoutSP
|
||||
@@ -50,6 +51,7 @@ OP.PanelType = IntEnum( # type: ignore[assignment] # ty: ignore[invalid-assignm
|
||||
"NAVIGATION",
|
||||
"TRIPS",
|
||||
"VEHICLE",
|
||||
"ACHIEVEMENTS",
|
||||
],
|
||||
start=0,
|
||||
)
|
||||
@@ -122,6 +124,8 @@ class SettingsLayoutSP(OP.SettingsLayout):
|
||||
OP.PanelType.OSM: PanelInfo(tr_noop("OSM"), OSMLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_map.png"),
|
||||
# OP.PanelType.NAVIGATION: PanelInfo(tr_noop("Navigation"), NavigationLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_map.png"),
|
||||
OP.PanelType.TRIPS: PanelInfo(tr_noop("Trips"), TripsLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_trips.png"),
|
||||
OP.PanelType.ACHIEVEMENTS: PanelInfo(tr_noop("Achievements"), AchievementsLayout(),
|
||||
icon="../../sunnypilot/selfdrive/assets/offroad/icon_achievements.png"),
|
||||
OP.PanelType.VEHICLE: PanelInfo(tr_noop("Vehicle"), VehicleLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_vehicle.png"),
|
||||
OP.PanelType.FIREHOSE: PanelInfo(tr_noop("Firehose"), FirehoseLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_firehose.png"),
|
||||
OP.PanelType.DEVELOPER: PanelInfo(tr_noop("Developer"), DeveloperLayoutSP(), icon="icons/shell.png"),
|
||||
|
||||
@@ -11,10 +11,12 @@ from openpilot.system.ui.lib.multilang import tr, tr_noop
|
||||
from openpilot.system.ui.widgets import DialogResult
|
||||
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
|
||||
from opendbc.car.toyota.values import ToyotaFlags
|
||||
|
||||
|
||||
ONROAD_ONLY_DESCRIPTION = tr_noop("Start the vehicle to check vehicle compatibility.")
|
||||
SNG_HACK_UNAVAILABLE = tr_noop("sunnypilot Longitudinal Control must be available and enabled for your vehicle to use this feature.")
|
||||
PLATFORM_UNAVAILABLE = tr_noop("This feature is currently not available on this platform.")
|
||||
|
||||
DESCRIPTIONS = {
|
||||
'enforce_stock_longitudinal': tr_noop(
|
||||
@@ -23,7 +25,11 @@ DESCRIPTIONS = {
|
||||
'stop_and_go_hack': tr_noop(
|
||||
'sunnypilot will allow some Toyota/Lexus cars to auto resume during stop and go traffic. ' +
|
||||
'This feature is only applicable to certain models that are able to use longitudinal control. This is an alpha feature. Use at your own risk.'
|
||||
)
|
||||
),
|
||||
'tss2_smooth': tr_noop(
|
||||
'Enable smoother longitudinal control for TSS2 vehicles. ' +
|
||||
'Uses a custom speed-dependent integral gain curve for smoother traffic driving.'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -47,9 +53,18 @@ class ToyotaSettings(BrandSettings):
|
||||
enabled=lambda: not ui_state.engaged,
|
||||
)
|
||||
|
||||
self.tss2_smooth = toggle_item_sp(
|
||||
lambda: tr("TSS2 Smooth Longitudinal (Alpha)"),
|
||||
description=lambda: tr(DESCRIPTIONS["tss2_smooth"]),
|
||||
initial_state=ui_state.params.get_bool("ToyotaTSS2Smooth"),
|
||||
callback=self._on_enable_tss2_smooth,
|
||||
enabled=lambda: not ui_state.engaged,
|
||||
)
|
||||
|
||||
self.items = [
|
||||
self.enforce_stock_longitudinal,
|
||||
self.stop_and_go_hack,
|
||||
self.tss2_smooth,
|
||||
]
|
||||
|
||||
def _on_enable_enforce_stock_longitudinal(self, state: bool):
|
||||
@@ -61,6 +76,8 @@ class ToyotaSettings(BrandSettings):
|
||||
ui_state.params.put_bool("AlphaLongitudinalEnabled", False)
|
||||
ui_state.params.put_bool("ToyotaStopAndGoHack", False)
|
||||
self.stop_and_go_hack.action_item.set_state(False)
|
||||
ui_state.params.put_bool("ToyotaTSS2Smooth", False)
|
||||
self.tss2_smooth.action_item.set_state(False)
|
||||
ui_state.params.put_bool("OnroadCycleRequested", True)
|
||||
else:
|
||||
self.enforce_stock_longitudinal.action_item.set_state(False)
|
||||
@@ -75,6 +92,25 @@ class ToyotaSettings(BrandSettings):
|
||||
ui_state.params.put_bool("ToyotaEnforceStockLongitudinal", False)
|
||||
ui_state.params.put_bool("OnroadCycleRequested", True)
|
||||
|
||||
def _on_enable_tss2_smooth(self, state: bool):
|
||||
if state:
|
||||
def confirm_callback(result: int):
|
||||
if result == DialogResult.CONFIRM:
|
||||
ui_state.params.put_bool("ToyotaTSS2Smooth", True)
|
||||
ui_state.params.put_bool("OnroadCycleRequested", True)
|
||||
else:
|
||||
self.tss2_smooth.action_item.set_state(False)
|
||||
|
||||
content = (f"<h1>{self.tss2_smooth.title}</h1><br>" +
|
||||
f"<p>{self.tss2_smooth.description}</p>")
|
||||
|
||||
dlg = ConfirmDialog(content, tr("Enable"), rich=True, callback=confirm_callback)
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
else:
|
||||
ui_state.params.put_bool("ToyotaTSS2Smooth", False)
|
||||
ui_state.params.put_bool("OnroadCycleRequested", True)
|
||||
|
||||
def _on_enable_stop_and_go_hack(self, state: bool):
|
||||
if state:
|
||||
def confirm_callback(result: int):
|
||||
@@ -113,9 +149,32 @@ class ToyotaSettings(BrandSettings):
|
||||
self.stop_and_go_hack.set_description(new_desc)
|
||||
if show_desc:
|
||||
self.stop_and_go_hack.show_description(True)
|
||||
|
||||
tss2 = bool(ui_state.CP.flags & ToyotaFlags.TSS2)
|
||||
if longitudinal and not enforce_stock and tss2:
|
||||
self.tss2_smooth.action_item.set_enabled(not ui_state.engaged)
|
||||
new_desc = tr(DESCRIPTIONS["tss2_smooth"])
|
||||
show_desc = False
|
||||
else:
|
||||
self.tss2_smooth.action_item.set_enabled(False)
|
||||
self.tss2_smooth.action_item.set_state(False)
|
||||
unavailable = SNG_HACK_UNAVAILABLE if tss2 else PLATFORM_UNAVAILABLE
|
||||
new_desc = "<b>" + tr(unavailable) + "</b>\n\n" + tr(DESCRIPTIONS["tss2_smooth"])
|
||||
show_desc = True
|
||||
|
||||
if self.tss2_smooth.description != new_desc:
|
||||
self.tss2_smooth.set_description(new_desc)
|
||||
if show_desc:
|
||||
self.tss2_smooth.show_description(True)
|
||||
else:
|
||||
self.stop_and_go_hack.action_item.set_enabled(False)
|
||||
new_desc = "<b>" + tr(ONROAD_ONLY_DESCRIPTION) + "</b>\n\n" + tr(DESCRIPTIONS["stop_and_go_hack"])
|
||||
if self.stop_and_go_hack.description != new_desc:
|
||||
self.stop_and_go_hack.set_description(new_desc)
|
||||
self.stop_and_go_hack.show_description(True)
|
||||
|
||||
self.tss2_smooth.action_item.set_enabled(False)
|
||||
new_desc = "<b>" + tr(ONROAD_ONLY_DESCRIPTION) + "</b>\n\n" + tr(DESCRIPTIONS["tss2_smooth"])
|
||||
if self.tss2_smooth.description != new_desc:
|
||||
self.tss2_smooth.set_description(new_desc)
|
||||
self.tss2_smooth.show_description(True)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
comma four Achievements screen: a summary card, then one card per achievement.
|
||||
Reads the state file achievementsd writes; opening the screen marks new unlocks as seen.
|
||||
"""
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog
|
||||
from openpilot.selfdrive.ui.sunnypilot.achievements_store import AchievementsStore, achievements_store
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets.scroller import NavScroller
|
||||
|
||||
|
||||
class AchievementsLayoutMici(NavScroller):
|
||||
def __init__(self, store: AchievementsStore = achievements_store):
|
||||
super().__init__()
|
||||
self._store = store
|
||||
self._shown_version = -1
|
||||
self._summary_btn = BigButton(tr("achievements"), "")
|
||||
self._summary_btn.set_click_callback(self._show_summary)
|
||||
|
||||
self._cards: dict[str, BigButton] = {}
|
||||
widgets = [self._summary_btn]
|
||||
for a in self._store.catalog.achievements:
|
||||
card = BigButton(a.name.lower(), "")
|
||||
card.set_click_callback(lambda aid=a.id: self._show_details(aid))
|
||||
self._cards[a.id] = card
|
||||
widgets.append(card)
|
||||
self._scroller.add_widgets(widgets)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._store.refresh(force=True)
|
||||
self._refresh_cards()
|
||||
# marked seen after drawing, so the "new" labels stay up while this screen is open
|
||||
self._store.mark_all_seen()
|
||||
self._shown_version = self._store.version
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self._store.refresh()
|
||||
if self._store.version != self._shown_version:
|
||||
self._refresh_cards()
|
||||
self._shown_version = self._store.version
|
||||
|
||||
def _refresh_cards(self):
|
||||
is_metric = ui_state.is_metric
|
||||
new = self._store.new_ids()
|
||||
unlocked, total = self._store.unlocked_count()
|
||||
self._summary_btn.set_value(f"{unlocked} / {total} unlocked")
|
||||
for status in self._store.statuses():
|
||||
a = status.achievement
|
||||
card = self._cards[a.id]
|
||||
is_new = any(uid in new for uid in self._store.unlock_ids(a.id))
|
||||
card.set_value(("new · " if is_new else "") + status.summary(is_metric))
|
||||
|
||||
def _show_summary(self):
|
||||
gui_app.push_widget(BigDialog("", self._store.totals_text(ui_state.is_metric)))
|
||||
|
||||
def _show_details(self, achievement_id: str):
|
||||
status = self._store.status(achievement_id)
|
||||
if status is None:
|
||||
return
|
||||
gui_app.push_widget(BigDialog(status.achievement.name.lower(), f"{status.achievement.description}\n{status.summary(ui_state.is_metric)}"))
|
||||
@@ -11,7 +11,7 @@ import pyray as rl
|
||||
from openpilot.cereal import custom
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog
|
||||
from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigParamControl
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, device
|
||||
from openpilot.selfdrive.ui.sunnypilot.model_info import (active_source, big_model_state, bundles_for_source, carrying_model,
|
||||
default_model_name, model_cache_size_mb, model_info, queued_name,
|
||||
@@ -95,7 +95,12 @@ class ModelsLayoutMici(NavScroller):
|
||||
self.clear_cache_btn.set_click_callback(self._confirm_clear_cache)
|
||||
self._cache_size_time = 0.0
|
||||
|
||||
self.main_items = [self.current_model_info, self.select_model_btn, self.cancel_download_btn, self.refresh_btn, self.clear_cache_btn]
|
||||
self.blendful_toggle = BigParamControl(tr("blendful (alpha)"), "Blendful",
|
||||
description=tr("Adds a gentle nudge toward lane center on top of the model's own steering, stronger when " +
|
||||
"lane lines are clear. Yields around obstacles. Not available with the Default model."))
|
||||
|
||||
self.main_items = [self.current_model_info, self.select_model_btn, self.cancel_download_btn, self.refresh_btn, self.clear_cache_btn,
|
||||
self.blendful_toggle]
|
||||
self._scroller.add_widgets(self.main_items)
|
||||
|
||||
@property
|
||||
@@ -205,6 +210,10 @@ class ModelsLayoutMici(NavScroller):
|
||||
device.set_override_interactive_timeout(None)
|
||||
self._was_downloading = False
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self.blendful_toggle.refresh()
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
@@ -213,6 +222,17 @@ class ModelsLayoutMici(NavScroller):
|
||||
self.current_model_info.current_model_header._shimmer = False
|
||||
self.current_model_info.info_header._shimmer = False
|
||||
|
||||
blendful_allowed = ui_state.active_runner_tinygrad
|
||||
if blendful_allowed:
|
||||
self.blendful_toggle.set_checked(ui_state.params.get_bool("Blendful"))
|
||||
self.blendful_toggle.set_enabled(True)
|
||||
else:
|
||||
self.blendful_toggle.set_checked(False)
|
||||
self.blendful_toggle.set_enabled(False)
|
||||
blendful_value = "" if blendful_allowed else tr("custom model only")
|
||||
if self.blendful_toggle.value != blendful_value:
|
||||
self.blendful_toggle.set_value(blendful_value)
|
||||
|
||||
manager = self.model_manager
|
||||
self._download_frame += 1
|
||||
should_update = self._download_frame % (gui_app.target_fps / 2) == 0
|
||||
@@ -251,11 +271,13 @@ class ModelsLayoutMici(NavScroller):
|
||||
self.current_model_info.info_text.set_text(tr("download failed"))
|
||||
|
||||
elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading:
|
||||
self.cancel_download_btn.set_visible(True)
|
||||
self.cancel_download_btn.set_visible(ui_state.params.get("ModelManager_DownloadRef") is not None)
|
||||
device.set_override_interactive_timeout(5)
|
||||
progress = 0.0
|
||||
count = 0
|
||||
verifying = False
|
||||
canceling = False
|
||||
speed = 0.0
|
||||
for model in manager.selectedBundle.models:
|
||||
count += 1
|
||||
p = model.artifact.downloadProgress
|
||||
@@ -263,11 +285,14 @@ class ModelsLayoutMici(NavScroller):
|
||||
custom.ModelManagerSP.DownloadStatus.verifying):
|
||||
progress += p.progress
|
||||
verifying = verifying or p.status == custom.ModelManagerSP.DownloadStatus.verifying
|
||||
canceling = canceling or p.canceling
|
||||
speed = max(speed, p.speed) # only one file downloads at a time, so the non-zero speed is the live one
|
||||
elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded,
|
||||
custom.ModelManagerSP.DownloadStatus.cached):
|
||||
progress += 100.0
|
||||
|
||||
self.current_model_info.current_model_header.set_text(tr("verifying") if verifying else tr("downloading"))
|
||||
header = tr("canceling...") if canceling else tr("verifying") if verifying else tr("downloading")
|
||||
self.current_model_info.current_model_header.set_text(header)
|
||||
self.cancel_download_btn.set_text(tr("cancel verification") if verifying else tr("cancel download"))
|
||||
self.current_model_info.current_model_header._shimmer = True
|
||||
name_text = manager.selectedBundle.internalName.lower()
|
||||
@@ -276,7 +301,10 @@ class ModelsLayoutMici(NavScroller):
|
||||
self.current_model_info.current_model_text.set_text(name_text)
|
||||
self.current_model_info.info_header.set_text(tr("progress") + self._download_progress)
|
||||
self.current_model_info.info_header._shimmer = True
|
||||
self.current_model_info.info_text.set_text(f"{progress/count:.2f}%")
|
||||
progress_text = f"{progress/count:.2f}%"
|
||||
if not verifying and not canceling:
|
||||
progress_text += f" | {speed / 1024 ** 2:.1f} MB/s" if speed > 0 else " | --.- MB/s"
|
||||
self.current_model_info.info_text.set_text(progress_text)
|
||||
|
||||
elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloaded:
|
||||
self.current_model_info.info_header.set_text(tr("downloaded"))
|
||||
|
||||
@@ -9,6 +9,8 @@ from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsBigBut
|
||||
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.achievements_store import achievements_store
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.achievements import AchievementsLayoutMici
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.models import ModelsLayoutMici
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
@@ -50,6 +52,11 @@ class SettingsLayoutSP(OP.SettingsLayout):
|
||||
models_btn = SettingsBigButton(tr("models"), "", gui_app.texture("../../sunnypilot/selfdrive/assets/offroad/icon_models.png", ICON_SIZE, ICON_SIZE))
|
||||
models_btn.set_click_callback(lambda: gui_app.push_widget(models_panel))
|
||||
|
||||
achievements_panel = AchievementsLayoutMici()
|
||||
self._achievements_btn = SettingsBigButton(tr("achievements"), "",
|
||||
gui_app.texture("../../sunnypilot/selfdrive/assets/icons_mici/achievements.png", ICON_SIZE, ICON_SIZE))
|
||||
self._achievements_btn.set_click_callback(lambda: gui_app.push_widget(achievements_panel))
|
||||
|
||||
# onroad: enable button sits at the front (left of toggles)
|
||||
self._enable_offroad_btn_onroad = BigCircleButton(self.icon_offroad_enable, red=True)
|
||||
self._enable_offroad_btn_onroad.set_click_callback(lambda: self._handle_always_offroad(True))
|
||||
@@ -72,6 +79,7 @@ class SettingsLayoutSP(OP.SettingsLayout):
|
||||
# front slots (only one ever visible at a time): exit-always-offroad, then enable-onroad
|
||||
items.insert(0, self._enable_offroad_btn_onroad)
|
||||
items.insert(0, self._disable_offroad_btn)
|
||||
items.append(self._achievements_btn)
|
||||
# end slot: enable-offroad (right of developer)
|
||||
items.append(self._enable_offroad_btn_offroad)
|
||||
|
||||
@@ -81,6 +89,11 @@ class SettingsLayoutSP(OP.SettingsLayout):
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
achievements_store.refresh()
|
||||
new = len(achievements_store.new_ids())
|
||||
value = tr("{} new").format(new) if new else ""
|
||||
if value != self._achievements_btn.get_value():
|
||||
self._achievements_btn.set_value(value)
|
||||
|
||||
def _handle_always_offroad(self, enable: bool):
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import pyray as rl
|
||||
from openpilot.selfdrive.ui.ui_state import UIStatus
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.blendful import BlendfulRenderer
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.rainbow_path import RainbowPath
|
||||
|
||||
LANE_LINE_COLORS_SP = {
|
||||
@@ -17,3 +18,4 @@ LANE_LINE_COLORS_SP = {
|
||||
class ModelRendererSP:
|
||||
def __init__(self):
|
||||
self.rainbow_path = RainbowPath()
|
||||
self.blendful = BlendfulRenderer(rect_relative=True)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Copyright (c) 2021-, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.sunnypilot.ui_state import UIStateSP
|
||||
from openpilot.sunnypilot.modeld_v2.blendful import LANE_WIDTH_CLIP, LOOKAHEAD_TIME, MIN_LOOKAHEAD
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.shader_polygon import draw_polygon
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
|
||||
BLENDFUL_COLOR = (0, 200, 255)
|
||||
FULL_BAR = 1.0
|
||||
|
||||
|
||||
class BlendfulRenderer:
|
||||
def __init__(self, readout: bool = True, rect_relative: bool = False):
|
||||
self._readout = readout
|
||||
self._rect_relative = rect_relative
|
||||
self._center_points = np.empty((0, 2), dtype=np.float32)
|
||||
self._trust = 0.0
|
||||
self._font = gui_app.font(FontWeight.BOLD)
|
||||
self._pull_display = FirstOrderFilter(0.0, 0.5, DT_MDL)
|
||||
self._trust_display = FirstOrderFilter(0.0, 0.5, DT_MDL)
|
||||
self._alpha = FirstOrderFilter(0.0, 0.3, DT_MDL)
|
||||
|
||||
def update(self, renderer, max_idx: int, *map_args) -> None:
|
||||
blendful = ui_state.sm['modelDataV2SP']
|
||||
if ui_state.sm.updated['modelDataV2SP']:
|
||||
self._alpha.update(float(blendful.blendfulTrust > 0.0))
|
||||
if blendful.blendfulTrust > 0.0:
|
||||
self._trust = blendful.blendfulTrust
|
||||
if self._readout:
|
||||
lookahead = max(ui_state.sm['carState'].vEgo * LOOKAHEAD_TIME, MIN_LOOKAHEAD)
|
||||
self._pull_display.update(blendful.blendfulCurvature * lookahead ** 2 / 2)
|
||||
self._trust_display.update(self._trust)
|
||||
|
||||
left, right = renderer._lane_lines[1].raw_points, renderer._lane_lines[2].raw_points
|
||||
stds = list(ui_state.sm['modelV2'].laneLineStds)
|
||||
if self._alpha.x < 0.01 or len(left) == 0 or len(right) != len(left) or len(stds) < 3:
|
||||
self._center_points = np.empty((0, 2), dtype=np.float32)
|
||||
return
|
||||
|
||||
l_prob, r_prob = blendful.blendfulLeftProb, blendful.blendfulRightProb
|
||||
if blendful.blendfulTrust > 0.0 and l_prob + r_prob > 0.0:
|
||||
half_width = np.clip(right[:, 1] - left[:, 1], *LANE_WIDTH_CLIP) / 2 if l_prob * r_prob > 0.5 else blendful.blendfulLaneWidth / 2
|
||||
l_var = stds[1] ** 2 / max(l_prob, 1e-4)
|
||||
r_var = stds[2] ** 2 / max(r_prob, 1e-4)
|
||||
center = left.copy()
|
||||
center[:, 1] = (r_var * (left[:, 1] + half_width) + l_var * (right[:, 1] - half_width)) / (l_var + r_var)
|
||||
self._center_points = renderer._map_line_to_polygon(center, 0.15, 0.0, max_idx, *map_args)
|
||||
|
||||
def _color(self, rgb, alpha: float) -> rl.Color:
|
||||
return rl.Color(*rgb, int(alpha * self._alpha.x))
|
||||
|
||||
def draw(self, rect: rl.Rectangle) -> None:
|
||||
if not UIStateSP.lateral_active(ui_state):
|
||||
return
|
||||
if self._center_points.size and self._trust > 0.05:
|
||||
points = self._center_points + (rect.x, rect.y) if self._rect_relative else self._center_points
|
||||
draw_polygon(rect, points, self._color(BLENDFUL_COLOR, np.clip(self._trust, 0.2, 1.0) * 255))
|
||||
if self._center_points.size and self._readout:
|
||||
self._draw_readout(rect)
|
||||
|
||||
def _draw_readout(self, rect: rl.Rectangle) -> None:
|
||||
pull, trust = self._pull_display.x, self._trust_display.x
|
||||
w, h = 190, 76
|
||||
x, y = rect.x + (rect.width - w) / 2, rect.y + 10
|
||||
cx, cy = x + w / 2, y + 30
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.5, 12, self._color((0, 0, 0), 170))
|
||||
|
||||
text = f"{abs(pull) * 100:.0f}"
|
||||
text_w = measure_text_cached(self._font, text, 44, 0).x
|
||||
rl.draw_text_ex(self._font, text, rl.Vector2(cx - text_w / 2, cy - 24), 44, 0, self._color((255, 255, 255), 255))
|
||||
|
||||
active = self._color(BLENDFUL_COLOR, np.clip(abs(pull) / 0.1, 0.0, 1.0) * 255)
|
||||
idle = self._color((255, 255, 255), 40)
|
||||
lx, rx = x + 18, x + w - 18
|
||||
rl.draw_triangle(rl.Vector2(lx, cy), rl.Vector2(lx + 16, cy + 12), rl.Vector2(lx + 16, cy - 12), active if pull < 0 else idle)
|
||||
rl.draw_triangle(rl.Vector2(rx - 16, cy - 12), rl.Vector2(rx - 16, cy + 12), rl.Vector2(rx, cy), active if pull > 0 else idle)
|
||||
|
||||
bar = rl.Rectangle(x + 24, y + h - 16, w - 48, 6)
|
||||
rl.draw_rectangle_rounded(bar, 1.0, 6, self._color((255, 255, 255), 40))
|
||||
rl.draw_rectangle(int(cx - 1), int(bar.y - 2), 2, int(bar.height + 4), self._color((255, 255, 255), 120))
|
||||
fill = bar.width / 2 * float(np.sqrt(np.clip(abs(pull) / FULL_BAR, 0.0, 1.0)))
|
||||
if fill > 1:
|
||||
fill_x = cx if pull > 0 else cx - fill
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(fill_x, bar.y, fill, bar.height), 1.0, 6,
|
||||
self._color(BLENDFUL_COLOR, np.clip(0.3 + 0.7 * trust, 0.0, 1.0) * 255))
|
||||
@@ -5,10 +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.
|
||||
"""
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.blendful import BlendfulRenderer
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.chevron_metrics import ChevronMetrics
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.rainbow_path import RainbowPath
|
||||
from openpilot.selfdrive.ui.sunnypilot.ui_state import MADSState
|
||||
from openpilot.selfdrive.ui.sunnypilot.ui_state import UIStateSP
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
|
||||
|
||||
@@ -16,16 +17,12 @@ class ModelRendererSP:
|
||||
def __init__(self):
|
||||
self.rainbow_path = RainbowPath()
|
||||
self.chevron_metrics = ChevronMetrics()
|
||||
self.blendful = BlendfulRenderer(readout=False)
|
||||
self._width_filter = FirstOrderFilter(0.9, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
@property
|
||||
def _lateral_active(self) -> bool:
|
||||
sm = ui_state.sm
|
||||
if sm.valid["selfdriveStateSP"]:
|
||||
mads = sm["selfdriveStateSP"].mads
|
||||
if mads.available:
|
||||
return mads.enabled and mads.state != MADSState.paused
|
||||
return ui_state.status in (UIStatus.ENGAGED, UIStatus.LAT_ONLY)
|
||||
return UIStateSP.lateral_active(ui_state)
|
||||
|
||||
def _get_path_half_width(self) -> float:
|
||||
target = 0.9 if self._lateral_active else 0.40
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from openpilot.selfdrive.ui.sunnypilot.achievements_store import AchievementsStore
|
||||
from openpilot.sunnypilot.achievements.engine import AchievementEngine, State, save_state
|
||||
|
||||
T0 = 1_780_000_000.0
|
||||
|
||||
|
||||
def make_store(tmp_path):
|
||||
return AchievementsStore(state_file=tmp_path / "state.json", seen_file=tmp_path / "seen.json")
|
||||
|
||||
|
||||
def write_state(tmp_path, engaged_km):
|
||||
e = AchievementEngine(State(engaged_m=engaged_km * 1000.0))
|
||||
e.check_unlocks(T0)
|
||||
save_state(e.state, tmp_path / "state.json")
|
||||
|
||||
|
||||
def test_no_state_file_shows_everything_locked(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
assert store.refresh(force=True)
|
||||
unlocked, total = store.unlocked_count()
|
||||
assert unlocked == 0 and total > 10
|
||||
assert store.new_ids() == set()
|
||||
|
||||
|
||||
def test_new_unlocks_until_seen(tmp_path):
|
||||
write_state(tmp_path, 150)
|
||||
store = make_store(tmp_path)
|
||||
store.refresh(force=True)
|
||||
assert store.new_ids() == {"first_light", "odometer.bronze"}
|
||||
v = store.version
|
||||
store.mark_all_seen()
|
||||
assert store.new_ids() == set() and store.version > v
|
||||
|
||||
# a second store (the other screen, or after a reboot) sees them as seen too
|
||||
other = make_store(tmp_path)
|
||||
other.refresh(force=True)
|
||||
assert other.new_ids() == set()
|
||||
|
||||
|
||||
def test_refresh_picks_up_new_unlocks(tmp_path):
|
||||
write_state(tmp_path, 150)
|
||||
store = make_store(tmp_path)
|
||||
store.refresh(force=True)
|
||||
store.mark_all_seen()
|
||||
write_state(tmp_path, 1500)
|
||||
assert store.refresh(force=True)
|
||||
assert store.new_ids() == {"odometer.silver"}
|
||||
|
||||
|
||||
def test_totals_and_status(tmp_path):
|
||||
write_state(tmp_path, 1240)
|
||||
store = make_store(tmp_path)
|
||||
store.refresh(force=True)
|
||||
assert store.status("odometer").summary() == "silver · 1,240 / 10,000 km"
|
||||
assert "1,240 km engaged" in store.totals_text()
|
||||
assert store.unlock_ids("odometer") == ["odometer.bronze", "odometer.silver", "odometer.gold", "odometer.platinum"]
|
||||
assert store.status("nope") is None
|
||||
@@ -36,7 +36,7 @@ class UIStateSP:
|
||||
self.is_sp_release: bool = self.params.get_bool("IsReleaseSpBranch")
|
||||
self.sm_services_ext = [
|
||||
"modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP",
|
||||
"lateralTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "lateralDelay"
|
||||
"lateralTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "lateralDelay", "modelDataV2SP"
|
||||
]
|
||||
|
||||
self.sunnylink_state = SunnylinkState()
|
||||
@@ -46,6 +46,7 @@ class UIStateSP:
|
||||
|
||||
self.active_bundle = None
|
||||
self.model_runner_tinygrad: bool = False
|
||||
self.active_runner_tinygrad: bool = False
|
||||
self.blindspot: bool = False
|
||||
self.chevron_metrics = None
|
||||
self.custom_interactive_timeout: int = 0
|
||||
@@ -109,6 +110,14 @@ class UIStateSP:
|
||||
def auto_onroad_brightness(self) -> bool:
|
||||
return self.onroad_brightness in (OnroadBrightness.AUTO, OnroadBrightness.AUTO_DARK)
|
||||
|
||||
@staticmethod
|
||||
def lateral_active(_ui_state) -> bool:
|
||||
if _ui_state.sm.valid["selfdriveStateSP"]:
|
||||
mads = _ui_state.sm["selfdriveStateSP"].mads
|
||||
if mads.available:
|
||||
return mads.enabled and mads.state != MADSState.paused
|
||||
return _ui_state.status.value in ("engaged", "lat_only")
|
||||
|
||||
@staticmethod
|
||||
def update_status(ss, ss_sp, onroad_evt) -> str:
|
||||
state = ss.state
|
||||
@@ -157,6 +166,8 @@ class UIStateSP:
|
||||
chestnut_loading=self.chestnut_loading, offroad=self.is_offroad())
|
||||
self.active_bundle = self.params.get(ACTIVE_BUNDLE_KEYS[source])
|
||||
self.model_runner_tinygrad = self.active_bundle is not None and self.active_bundle.get("runner") == "tinygrad"
|
||||
# runner the manager launches (process_config.is_tinygrad_model); with a chestnut the big slot decides
|
||||
self.active_runner_tinygrad = self.params.get("ModelRunnerTypeCache") == custom.ModelManagerSP.Runner.tinygrad
|
||||
# stock only counts the default big model's compiled pkl. a downloaded big bundle runs on the
|
||||
# chestnut just the same, so ChestnutState has to see it as available too.
|
||||
self.chestnut_compiled = self.chestnut_compiled or self.model_runner_tinygrad
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Achievements and the community log
|
||||
|
||||
Everything here runs on the car. Nothing is uploaded yet: the uploads are a dry run that writes files.
|
||||
|
||||
| File | What it does |
|
||||
| --- | --- |
|
||||
| `achievementsd.py` | Onroad process. Feeds car messages to the engine and the community log. |
|
||||
| `engine.py`, `catalog.py`, `catalog.json` | Driving stats and achievement unlocks (Phase 1). |
|
||||
| `community.py` | Community log in `community.db`: 6 km tiles, drive start/end tiles, daily totals, region per day. Also the privacy filter and the report builders. |
|
||||
| `preview.py` | Writes what the car would send, plus `map.html`, to `preview/`. |
|
||||
| `h3lite.py` | The H3 functions we need, in pure Python (sunnypilot doesn't add packages). Same cell ids as Uber's H3. |
|
||||
| `regions.py`, `regions.json.gz` | Tile to home region (`US-OH`, `CA-ON`, `GB`). Built by `tools/build_regions.py`. |
|
||||
| `replay.py` | Runs everything over drives already saved on the car. |
|
||||
| `simulate.py` | Made-up drives for tests and a demo. |
|
||||
|
||||
## Privacy rules (from the build plan)
|
||||
|
||||
- Positions only become H3 resolution 6 tiles (about 6 km). No coordinates, times of day or routes are stored.
|
||||
- A tile where drives start or end on 3+ days is a frequent stop. It and the 6 tiles around it never leave the car.
|
||||
- Other start and end tiles wait 14 days.
|
||||
- Hidden tiles never leave the car.
|
||||
- The tile batch is sorted and has no dates. It is separate from the daily report.
|
||||
- Others only ever see resolution 4 blocks (about 45 km).
|
||||
- Home region: where you drove most on most days in the last 90 days. A detected change is sent at most every 30 days; a change you make goes right away.
|
||||
|
||||
## Files on the car
|
||||
|
||||
All under `/data/media/0/achievements/`:
|
||||
|
||||
- `state.json`: achievement progress.
|
||||
- `community.db`: the community log.
|
||||
- `community_settings.json` (optional): `{"level": 0-3, "region": "auto" | "country" | "hidden" | "US-OH"}`. Level 0 (off) is the default.
|
||||
- `preview/`: `daily-YYYY-MM-DD.json`, `tiles.json`, `delete.json`, `map.html`. Rewritten after every drive.
|
||||
|
||||
`map.html` shows your frequent stops. It is for you only.
|
||||
|
||||
## See it on your own drives
|
||||
|
||||
```sh
|
||||
ssh comma@<car ip>
|
||||
cd /data/openpilot
|
||||
python -m openpilot.sunnypilot.achievements.replay # qlogs in /data/media/0/realdata; add --days 30 or --rlog
|
||||
# on your computer:
|
||||
scp comma@<car ip>:/data/media/0/achievements/replay/preview/map.html .
|
||||
```
|
||||
|
||||
The replay writes to `achievements/replay/` and doesn't touch live progress.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
python -m pytest openpilot/sunnypilot/achievements/tests
|
||||
```
|
||||
|
||||
With `h3` installed (`pip install h3`), the H3 tests also compare random points against the real library.
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/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.
|
||||
|
||||
achievementsd: counts driving stats and unlocks achievements while onroad.
|
||||
|
||||
Read-only toward the car: it only subscribes to messages, never publishes
|
||||
controls. Progress is saved to a JSON file on the device (see engine.state_path)
|
||||
every SAVE_INTERVAL_S and when the drive ends. The community log (map tiles,
|
||||
stops, daily totals; see community.py) is saved next to it, and when the drive
|
||||
ends a dry-run preview of what would be shared is written to preview/.
|
||||
Nothing leaves the device.
|
||||
"""
|
||||
import datetime
|
||||
import time
|
||||
|
||||
from openpilot.cereal import messaging
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import Ratekeeper
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.time_helpers import system_time_valid
|
||||
from openpilot.sunnypilot.achievements.community import CommunityLog, Settings
|
||||
from openpilot.sunnypilot.achievements.engine import (AchievementEngine, MAX_GPS_ACCURACY_M, Sample, load_state,
|
||||
save_state)
|
||||
from openpilot.sunnypilot.achievements.preview import write_preview
|
||||
|
||||
RATE_HZ = 10.
|
||||
SAVE_INTERVAL_S = 30.
|
||||
SLOW_POLL_S = 30. # model, build and sunnylink checks
|
||||
GPS_MAX_AGE_S = 2.
|
||||
UNREGISTERED_SUNNYLINK_DONGLE_ID = "UnregisteredDevice"
|
||||
|
||||
SERVICES = ['carState', 'selfdriveState', 'selfdriveStateSP', 'modelV2', 'driverMonitoringState',
|
||||
'gpsLocation', 'gpsLocationExternal']
|
||||
|
||||
|
||||
def _dev_build() -> bool:
|
||||
try:
|
||||
from openpilot.common.version import get_build_metadata
|
||||
bm = get_build_metadata()
|
||||
return "staging" in bm.channel or not (bm.release_channel or bm.release_sp_channel)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _active_model() -> tuple[str, str]:
|
||||
try:
|
||||
from openpilot.sunnypilot.models.helpers import get_active_bundle
|
||||
bundle = get_active_bundle()
|
||||
if bundle is None:
|
||||
return "default", "Default"
|
||||
return bundle.ref or bundle.internalName, bundle.displayName or bundle.internalName
|
||||
except Exception:
|
||||
cloudlog.exception("achievementsd: could not read the active model")
|
||||
return "", ""
|
||||
|
||||
|
||||
def _sunnylink_linked(params: Params) -> bool:
|
||||
try:
|
||||
dongle = params.get("SunnylinkDongleId")
|
||||
return bool(params.get_bool("SunnylinkEnabled")) and dongle not in (None, "", UNREGISTERED_SUNNYLINK_DONGLE_ID)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _gps_fix(sm: messaging.SubMaster, now: float) -> tuple[float | None, float | None]:
|
||||
for svc in ('gpsLocation', 'gpsLocationExternal'):
|
||||
if sm.seen[svc] and now - sm.recv_time[svc] < GPS_MAX_AGE_S:
|
||||
g = sm[svc]
|
||||
if g.hasFix and 0 < g.horizontalAccuracy < MAX_GPS_ACCURACY_M:
|
||||
return g.latitude, g.longitude
|
||||
return None, None
|
||||
|
||||
|
||||
def _car_platform(params: Params) -> str | None:
|
||||
try:
|
||||
from opendbc.car.structs import car
|
||||
cp_bytes = params.get("CarParams")
|
||||
if not cp_bytes:
|
||||
return None
|
||||
return messaging.log_from_bytes(cp_bytes, car.CarParams).carFingerprint or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _open_community() -> CommunityLog | None:
|
||||
try:
|
||||
community = CommunityLog()
|
||||
_ = community.regions # load the region table now, not mid-drive
|
||||
return community
|
||||
except Exception:
|
||||
cloudlog.exception("achievementsd: could not open the community log")
|
||||
return None
|
||||
|
||||
|
||||
def _community_step(community: CommunityLog, sample: Sample, engine: AchievementEngine, save: bool) -> CommunityLog | None:
|
||||
"""Feed one sample. On any error the community log is off until the next drive; achievements keep working."""
|
||||
try:
|
||||
community.update(sample, engine.last_step)
|
||||
if save:
|
||||
community.flush()
|
||||
return community
|
||||
except Exception:
|
||||
cloudlog.exception("achievementsd: community log failed, off until the next drive")
|
||||
community.close()
|
||||
return None
|
||||
|
||||
|
||||
def _finish_community(community: CommunityLog, engine: AchievementEngine, params: Params) -> None:
|
||||
"""Save where the drive ended and write the dry-run preview. Must stay well under the manager's 5 s stop timeout."""
|
||||
try:
|
||||
community.end_drive(engine.utc_offset_h)
|
||||
if system_time_valid():
|
||||
today = engine.local_date(datetime.datetime.now(datetime.UTC).timestamp())
|
||||
write_preview(community, engine.state, today, _car_platform(params), Settings.load())
|
||||
except Exception:
|
||||
cloudlog.exception("achievementsd: community log or preview failed")
|
||||
finally:
|
||||
community.close()
|
||||
|
||||
|
||||
def _wall_time(valid: bool) -> float | None:
|
||||
return datetime.datetime.now(datetime.UTC).timestamp() if valid else None
|
||||
|
||||
|
||||
def main():
|
||||
params = Params()
|
||||
sm = messaging.SubMaster(SERVICES, ignore_alive=['gpsLocation', 'gpsLocationExternal'],
|
||||
ignore_valid=['gpsLocation', 'gpsLocationExternal'])
|
||||
engine = AchievementEngine(load_state())
|
||||
community = _open_community()
|
||||
rk = Ratekeeper(RATE_HZ, print_delay_threshold=None)
|
||||
|
||||
dev_build = _dev_build()
|
||||
model_ref, model_name = _active_model()
|
||||
last_save = last_slow = time.monotonic()
|
||||
clock_ok = system_time_valid()
|
||||
engine.set_sunnylink_linked(_sunnylink_linked(params), _wall_time(clock_ok))
|
||||
|
||||
try:
|
||||
while True:
|
||||
sm.update(0)
|
||||
now = time.monotonic()
|
||||
|
||||
if now - last_slow > SLOW_POLL_S:
|
||||
last_slow = now
|
||||
model_ref, model_name = _active_model()
|
||||
clock_ok = system_time_valid()
|
||||
for uid in engine.set_sunnylink_linked(_sunnylink_linked(params), _wall_time(clock_ok)):
|
||||
cloudlog.event("achievement unlocked", id=uid)
|
||||
|
||||
lat, lon = _gps_fix(sm, now)
|
||||
sample = Sample(
|
||||
t=now,
|
||||
v_ego=sm['carState'].vEgo,
|
||||
op_active=sm['selfdriveState'].active,
|
||||
mads_active=sm['selfdriveStateSP'].mads.active,
|
||||
lane_change_state=sm['modelV2'].meta.laneChangeState.raw,
|
||||
dm_alert_level=sm['driverMonitoringState'].alertLevel.raw,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
model_ref=model_ref,
|
||||
model_name=model_name,
|
||||
dev_build=dev_build,
|
||||
wall=_wall_time(clock_ok),
|
||||
)
|
||||
for uid in engine.update(sample):
|
||||
cloudlog.event("achievement unlocked", id=uid)
|
||||
|
||||
save = now - last_save > SAVE_INTERVAL_S
|
||||
if save:
|
||||
last_save = now
|
||||
save_state(engine.state)
|
||||
if community is not None:
|
||||
community = _community_step(community, sample, engine, save)
|
||||
|
||||
rk.keep_time()
|
||||
finally:
|
||||
# the manager stops us with SIGINT when the drive ends
|
||||
engine.end_drive()
|
||||
save_state(engine.state)
|
||||
if community is not None:
|
||||
_finish_community(community, engine, params)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"version": 1,
|
||||
"achievements": [
|
||||
{
|
||||
"id": "first_light",
|
||||
"name": "First Light",
|
||||
"description": "Your first engaged drive with sunnypilot.",
|
||||
"metric": "engaged_km",
|
||||
"unit": "km",
|
||||
"tiers": [{"tier": "single", "threshold": 0.1}]
|
||||
},
|
||||
{
|
||||
"id": "odometer",
|
||||
"name": "Odometer",
|
||||
"description": "Distance driven with sunnypilot engaged.",
|
||||
"metric": "engaged_km",
|
||||
"unit": "km",
|
||||
"tiers": [
|
||||
{"tier": "bronze", "threshold": 100},
|
||||
{"tier": "silver", "threshold": 1000},
|
||||
{"tier": "gold", "threshold": 10000},
|
||||
{"tier": "platinum", "threshold": 50000}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "captains_hours",
|
||||
"name": "Captain's Hours",
|
||||
"description": "Time spent with sunnypilot engaged.",
|
||||
"metric": "engaged_h",
|
||||
"unit": "h",
|
||||
"tiers": [
|
||||
{"tier": "bronze", "threshold": 10},
|
||||
{"tier": "silver", "threshold": 100},
|
||||
{"tier": "gold", "threshold": 1000}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "regular",
|
||||
"name": "Regular",
|
||||
"description": "Different days you drove with sunnypilot engaged.",
|
||||
"metric": "active_days",
|
||||
"unit": "days",
|
||||
"tiers": [
|
||||
{"tier": "bronze", "threshold": 7},
|
||||
{"tier": "silver", "threshold": 30},
|
||||
{"tier": "gold", "threshold": 100},
|
||||
{"tier": "platinum", "threshold": 365}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "streak",
|
||||
"name": "Streak",
|
||||
"description": "Engaged on this many days in a row.",
|
||||
"metric": "best_streak_days",
|
||||
"unit": "days",
|
||||
"tiers": [
|
||||
{"tier": "bronze", "threshold": 7},
|
||||
{"tier": "silver", "threshold": 30}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "steady_hands",
|
||||
"name": "Steady Hands",
|
||||
"description": "Distance with MADS steering on its own, without sunnypilot's cruise.",
|
||||
"metric": "mads_km",
|
||||
"unit": "km",
|
||||
"tiers": [
|
||||
{"tier": "bronze", "threshold": 100},
|
||||
{"tier": "silver", "threshold": 1000}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "smooth_merger",
|
||||
"name": "Smooth Merger",
|
||||
"description": "Assisted lane changes completed.",
|
||||
"metric": "lane_changes",
|
||||
"unit": "changes",
|
||||
"tiers": [
|
||||
{"tier": "bronze", "threshold": 10},
|
||||
{"tier": "silver", "threshold": 100},
|
||||
{"tier": "gold", "threshold": 1000}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "road_trip",
|
||||
"name": "Road Trip",
|
||||
"description": "Engaged distance in a single day. Take breaks along the way.",
|
||||
"metric": "best_day_km",
|
||||
"unit": "km",
|
||||
"tiers": [{"tier": "single", "threshold": 500}]
|
||||
},
|
||||
{
|
||||
"id": "explorer",
|
||||
"name": "Explorer",
|
||||
"description": "Different areas (about 5 km across) driven engaged. Areas are kept as anonymous hashes on this device.",
|
||||
"metric": "areas",
|
||||
"unit": "areas",
|
||||
"tiers": [
|
||||
{"tier": "bronze", "threshold": 5},
|
||||
{"tier": "silver", "threshold": 25},
|
||||
{"tier": "gold", "threshold": 100}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "model_hopper",
|
||||
"name": "Model Hopper",
|
||||
"description": "Driving models you have driven at least 50 km engaged.",
|
||||
"metric": "models_50km",
|
||||
"unit": "models",
|
||||
"tiers": [
|
||||
{"tier": "bronze", "threshold": 3},
|
||||
{"tier": "silver", "threshold": 5},
|
||||
{"tier": "gold", "threshold": 10}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dev_branch_brave",
|
||||
"name": "Dev Branch Brave",
|
||||
"description": "Engaged distance on a development or staging build.",
|
||||
"metric": "dev_km",
|
||||
"unit": "km",
|
||||
"tiers": [{"tier": "single", "threshold": 100}]
|
||||
},
|
||||
{
|
||||
"id": "attentive",
|
||||
"name": "Attentive",
|
||||
"description": "Engaged time in a row without a red driver monitoring alert.",
|
||||
"metric": "attentive_best_h",
|
||||
"unit": "h",
|
||||
"tiers": [
|
||||
{"tier": "bronze", "threshold": 10},
|
||||
{"tier": "silver", "threshold": 50},
|
||||
{"tier": "gold", "threshold": 100}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "seasons",
|
||||
"name": "Four Seasons",
|
||||
"description": "Engaged drives in spring, summer, fall and winter.",
|
||||
"metric": "seasons",
|
||||
"unit": "seasons",
|
||||
"tiers": [{"tier": "single", "threshold": 4}]
|
||||
},
|
||||
{
|
||||
"id": "linked_up",
|
||||
"name": "Linked Up",
|
||||
"description": "Turn on sunnylink on this device.",
|
||||
"metric": "sunnylink_linked",
|
||||
"unit": "",
|
||||
"tiers": [{"tier": "single", "threshold": 1}]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
The achievement catalog. Pure Python on purpose: the UI, achievementsd and the
|
||||
tests all load it, and none of them should need cereal or the Params library to
|
||||
read a list of goals.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
CATALOG_PATH = Path(__file__).with_name("catalog.json")
|
||||
SINGLE = "single"
|
||||
KM_TO_MI = 0.621371
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tier:
|
||||
tier: str
|
||||
threshold: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Achievement:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
metric: str
|
||||
unit: str
|
||||
tiers: tuple[Tier, ...]
|
||||
|
||||
def unlock_id(self, tier: Tier) -> str:
|
||||
"""The id an unlock is stored and shared under: `odometer.silver`, or just `first_light`."""
|
||||
return self.id if tier.tier == SINGLE else f"{self.id}.{tier.tier}"
|
||||
|
||||
@property
|
||||
def tiered(self) -> bool:
|
||||
return not (len(self.tiers) == 1 and self.tiers[0].tier == SINGLE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Catalog:
|
||||
version: int
|
||||
achievements: tuple[Achievement, ...]
|
||||
|
||||
@property
|
||||
def metrics(self) -> set[str]:
|
||||
return {a.metric for a in self.achievements}
|
||||
|
||||
def all_unlock_ids(self) -> list[str]:
|
||||
return [a.unlock_id(t) for a in self.achievements for t in a.tiers]
|
||||
|
||||
|
||||
def parse_catalog(data: dict) -> Catalog:
|
||||
achievements = []
|
||||
seen: set[str] = set()
|
||||
for raw in data["achievements"]:
|
||||
tiers = tuple(Tier(str(t["tier"]), float(t["threshold"])) for t in raw["tiers"])
|
||||
if not tiers:
|
||||
raise ValueError(f"achievement {raw['id']} has no tiers")
|
||||
if any(b.threshold <= a.threshold for a, b in zip(tiers, tiers[1:], strict=False)):
|
||||
raise ValueError(f"achievement {raw['id']} tiers must increase")
|
||||
if raw["id"] in seen:
|
||||
raise ValueError(f"duplicate achievement id {raw['id']}")
|
||||
seen.add(raw["id"])
|
||||
achievements.append(Achievement(raw["id"], raw["name"], raw["description"], raw["metric"], raw.get("unit", ""), tiers))
|
||||
return Catalog(int(data["version"]), tuple(achievements))
|
||||
|
||||
|
||||
@cache
|
||||
def load_catalog(path: str = str(CATALOG_PATH)) -> Catalog:
|
||||
with open(path) as f:
|
||||
return parse_catalog(json.load(f))
|
||||
|
||||
|
||||
# --- display helpers, shared by both device UIs ---
|
||||
|
||||
def _fmt_number(value: float) -> str:
|
||||
if value >= 100 or value == int(value):
|
||||
return f"{int(value):,}"
|
||||
return f"{value:.1f}"
|
||||
|
||||
|
||||
def format_amount(value: float, unit: str, is_metric: bool = True) -> str:
|
||||
if unit == "km" and not is_metric:
|
||||
value, unit = value * KM_TO_MI, "mi"
|
||||
if not unit:
|
||||
return _fmt_number(value)
|
||||
return f"{_fmt_number(value)} {unit}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AchievementStatus:
|
||||
achievement: Achievement
|
||||
reached: Tier | None # highest tier unlocked, None if locked
|
||||
next: Tier | None # next tier to unlock, None when all are done
|
||||
value: float
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return self.next is None
|
||||
|
||||
def summary(self, is_metric: bool = True) -> str:
|
||||
"""One short line: `silver · 1,240 / 10,000 km`, `locked · 3 / 7 days`, `gold · done`."""
|
||||
a = self.achievement
|
||||
if self.next is None:
|
||||
return f"{self.reached.tier} · done" if a.tiered and self.reached else "done"
|
||||
label = self.reached.tier if self.reached else "locked"
|
||||
if not a.unit: # a yes/no achievement, like turning on sunnylink
|
||||
return label
|
||||
return f"{label} · {format_progress(self.value, self.next.threshold, a.unit, is_metric)}"
|
||||
|
||||
|
||||
def format_progress(value: float, goal: float, unit: str, is_metric: bool = True) -> str:
|
||||
if unit == "km" and not is_metric:
|
||||
value, goal, unit = value * KM_TO_MI, goal * KM_TO_MI, "mi"
|
||||
return f"{_fmt_number(min(value, goal))} / {_fmt_number(goal)} {unit}"
|
||||
|
||||
|
||||
def statuses(catalog: Catalog, metrics: dict[str, float], unlocked: dict[str, str]) -> list[AchievementStatus]:
|
||||
out = []
|
||||
for a in catalog.achievements:
|
||||
reached = None
|
||||
nxt = None
|
||||
for t in a.tiers:
|
||||
if a.unlock_id(t) in unlocked:
|
||||
reached = t
|
||||
elif nxt is None:
|
||||
nxt = t
|
||||
out.append(AchievementStatus(a, reached, nxt, float(metrics.get(a.metric, 0.0))))
|
||||
return out
|
||||
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
The community log and the privacy filter.
|
||||
|
||||
While driving, achievementsd hands every sample to CommunityLog, which keeps in
|
||||
community.db on the car:
|
||||
- the 6 km map tiles (H3 resolution 6) you drove through, with the day each was
|
||||
first seen,
|
||||
- the tile where each drive started and ended, per day,
|
||||
- engaged distance and time per day,
|
||||
- engaged distance per region per day.
|
||||
Positions only ever become tiles: no coordinates, times of day or routes are
|
||||
stored here.
|
||||
|
||||
The privacy filter (tile_view, daily_report, tile_batch, profile_update) then
|
||||
decides what could leave the car, following the Privacy spec in the build plan:
|
||||
- A tile holding a drive's start or end on 3+ different days is a frequent stop
|
||||
(home, work, school). It and the 6 tiles around it never leave the car.
|
||||
- Any other start or end tile waits 14 days.
|
||||
- Tiles the driver hid never leave the car.
|
||||
- Tiles go out as a sorted list with no dates, apart from the daily report.
|
||||
- The home region is where you drove most on most days over 90 days. It is sent
|
||||
only when it changes, and an automatic change at most every 30 days.
|
||||
|
||||
Nothing in this file sends anything.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import datetime as dt
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.sunnypilot.achievements import h3lite
|
||||
from openpilot.sunnypilot.achievements.engine import Sample, State, Step, achievements_root
|
||||
from openpilot.sunnypilot.achievements.regions import RegionTable, country_of
|
||||
from openpilot.sunnypilot.achievements.regions import table as region_table
|
||||
|
||||
REPORT_VERSION = 1
|
||||
TILE_RES = 6 # about 6 km across; only the owner sees these
|
||||
PUBLIC_RES = 4 # about 45 km across; what crews and the public see
|
||||
STOP_HOLD_DAYS = 14
|
||||
FREQUENT_STOP_DAYS = 3
|
||||
REGION_WINDOW_DAYS = 90
|
||||
REGION_RESEND_DAYS = 30
|
||||
MOVING_MS = 1.0
|
||||
METERS_PER_MILE = 1609.344
|
||||
UNLINKED_DEVICE = "c_" + "0" * 16 # the real id comes from the community key made at linking
|
||||
|
||||
LEVEL_OFF, LEVEL_ACHIEVEMENTS, LEVEL_DAILY, LEVEL_MAP = range(4)
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS tiles (cell TEXT PRIMARY KEY, first_day TEXT NOT NULL, sent INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS stops (cell TEXT NOT NULL, day TEXT NOT NULL, PRIMARY KEY (cell, day));
|
||||
CREATE TABLE IF NOT EXISTS days (day TEXT PRIMARY KEY, engaged_m REAL NOT NULL, engaged_s REAL NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS day_regions (day TEXT NOT NULL, region TEXT NOT NULL, engaged_m REAL NOT NULL,
|
||||
PRIMARY KEY (day, region));
|
||||
CREATE TABLE IF NOT EXISTS hidden (cell TEXT PRIMARY KEY);
|
||||
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
"""
|
||||
|
||||
|
||||
def db_path() -> Path:
|
||||
return achievements_root() / "community.db"
|
||||
|
||||
|
||||
def settings_path() -> Path:
|
||||
return achievements_root() / "community_settings.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
level: int = LEVEL_OFF # 0 off (default), 1 achievements, 2 daily stats, 3 map
|
||||
region: str = "auto" # "auto", "country", "hidden", or a region code the driver picked
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path | None = None) -> Settings:
|
||||
try:
|
||||
with open(path or settings_path()) as f:
|
||||
d = json.load(f)
|
||||
return cls(level=int(d.get("level", LEVEL_OFF)), region=str(d.get("region", "auto")))
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
return cls()
|
||||
|
||||
|
||||
def round_to(x: float, step: int) -> int:
|
||||
return int(round(x / step)) * step
|
||||
|
||||
|
||||
def tile_of(lat: float, lon: float) -> str:
|
||||
return h3lite.cell_to_str(h3lite.latlng_to_cell(lat, lon, TILE_RES))
|
||||
|
||||
|
||||
def ring_of(tile: str) -> set[str]:
|
||||
"""The tile and the tiles touching it: an area about 18 km across."""
|
||||
return {h3lite.cell_to_str(c) for c in h3lite.grid_disk1(h3lite.str_to_cell(tile))}
|
||||
|
||||
|
||||
def public_block(tile: str) -> str:
|
||||
return h3lite.cell_to_str(h3lite.cell_to_parent(h3lite.str_to_cell(tile), PUBLIC_RES))
|
||||
|
||||
|
||||
@dataclass
|
||||
class TileView:
|
||||
"""Every driven tile, sorted by what the privacy filter does with it."""
|
||||
driven: set[str] = field(default_factory=set)
|
||||
frequent: set[str] = field(default_factory=set) # home, work, school...
|
||||
ring: set[str] = field(default_factory=set) # frequent stops and the tiles around them: never sent
|
||||
held: dict[str, str] = field(default_factory=dict) # recent stop tile -> first day it may be sent
|
||||
hidden: set[str] = field(default_factory=set)
|
||||
shareable: set[str] = field(default_factory=set)
|
||||
sent: set[str] = field(default_factory=set)
|
||||
|
||||
@property
|
||||
def to_send(self) -> set[str]:
|
||||
return self.shareable - self.sent
|
||||
|
||||
@property
|
||||
def to_delete(self) -> set[str]:
|
||||
"""Tiles already sent that later became private (moved house, new job, hidden)."""
|
||||
return self.sent & (self.ring | self.hidden)
|
||||
|
||||
|
||||
class CommunityLog:
|
||||
def __init__(self, path: Path | None = None, regions: RegionTable | None = None):
|
||||
self.path = path or db_path()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.db = sqlite3.connect(self.path)
|
||||
self.db.executescript(SCHEMA)
|
||||
self._regions = regions
|
||||
self._known: set[str] = {r[0] for r in self.db.execute("SELECT cell FROM tiles")}
|
||||
self._region_of: dict[str, str | None] = {}
|
||||
self._utc_offset_h: float | None = None
|
||||
self._reset_pending()
|
||||
self._reset_drive()
|
||||
|
||||
def close(self) -> None:
|
||||
self.db.close()
|
||||
|
||||
@property
|
||||
def regions(self) -> RegionTable:
|
||||
if self._regions is None:
|
||||
self._regions = region_table()
|
||||
return self._regions
|
||||
|
||||
def _reset_pending(self) -> None:
|
||||
self._new_tiles: dict[str, str] = {}
|
||||
self._stops: set[tuple[str, str]] = set()
|
||||
self._days: dict[str, list[float]] = collections.defaultdict(lambda: [0.0, 0.0])
|
||||
self._day_regions: dict[tuple[str, str], float] = collections.defaultdict(float)
|
||||
|
||||
def _reset_drive(self) -> None:
|
||||
self._started = False
|
||||
self._last_fix: tuple[str, str] | None = None
|
||||
|
||||
# --- while driving ---
|
||||
|
||||
def update(self, s: Sample, step: Step) -> None:
|
||||
day = step.day
|
||||
if day is None:
|
||||
return
|
||||
if s.engaged and step.dt_s > 0.0:
|
||||
d = self._days[day]
|
||||
d[0] += step.engaged_m
|
||||
d[1] += step.dt_s
|
||||
|
||||
if s.lat is None or s.lon is None:
|
||||
return
|
||||
tile = tile_of(s.lat, s.lon)
|
||||
if not self._started:
|
||||
self._started = True
|
||||
self._stops.add((tile, day))
|
||||
self._last_fix = (tile, day)
|
||||
|
||||
if s.v_ego > MOVING_MS and tile not in self._known and tile not in self._new_tiles:
|
||||
self._new_tiles[tile] = day
|
||||
|
||||
if step.engaged_m > 0.0:
|
||||
if tile not in self._region_of:
|
||||
self._region_of[tile] = self.regions.lookup_cell(h3lite.str_to_cell(tile))
|
||||
region = self._region_of[tile]
|
||||
if region is not None:
|
||||
self._day_regions[(day, region)] += step.engaged_m
|
||||
|
||||
def end_drive(self, utc_offset_h: float | None = None) -> None:
|
||||
"""Record where the drive ended and save. Safe to call more than once."""
|
||||
if self._last_fix is not None:
|
||||
self._stops.add(self._last_fix)
|
||||
if utc_offset_h is not None:
|
||||
self._utc_offset_h = utc_offset_h
|
||||
self._reset_drive()
|
||||
self.flush()
|
||||
|
||||
def flush(self) -> None:
|
||||
with self.db:
|
||||
self.db.executemany("INSERT OR IGNORE INTO tiles (cell, first_day) VALUES (?, ?)", self._new_tiles.items())
|
||||
self.db.executemany("INSERT OR IGNORE INTO stops (cell, day) VALUES (?, ?)", self._stops)
|
||||
self.db.executemany("""INSERT INTO days (day, engaged_m, engaged_s) VALUES (?, ?, ?)
|
||||
ON CONFLICT(day) DO UPDATE SET engaged_m = engaged_m + excluded.engaged_m,
|
||||
engaged_s = engaged_s + excluded.engaged_s""",
|
||||
[(day, m, s) for day, (m, s) in self._days.items()])
|
||||
self.db.executemany("""INSERT INTO day_regions (day, region, engaged_m) VALUES (?, ?, ?)
|
||||
ON CONFLICT(day, region) DO UPDATE SET engaged_m = engaged_m + excluded.engaged_m""",
|
||||
[(day, region, m) for (day, region), m in self._day_regions.items()])
|
||||
if self._utc_offset_h is not None:
|
||||
self._set_meta("utc_offset_h", self._utc_offset_h)
|
||||
self._known.update(self._new_tiles)
|
||||
self._reset_pending()
|
||||
|
||||
# --- settings kept with the data ---
|
||||
|
||||
def _get_meta(self, key: str, default=None):
|
||||
row = self.db.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
|
||||
return json.loads(row[0]) if row else default
|
||||
|
||||
def _set_meta(self, key: str, value) -> None:
|
||||
self.db.execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", (key, json.dumps(value)))
|
||||
|
||||
def hide_tile(self, tile: str) -> None:
|
||||
with self.db:
|
||||
self.db.execute("INSERT OR IGNORE INTO hidden (cell) VALUES (?)", (tile,))
|
||||
|
||||
def unhide_tile(self, tile: str) -> None:
|
||||
with self.db:
|
||||
self.db.execute("DELETE FROM hidden WHERE cell = ?", (tile,))
|
||||
|
||||
def mark_sent(self, tiles: set[str], sent: bool = True) -> None:
|
||||
"""For the uploader: tiles the server now has (or, with sent=False, has deleted)."""
|
||||
with self.db:
|
||||
self.db.executemany("UPDATE tiles SET sent = ? WHERE cell = ?", [(int(sent), t) for t in tiles])
|
||||
|
||||
# --- the privacy filter ---
|
||||
|
||||
def tile_view(self, today: dt.date) -> TileView:
|
||||
v = TileView()
|
||||
for cell, sent in self.db.execute("SELECT cell, sent FROM tiles"):
|
||||
v.driven.add(cell)
|
||||
if sent:
|
||||
v.sent.add(cell)
|
||||
|
||||
stop_days: dict[str, set[str]] = collections.defaultdict(set)
|
||||
for cell, day in self.db.execute("SELECT cell, day FROM stops"):
|
||||
stop_days[cell].add(day)
|
||||
v.frequent = {c for c, days in stop_days.items() if len(days) >= FREQUENT_STOP_DAYS}
|
||||
for c in v.frequent:
|
||||
v.ring |= ring_of(c)
|
||||
for c, days in stop_days.items():
|
||||
if c in v.ring:
|
||||
continue
|
||||
release = dt.date.fromisoformat(max(days)) + dt.timedelta(days=STOP_HOLD_DAYS)
|
||||
if release > today:
|
||||
v.held[c] = release.isoformat()
|
||||
|
||||
v.hidden = {r[0] for r in self.db.execute("SELECT cell FROM hidden")}
|
||||
v.shareable = v.driven - v.ring - set(v.held) - v.hidden
|
||||
return v
|
||||
|
||||
def driven_days(self, since: dt.date | None = None) -> list[str]:
|
||||
rows = self.db.execute("SELECT day FROM days WHERE day >= ? ORDER BY day", ((since or dt.date.min).isoformat(),))
|
||||
return [r[0] for r in rows]
|
||||
|
||||
def day_stats(self, day: str) -> tuple[float, float, int]:
|
||||
"""Engaged meters, engaged seconds and new tiles on a day."""
|
||||
row = self.db.execute("SELECT engaged_m, engaged_s FROM days WHERE day = ?", (day,)).fetchone()
|
||||
new = self.db.execute("SELECT COUNT(*) FROM tiles WHERE first_day = ?", (day,)).fetchone()[0]
|
||||
return (row[0], row[1], new) if row else (0.0, 0.0, new)
|
||||
|
||||
def region_days(self, today: dt.date) -> collections.Counter:
|
||||
"""How many of the last 90 days each region was where you drove most, engaged."""
|
||||
since = (today - dt.timedelta(days=REGION_WINDOW_DAYS)).isoformat()
|
||||
best: dict[str, tuple[float, str]] = {}
|
||||
for day, region, m in self.db.execute("SELECT day, region, engaged_m FROM day_regions WHERE day > ? ORDER BY region",
|
||||
(since,)):
|
||||
if day not in best or m > best[day][0]:
|
||||
best[day] = (m, region)
|
||||
return collections.Counter(region for _, region in best.values())
|
||||
|
||||
def home_region(self, today: dt.date) -> str | None:
|
||||
tally = self.region_days(today)
|
||||
if not tally:
|
||||
return None
|
||||
return max(sorted(tally), key=lambda r: tally[r])
|
||||
|
||||
def shared_region(self, today: dt.date, settings: Settings) -> str | None:
|
||||
if settings.region == "hidden":
|
||||
return None
|
||||
if settings.region not in ("auto", "country"):
|
||||
return settings.region
|
||||
home = self.home_region(today)
|
||||
if home is None:
|
||||
return None
|
||||
return country_of(home) if settings.region == "country" else home
|
||||
|
||||
def profile_update(self, today: dt.date, car: str | None, settings: Settings) -> dict | None:
|
||||
"""Car model and home region, only when they changed since they were last sent."""
|
||||
last = self._get_meta("profile_sent", {})
|
||||
out: dict = {}
|
||||
if car and car != last.get("car"):
|
||||
out["car"] = car
|
||||
region = self.shared_region(today, settings)
|
||||
if region != last.get("region") and (region is not None or "region" in last):
|
||||
# the driver changing the setting goes right away; a change the car detected waits
|
||||
chosen = settings.region != last.get("choice", "auto")
|
||||
last_day = last.get("region_day")
|
||||
if chosen or last_day is None or (today - dt.date.fromisoformat(last_day)).days >= REGION_RESEND_DAYS:
|
||||
out["region"] = region
|
||||
return out or None
|
||||
|
||||
def mark_profile_sent(self, today: dt.date, profile: dict, settings: Settings) -> None:
|
||||
last = self._get_meta("profile_sent", {})
|
||||
if "car" in profile:
|
||||
last["car"] = profile["car"]
|
||||
if "region" in profile:
|
||||
last["region"] = profile["region"]
|
||||
last["region_day"] = today.isoformat()
|
||||
last["choice"] = settings.region
|
||||
with self.db:
|
||||
self._set_meta("profile_sent", last)
|
||||
|
||||
# --- uploads ---
|
||||
|
||||
def unlock_day(self, unix: float) -> str | None:
|
||||
if not unix:
|
||||
return None # unlocked before the clock was valid
|
||||
offset = self._utc_offset_h if self._utc_offset_h is not None else self._get_meta("utc_offset_h", 0)
|
||||
return (dt.datetime.fromtimestamp(unix, dt.UTC) + dt.timedelta(hours=offset)).date().isoformat()
|
||||
|
||||
def daily_report(self, day: str, state: State, level: int, profile: dict | None = None,
|
||||
include_undated: bool = False, device: str = UNLINKED_DEVICE) -> dict | None:
|
||||
"""The daily report for one day, with only the parts the sharing level allows."""
|
||||
if level < LEVEL_ACHIEVEMENTS:
|
||||
return None
|
||||
unlocks = []
|
||||
for uid, unix in sorted(state.unlocked.items()):
|
||||
d = self.unlock_day(unix)
|
||||
if d == day or (d is None and include_undated):
|
||||
unlocks.append({"id": uid, "day": day})
|
||||
report: dict = {
|
||||
"v": REPORT_VERSION,
|
||||
"device": device,
|
||||
"day": day,
|
||||
"unlocks": unlocks,
|
||||
"totals": {"engaged_mi": round_to(state.engaged_m / METERS_PER_MILE, 10),
|
||||
"engaged_h": round_to(state.engaged_s / 3600.0, 10)},
|
||||
}
|
||||
if level >= LEVEL_DAILY:
|
||||
m, s, new = self.day_stats(day)
|
||||
report["daily"] = {"engaged_mi": round(m / METERS_PER_MILE), "engaged_min": round(s / 60.0), "new_places": new}
|
||||
if profile:
|
||||
report["profile"] = profile
|
||||
return report
|
||||
|
||||
def tile_batch(self, today: dt.date, device: str = UNLINKED_DEVICE) -> dict | None:
|
||||
"""The weekly tile batch: new shareable tiles, sorted, with no dates."""
|
||||
tiles = self.tile_view(today).to_send
|
||||
if not tiles:
|
||||
return None
|
||||
return {"v": REPORT_VERSION, "device": device, "tiles": sorted(tiles)}
|
||||
|
||||
def tile_deletes(self, today: dt.date, device: str = UNLINKED_DEVICE) -> dict | None:
|
||||
tiles = self.tile_view(today).to_delete
|
||||
if not tiles:
|
||||
return None
|
||||
return {"v": REPORT_VERSION, "device": device, "delete_tiles": sorted(tiles)}
|
||||
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
The achievements engine: running totals and unlocks, fed one Sample at a time.
|
||||
|
||||
Pure Python with no cereal or Params imports, so it can be tested and replayed
|
||||
anywhere. achievementsd turns live messages into Samples; the UI only reads the
|
||||
saved state file.
|
||||
|
||||
Everything stays on the device. Explorer areas are stored as salted hashes of
|
||||
~5 km geohash cells, never as coordinates.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.sunnypilot.achievements.catalog import Catalog, load_catalog
|
||||
|
||||
STATE_VERSION = 1
|
||||
|
||||
# plausibility limits for a single update
|
||||
MAX_DT_S = 1.0 # a longer gap (process stall, suspend) counts nothing
|
||||
MAX_SPEED_MS = 70.0 # ~250 km/h; above this the reading is not trusted
|
||||
MIN_SPEED_MS = 0.0
|
||||
|
||||
# GPS
|
||||
GEOHASH_PRECISION = 5 # cells of about 4.9 x 4.9 km
|
||||
MAX_GPS_ACCURACY_M = 100.0
|
||||
MODEL_KM_GOAL = 50.0 # Model Hopper: km on a model before it counts
|
||||
|
||||
# driver monitoring alertLevel.three is the red, take-control-now alert
|
||||
DM_ALERT_RED = 3
|
||||
|
||||
# modelV2.meta.laneChangeState
|
||||
LANE_CHANGE_STARTING = 2
|
||||
LANE_CHANGE_FINISHING = 3
|
||||
|
||||
_GEOHASH_BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
|
||||
|
||||
|
||||
def achievements_root() -> Path:
|
||||
"""/data/media/0/achievements on the device (survives updates), ~/.comma/... on PC."""
|
||||
if "ACHIEVEMENTS_ROOT" in os.environ:
|
||||
return Path(os.environ["ACHIEVEMENTS_ROOT"])
|
||||
if os.path.isfile("/TICI"):
|
||||
return Path("/data/media/0/achievements")
|
||||
return Path.home() / (".comma" + os.environ.get("OPENPILOT_PREFIX", "")) / "media" / "0" / "achievements"
|
||||
|
||||
|
||||
def state_path() -> Path:
|
||||
return achievements_root() / "state.json"
|
||||
|
||||
|
||||
def seen_path() -> Path:
|
||||
"""Unlocks the driver has already looked at. Written only by the UI."""
|
||||
return achievements_root() / "seen.json"
|
||||
|
||||
|
||||
def geohash(lat: float, lon: float, precision: int = GEOHASH_PRECISION) -> str:
|
||||
lat_rng, lon_rng = [-90.0, 90.0], [-180.0, 180.0]
|
||||
bits, bit, ch, even = [16, 8, 4, 2, 1], 0, 0, True
|
||||
out = []
|
||||
while len(out) < precision:
|
||||
rng, val = (lon_rng, lon) if even else (lat_rng, lat)
|
||||
mid = (rng[0] + rng[1]) / 2
|
||||
if val >= mid:
|
||||
ch |= bits[bit]
|
||||
rng[0] = mid
|
||||
else:
|
||||
rng[1] = mid
|
||||
even = not even
|
||||
if bit < 4:
|
||||
bit += 1
|
||||
else:
|
||||
out.append(_GEOHASH_BASE32[ch])
|
||||
bit, ch = 0, 0
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def season(day: dt.date, southern: bool = False) -> str:
|
||||
s = ("winter", "winter", "spring", "spring", "spring", "summer", "summer", "summer", "fall", "fall", "fall", "winter")[day.month - 1]
|
||||
if southern:
|
||||
s = {"winter": "summer", "summer": "winter", "spring": "fall", "fall": "spring"}[s]
|
||||
return s
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sample:
|
||||
"""One reading of the car, built by achievementsd from cereal."""
|
||||
t: float # monotonic seconds, for time between samples
|
||||
v_ego: float # m/s
|
||||
op_active: bool # selfdriveState.active (sunnypilot driving, incl. driver override)
|
||||
mads_active: bool = False # selfdriveStateSP.mads.active (lateral)
|
||||
lane_change_state: int = 0 # modelV2.meta.laneChangeState
|
||||
dm_alert_level: int = 0 # driverMonitoringState.alertLevel
|
||||
lat: float | None = None # only when a good fix is present
|
||||
lon: float | None = None
|
||||
model_ref: str = ""
|
||||
model_name: str = ""
|
||||
dev_build: bool = False
|
||||
wall: float | None = None # unix time; None until the device clock is valid
|
||||
|
||||
@property
|
||||
def engaged(self) -> bool:
|
||||
return self.op_active or self.mads_active
|
||||
|
||||
@property
|
||||
def mads_only(self) -> bool:
|
||||
return self.mads_active and not self.op_active
|
||||
|
||||
|
||||
@dataclass
|
||||
class Step:
|
||||
"""What the last update counted, for the community log (see community.py)."""
|
||||
dt_s: float = 0.0 # trusted seconds since the previous sample (0 after a gap or a bad reading)
|
||||
engaged_m: float = 0.0 # meters driven engaged in this step
|
||||
day: str | None = None # local calendar day; None until the clock is valid
|
||||
|
||||
|
||||
@dataclass
|
||||
class State:
|
||||
version: int = STATE_VERSION
|
||||
salt: str = field(default_factory=lambda: secrets.token_hex(16))
|
||||
engaged_m: float = 0.0
|
||||
engaged_s: float = 0.0
|
||||
mads_m: float = 0.0
|
||||
dev_m: float = 0.0
|
||||
lane_changes: int = 0
|
||||
days: list[str] = field(default_factory=list) # ISO dates with engaged driving, sorted
|
||||
best_streak_days: int = 0
|
||||
today: str = ""
|
||||
today_m: float = 0.0
|
||||
best_day_m: float = 0.0
|
||||
areas: list[str] = field(default_factory=list) # salted hashes of geohash cells, sorted
|
||||
model_m: dict[str, float] = field(default_factory=dict)
|
||||
model_names: dict[str, str] = field(default_factory=dict)
|
||||
attentive_run_s: float = 0.0
|
||||
attentive_best_s: float = 0.0
|
||||
seasons: list[str] = field(default_factory=list)
|
||||
sunnylink_linked: bool = False
|
||||
unlocked: dict[str, float] = field(default_factory=dict) # unlock id -> unix time
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> State:
|
||||
known = {k: v for k, v in d.items() if k in cls.__dataclass_fields__}
|
||||
return cls(**known)
|
||||
|
||||
def metrics(self) -> dict[str, float]:
|
||||
return {
|
||||
"engaged_km": self.engaged_m / 1000.0,
|
||||
"engaged_h": self.engaged_s / 3600.0,
|
||||
"active_days": float(len(self.days)),
|
||||
"best_streak_days": float(self.best_streak_days),
|
||||
"mads_km": self.mads_m / 1000.0,
|
||||
"lane_changes": float(self.lane_changes),
|
||||
"best_day_km": max(self.best_day_m, self.today_m) / 1000.0,
|
||||
"areas": float(len(self.areas)),
|
||||
"models_50km": float(sum(1 for m in self.model_m.values() if m >= MODEL_KM_GOAL * 1000.0)),
|
||||
"dev_km": self.dev_m / 1000.0,
|
||||
"attentive_best_h": max(self.attentive_best_s, self.attentive_run_s) / 3600.0,
|
||||
"seasons": float(len(self.seasons)),
|
||||
"sunnylink_linked": 1.0 if self.sunnylink_linked else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _streak_ending(days: list[str], last: str) -> int:
|
||||
have = set(days)
|
||||
d = dt.date.fromisoformat(last)
|
||||
n = 0
|
||||
while d.isoformat() in have:
|
||||
n += 1
|
||||
d -= dt.timedelta(days=1)
|
||||
return n
|
||||
|
||||
|
||||
class AchievementEngine:
|
||||
def __init__(self, state: State | None = None, catalog: Catalog | None = None):
|
||||
self.state = state or State()
|
||||
self.catalog = catalog or load_catalog()
|
||||
self._last_t: float | None = None
|
||||
self._last_lane_change = 0
|
||||
self._utc_offset_h: float | None = None # from GPS longitude, see local_date
|
||||
self._southern = False
|
||||
self.last_step = Step()
|
||||
|
||||
# --- time ---
|
||||
|
||||
@property
|
||||
def utc_offset_h(self) -> float | None:
|
||||
return self._utc_offset_h
|
||||
|
||||
def local_date(self, wall: float) -> dt.date:
|
||||
"""Calendar day for streaks and Road Trip.
|
||||
|
||||
Comma devices keep their clock in UTC, which would split an evening drive in
|
||||
the Americas across two days. With a GPS fix, use the solar offset from
|
||||
longitude (15 degrees per hour); without one, fall back to the system zone.
|
||||
"""
|
||||
if self._utc_offset_h is not None:
|
||||
return (dt.datetime.fromtimestamp(wall, dt.UTC) + dt.timedelta(hours=self._utc_offset_h)).date()
|
||||
return dt.datetime.fromtimestamp(wall).date()
|
||||
|
||||
# --- updates ---
|
||||
|
||||
def update(self, s: Sample) -> list[str]:
|
||||
"""Feed one sample. Returns unlock ids that were newly unlocked by it."""
|
||||
st = self.state
|
||||
dt_s = 0.0 if self._last_t is None else s.t - self._last_t
|
||||
self._last_t = s.t
|
||||
if not (0.0 < dt_s <= MAX_DT_S) or not (MIN_SPEED_MS <= s.v_ego <= MAX_SPEED_MS):
|
||||
dt_s = 0.0
|
||||
|
||||
if s.lat is not None and s.lon is not None:
|
||||
self._utc_offset_h = round(s.lon / 15.0)
|
||||
self._southern = s.lat < 0
|
||||
|
||||
day = self.local_date(s.wall).isoformat() if s.wall is not None else None
|
||||
self.last_step = Step(dt_s=dt_s, engaged_m=s.v_ego * dt_s if s.engaged else 0.0, day=day)
|
||||
|
||||
if s.engaged and dt_s > 0.0:
|
||||
dist = s.v_ego * dt_s
|
||||
if day is not None and day != st.today:
|
||||
st.best_day_m = max(st.best_day_m, st.today_m)
|
||||
st.today, st.today_m = day, 0.0
|
||||
|
||||
st.engaged_m += dist
|
||||
st.engaged_s += dt_s
|
||||
if day is not None:
|
||||
st.today_m += dist
|
||||
if s.mads_only:
|
||||
st.mads_m += dist
|
||||
if s.dev_build:
|
||||
st.dev_m += dist
|
||||
if s.model_ref:
|
||||
st.model_m[s.model_ref] = st.model_m.get(s.model_ref, 0.0) + dist
|
||||
if s.model_name:
|
||||
st.model_names[s.model_ref] = s.model_name
|
||||
|
||||
if day is not None and day not in st.days and s.v_ego > 1.0:
|
||||
st.days.append(day)
|
||||
st.days.sort()
|
||||
st.best_streak_days = max(st.best_streak_days, _streak_ending(st.days, day))
|
||||
sn = season(dt.date.fromisoformat(day), self._southern)
|
||||
if sn not in st.seasons:
|
||||
st.seasons.append(sn)
|
||||
|
||||
if s.dm_alert_level >= DM_ALERT_RED:
|
||||
st.attentive_best_s = max(st.attentive_best_s, st.attentive_run_s)
|
||||
st.attentive_run_s = 0.0
|
||||
else:
|
||||
st.attentive_run_s += dt_s
|
||||
|
||||
if s.lat is not None and s.lon is not None:
|
||||
area = hashlib.sha256((st.salt + geohash(s.lat, s.lon)).encode()).hexdigest()[:16]
|
||||
if area not in st.areas:
|
||||
st.areas.append(area)
|
||||
st.areas.sort()
|
||||
|
||||
# a lane change counts once, when it reaches its finishing phase while engaged
|
||||
if s.engaged and s.lane_change_state == LANE_CHANGE_FINISHING and self._last_lane_change == LANE_CHANGE_STARTING:
|
||||
st.lane_changes += 1
|
||||
self._last_lane_change = s.lane_change_state
|
||||
|
||||
return self.check_unlocks(s.wall)
|
||||
|
||||
def set_sunnylink_linked(self, linked: bool, wall: float | None) -> list[str]:
|
||||
self.state.sunnylink_linked = self.state.sunnylink_linked or linked
|
||||
return self.check_unlocks(wall)
|
||||
|
||||
def end_drive(self) -> None:
|
||||
"""Close out the attentive run so the best is kept, and forget timing between drives."""
|
||||
st = self.state
|
||||
st.attentive_best_s = max(st.attentive_best_s, st.attentive_run_s)
|
||||
self._last_t = None
|
||||
self._last_lane_change = 0
|
||||
|
||||
def check_unlocks(self, wall: float | None) -> list[str]:
|
||||
"""Unlock every tier whose goal is met. The unlock time is 0 when the clock isn't valid yet."""
|
||||
metrics = self.state.metrics()
|
||||
new = []
|
||||
for a in self.catalog.achievements:
|
||||
value = metrics.get(a.metric, 0.0)
|
||||
for tier in a.tiers:
|
||||
uid = a.unlock_id(tier)
|
||||
if value >= tier.threshold and uid not in self.state.unlocked:
|
||||
self.state.unlocked[uid] = round(wall) if wall is not None else 0
|
||||
new.append(uid)
|
||||
return new
|
||||
|
||||
|
||||
# --- persistence ---
|
||||
|
||||
def load_state(path: Path | None = None) -> State:
|
||||
path = path or state_path()
|
||||
try:
|
||||
with open(path) as f:
|
||||
return State.from_dict(json.load(f))
|
||||
except FileNotFoundError:
|
||||
return State()
|
||||
except (ValueError, TypeError):
|
||||
# unreadable JSON: keep it aside for debugging instead of silently overwriting it.
|
||||
# Other OSErrors propagate, so a transient read failure can never be saved over real progress.
|
||||
os.replace(path, path.with_suffix(".corrupt.json"))
|
||||
return State()
|
||||
|
||||
|
||||
def save_json(path: Path, data: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(data, f, separators=(",", ":"), sort_keys=True)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def save_state(state: State, path: Path | None = None) -> None:
|
||||
save_json(path or state_path(), asdict(state))
|
||||
|
||||
|
||||
def load_seen(path: Path | None = None) -> set[str]:
|
||||
try:
|
||||
with open(path or seen_path()) as f:
|
||||
return set(json.load(f).get("seen", []))
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return set()
|
||||
|
||||
|
||||
def save_seen(seen: set[str], path: Path | None = None) -> None:
|
||||
save_json(path or seen_path(), {"seen": sorted(seen)})
|
||||
@@ -0,0 +1,629 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
h3lite: the few H3 functions the car needs, in pure Python.
|
||||
|
||||
sunnypilot doesn't add Python packages, so instead of the h3 package this is a
|
||||
line-by-line port of the parts of Uber's H3 v4.1.2 C library we use:
|
||||
latLngToCell, cellToLatLng, cellToParent and a one-ring disk. Cell ids match
|
||||
the real library exactly (tests/test_h3lite.py checks this against h3-py when
|
||||
it's installed, and against fixed vectors when it isn't), so the site and the
|
||||
server can keep using h3-js and h3-pg on the same ids.
|
||||
|
||||
H3 is Copyright Uber Technologies, Inc., licensed under the Apache License 2.0
|
||||
(https://github.com/uber/h3). The tables below are copied from its
|
||||
faceijk.c and baseCells.c.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
MAX_RES = 15
|
||||
NUM_FACES = 20
|
||||
NUM_BASE_CELLS = 122
|
||||
|
||||
_EPSILON = 1e-16
|
||||
_SQRT3_2 = 0.8660254037844386467637231707529361834714
|
||||
_SQRT7 = 2.6457513110645905905016157536392604257102
|
||||
_AP7_ROT_RADS = 0.333473172251832115336090755351601070065900389
|
||||
_RES0_U_GNOMONIC = 0.38196601125010500003
|
||||
_2PI = 2.0 * math.pi
|
||||
|
||||
# Directions (index digits)
|
||||
_CENTER, _K, _J, _JK, _I, _IK, _IJ = range(7)
|
||||
_UNIT_VECS = ((0, 0, 0), (0, 0, 1), (0, 1, 0), (0, 1, 1), (1, 0, 0), (1, 0, 1), (1, 1, 0))
|
||||
_ROT_CCW = (0, _IK, _JK, _K, _IJ, _I, _J) # digit -> digit rotated 60 degrees ccw
|
||||
_ROT_CW = (0, _JK, _IJ, _J, _IK, _K, _I) # digit -> digit rotated 60 degrees cw
|
||||
|
||||
# face neighbor quadrants
|
||||
_IJ_Q, _KI_Q, _JK_Q = 1, 2, 3
|
||||
|
||||
# bit layout of an H3 index
|
||||
_MODE_OFFSET = 59
|
||||
_RES_OFFSET = 52
|
||||
_BC_OFFSET = 45
|
||||
_H3_INIT = (1 << 45) - 1 # every digit set to 7
|
||||
_CELL_MODE = 1
|
||||
|
||||
|
||||
def _max_dim(res: int) -> int: # Class II resolutions only
|
||||
return 2 * 7 ** (res // 2)
|
||||
|
||||
|
||||
def _unit_scale(res: int) -> int: # Class II resolutions only
|
||||
return 7 ** (res // 2)
|
||||
|
||||
|
||||
# --- tables copied from H3 v4.1.2 faceijk.c and baseCells.c ---
|
||||
|
||||
FACE_CENTER_GEO = (
|
||||
(0.80358264971899, 1.2483974196173961), (1.3077478834556382, 2.5369450098779214),
|
||||
(1.054751253523952, -1.3475173589003966), (0.6001915955381868, -0.45060390946975576),
|
||||
(0.49171542819877384, 0.40198820291130694), (0.1727453274156187, 1.6781468852804338),
|
||||
(0.6059293215713507, 2.9539233298124117), (0.42737051832897965, -1.8888762003362853),
|
||||
(-0.07906611854921283, -0.7334295133808677), (-0.23096164445538364, 0.506495587332349),
|
||||
(0.07906611854921283, 2.4081631402089254), (0.23096164445538364, -2.635097066257444),
|
||||
(-0.1727453274156187, -1.4634457683093596), (-0.6059293215713507, -0.18766932377738163),
|
||||
(-0.42737051832897965, 1.2527164532535078), (-0.6001915955381868, 2.6909887441200375),
|
||||
(-0.49171542819877384, -2.7396044506784865), (-0.80358264971899, -1.8931952339723972),
|
||||
(-1.3077478834556382, -0.6046476437118721), (-1.054751253523952, 1.7940752946893965),
|
||||
)
|
||||
FACE_CENTER_POINT = (
|
||||
(0.2199307791404606, 0.6583691780274996, 0.7198475378926182),
|
||||
(-0.2139234834501421, 0.1478171829550703, 0.9656017935214205),
|
||||
(0.1092625278784797, -0.481195157287321, 0.8697775121287253),
|
||||
(0.7428567301586791, -0.3593941678278028, 0.5648005936517033),
|
||||
(0.8112534709140969, 0.3448953237639384, 0.472138773641393),
|
||||
(-0.1055498149613921, 0.9794457296411413, 0.1718874610009365),
|
||||
(-0.8075407579970092, 0.1533552485898818, 0.5695261994882688),
|
||||
(-0.2846148069787907, -0.8644080972654206, 0.4144792552473539),
|
||||
(0.7405621473854482, -0.6673299564565524, -0.0789837646326737),
|
||||
(0.8512303986474293, 0.4722343788582681, -0.2289137388687808),
|
||||
(-0.7405621473854481, 0.6673299564565524, 0.0789837646326737),
|
||||
(-0.8512303986474292, -0.4722343788582682, 0.2289137388687808),
|
||||
(0.1055498149613919, -0.9794457296411413, -0.1718874610009365),
|
||||
(0.8075407579970092, -0.1533552485898819, -0.5695261994882688),
|
||||
(0.2846148069787908, 0.8644080972654204, -0.4144792552473539),
|
||||
(-0.7428567301586791, 0.3593941678278027, -0.5648005936517033),
|
||||
(-0.811253470914097, -0.3448953237639382, -0.472138773641393),
|
||||
(-0.2199307791404607, -0.6583691780274996, -0.7198475378926182),
|
||||
(0.213923483450142, -0.1478171829550704, -0.9656017935214205),
|
||||
(-0.1092625278784796, 0.481195157287321, -0.8697775121287253),
|
||||
)
|
||||
FACE_AXIS_AZ_CII = (
|
||||
5.6199582685239395, 5.7603390817141875, 0.78021365439343, 0.4304693639799999,
|
||||
6.130269123335111, 2.692877706530643, 2.982963003477244, 3.532912002790141,
|
||||
3.494305004259568, 3.0032141694995382, 5.930472956509812, 0.13837848409025486,
|
||||
0.4487149470591504, 0.15862965011254937, 5.891865957979238, 2.711123289609793,
|
||||
3.294508837434268, 3.80481969224544, 3.6644388790551923, 2.361378999196363,
|
||||
)
|
||||
# per face: (central, ij, ki, jk) neighbors as (face, (i, j, k) translate, ccw 60 rotations)
|
||||
FACE_NEIGHBORS = (
|
||||
((0, (0, 0, 0), 0), (4, (2, 0, 2), 1), (1, (2, 2, 0), 5), (5, (0, 2, 2), 3)),
|
||||
((1, (0, 0, 0), 0), (0, (2, 0, 2), 1), (2, (2, 2, 0), 5), (6, (0, 2, 2), 3)),
|
||||
((2, (0, 0, 0), 0), (1, (2, 0, 2), 1), (3, (2, 2, 0), 5), (7, (0, 2, 2), 3)),
|
||||
((3, (0, 0, 0), 0), (2, (2, 0, 2), 1), (4, (2, 2, 0), 5), (8, (0, 2, 2), 3)),
|
||||
((4, (0, 0, 0), 0), (3, (2, 0, 2), 1), (0, (2, 2, 0), 5), (9, (0, 2, 2), 3)),
|
||||
((5, (0, 0, 0), 0), (10, (2, 2, 0), 3), (14, (2, 0, 2), 3), (0, (0, 2, 2), 3)),
|
||||
((6, (0, 0, 0), 0), (11, (2, 2, 0), 3), (10, (2, 0, 2), 3), (1, (0, 2, 2), 3)),
|
||||
((7, (0, 0, 0), 0), (12, (2, 2, 0), 3), (11, (2, 0, 2), 3), (2, (0, 2, 2), 3)),
|
||||
((8, (0, 0, 0), 0), (13, (2, 2, 0), 3), (12, (2, 0, 2), 3), (3, (0, 2, 2), 3)),
|
||||
((9, (0, 0, 0), 0), (14, (2, 2, 0), 3), (13, (2, 0, 2), 3), (4, (0, 2, 2), 3)),
|
||||
((10, (0, 0, 0), 0), (5, (2, 2, 0), 3), (6, (2, 0, 2), 3), (15, (0, 2, 2), 3)),
|
||||
((11, (0, 0, 0), 0), (6, (2, 2, 0), 3), (7, (2, 0, 2), 3), (16, (0, 2, 2), 3)),
|
||||
((12, (0, 0, 0), 0), (7, (2, 2, 0), 3), (8, (2, 0, 2), 3), (17, (0, 2, 2), 3)),
|
||||
((13, (0, 0, 0), 0), (8, (2, 2, 0), 3), (9, (2, 0, 2), 3), (18, (0, 2, 2), 3)),
|
||||
((14, (0, 0, 0), 0), (9, (2, 2, 0), 3), (5, (2, 0, 2), 3), (19, (0, 2, 2), 3)),
|
||||
((15, (0, 0, 0), 0), (16, (2, 0, 2), 1), (19, (2, 2, 0), 5), (10, (0, 2, 2), 3)),
|
||||
((16, (0, 0, 0), 0), (17, (2, 0, 2), 1), (15, (2, 2, 0), 5), (11, (0, 2, 2), 3)),
|
||||
((17, (0, 0, 0), 0), (18, (2, 0, 2), 1), (16, (2, 2, 0), 5), (12, (0, 2, 2), 3)),
|
||||
((18, (0, 0, 0), 0), (19, (2, 0, 2), 1), (17, (2, 2, 0), 5), (13, (0, 2, 2), 3)),
|
||||
((19, (0, 0, 0), 0), (15, (2, 0, 2), 1), (18, (2, 2, 0), 5), (14, (0, 2, 2), 3)),
|
||||
)
|
||||
# [face][i][j][k] -> (base cell, ccw 60 rotations), flattened as face*27 + i*9 + j*3 + k
|
||||
FACE_IJK_BASE_CELLS = (
|
||||
(16,0), (18,0), (24,0), (33,0), (30,0), (32,3), (49,1), (48,3), (50,3),
|
||||
(8,0), (5,5), (10,5), (22,0), (16,0), (18,0), (41,1), (33,0), (30,0),
|
||||
(4,0), (0,5), (2,5), (15,1), (8,0), (5,5), (31,1), (22,0), (16,0),
|
||||
(2,0), (6,0), (14,0), (10,0), (11,0), (17,3), (24,1), (23,3), (25,3),
|
||||
(0,0), (1,5), (9,5), (5,0), (2,0), (6,0), (18,1), (10,0), (11,0),
|
||||
(4,1), (3,5), (7,5), (8,1), (0,0), (1,5), (16,1), (5,0), (2,0),
|
||||
(7,0), (21,0), (38,0), (9,0), (19,0), (34,3), (14,1), (20,3), (36,3),
|
||||
(3,0), (13,5), (29,5), (1,0), (7,0), (21,0), (6,1), (9,0), (19,0),
|
||||
(4,2), (12,5), (26,5), (0,1), (3,0), (13,5), (2,1), (1,0), (7,0),
|
||||
(26,0), (42,0), (58,0), (29,0), (43,0), (62,3), (38,1), (47,3), (64,3),
|
||||
(12,0), (28,5), (44,5), (13,0), (26,0), (42,0), (21,1), (29,0), (43,0),
|
||||
(4,3), (15,5), (31,5), (3,1), (12,0), (28,5), (7,1), (13,0), (26,0),
|
||||
(31,0), (41,0), (49,0), (44,0), (53,0), (61,3), (58,1), (65,3), (75,3),
|
||||
(15,0), (22,5), (33,5), (28,0), (31,0), (41,0), (42,1), (44,0), (53,0),
|
||||
(4,4), (8,5), (16,5), (12,1), (15,0), (22,5), (26,1), (28,0), (31,0),
|
||||
(50,0), (48,0), (49,3), (32,0), (30,3), (33,3), (24,3), (18,3), (16,3),
|
||||
(70,0), (67,0), (66,3), (52,3), (50,0), (48,0), (37,3), (32,0), (30,3),
|
||||
(83,0), (87,3), (85,3), (74,3), (70,0), (67,0), (57,1), (52,3), (50,0),
|
||||
(25,0), (23,0), (24,3), (17,0), (11,3), (10,3), (14,3), (6,3), (2,3),
|
||||
(45,0), (39,0), (37,3), (35,3), (25,0), (23,0), (27,3), (17,0), (11,3),
|
||||
(63,0), (59,3), (57,3), (56,3), (45,0), (39,0), (46,3), (35,3), (25,0),
|
||||
(36,0), (20,0), (14,3), (34,0), (19,3), (9,3), (38,3), (21,3), (7,3),
|
||||
(55,0), (40,0), (27,3), (54,3), (36,0), (20,0), (51,3), (34,0), (19,3),
|
||||
(72,0), (60,3), (46,3), (73,3), (55,0), (40,0), (71,3), (54,3), (36,0),
|
||||
(64,0), (47,0), (38,3), (62,0), (43,3), (29,3), (58,3), (42,3), (26,3),
|
||||
(84,0), (69,0), (51,3), (82,3), (64,0), (47,0), (76,3), (62,0), (43,3),
|
||||
(97,0), (89,3), (71,3), (98,3), (84,0), (69,0), (96,3), (82,3), (64,0),
|
||||
(75,0), (65,0), (58,3), (61,0), (53,3), (44,3), (49,3), (41,3), (31,3),
|
||||
(94,0), (86,0), (76,3), (81,3), (75,0), (65,0), (66,3), (61,0), (53,3),
|
||||
(107,0), (104,3), (96,3), (101,3), (94,0), (86,0), (85,3), (81,3), (75,0),
|
||||
(57,0), (59,0), (63,3), (74,0), (78,3), (79,3), (83,3), (92,3), (95,3),
|
||||
(37,0), (39,3), (45,3), (52,0), (57,0), (59,0), (70,3), (74,0), (78,3),
|
||||
(24,0), (23,3), (25,3), (32,3), (37,0), (39,3), (50,3), (52,0), (57,0),
|
||||
(46,0), (60,0), (72,3), (56,0), (68,3), (80,3), (63,3), (77,3), (90,3),
|
||||
(27,0), (40,3), (55,3), (35,0), (46,0), (60,0), (45,3), (56,0), (68,3),
|
||||
(14,0), (20,3), (36,3), (17,3), (27,0), (40,3), (25,3), (35,0), (46,0),
|
||||
(71,0), (89,0), (97,3), (73,0), (91,3), (103,3), (72,3), (88,3), (105,3),
|
||||
(51,0), (69,3), (84,3), (54,0), (71,0), (89,0), (55,3), (73,0), (91,3),
|
||||
(38,0), (47,3), (64,3), (34,3), (51,0), (69,3), (36,3), (54,0), (71,0),
|
||||
(96,0), (104,0), (107,3), (98,0), (110,3), (115,3), (97,3), (111,3), (119,3),
|
||||
(76,0), (86,3), (94,3), (82,0), (96,0), (104,0), (84,3), (98,0), (110,3),
|
||||
(58,0), (65,3), (75,3), (62,3), (76,0), (86,3), (64,3), (82,0), (96,0),
|
||||
(85,0), (87,0), (83,3), (101,0), (102,3), (100,3), (107,3), (112,3), (114,3),
|
||||
(66,0), (67,3), (70,3), (81,0), (85,0), (87,0), (94,3), (101,0), (102,3),
|
||||
(49,0), (48,3), (50,3), (61,3), (66,0), (67,3), (75,3), (81,0), (85,0),
|
||||
(95,0), (92,0), (83,0), (79,0), (78,0), (74,3), (63,1), (59,3), (57,3),
|
||||
(109,0), (108,0), (100,5), (93,1), (95,0), (92,0), (77,1), (79,0), (78,0),
|
||||
(117,4), (118,5), (114,5), (106,1), (109,0), (108,0), (90,1), (93,1), (95,0),
|
||||
(90,0), (77,0), (63,0), (80,0), (68,0), (56,3), (72,1), (60,3), (46,3),
|
||||
(106,0), (93,0), (79,5), (99,1), (90,0), (77,0), (88,1), (80,0), (68,0),
|
||||
(117,3), (109,5), (95,5), (113,1), (106,0), (93,0), (105,1), (99,1), (90,0),
|
||||
(105,0), (88,0), (72,0), (103,0), (91,0), (73,3), (97,1), (89,3), (71,3),
|
||||
(113,0), (99,0), (80,5), (116,1), (105,0), (88,0), (111,1), (103,0), (91,0),
|
||||
(117,2), (106,5), (90,5), (121,1), (113,0), (99,0), (119,1), (116,1), (105,0),
|
||||
(119,0), (111,0), (97,0), (115,0), (110,0), (98,3), (107,1), (104,3), (96,3),
|
||||
(121,0), (116,0), (103,5), (120,1), (119,0), (111,0), (112,1), (115,0), (110,0),
|
||||
(117,1), (113,5), (105,5), (118,1), (121,0), (116,0), (114,1), (120,1), (119,0),
|
||||
(114,0), (112,0), (107,0), (100,0), (102,0), (101,3), (83,1), (87,3), (85,3),
|
||||
(118,0), (120,0), (115,5), (108,1), (114,0), (112,0), (92,1), (100,0), (102,0),
|
||||
(117,0), (121,5), (119,5), (109,1), (118,0), (120,0), (95,1), (108,1), (114,0),
|
||||
)
|
||||
# base cell -> (home face, i, j, k, is pentagon, cw offset face 1, cw offset face 2)
|
||||
BASE_CELL_DATA = (
|
||||
(1,1,0,0,0,0,0), (2,1,1,0,0,0,0), (1,0,0,0,0,0,0), (2,1,0,0,0,0,0),
|
||||
(0,2,0,0,1,-1,-1), (1,1,1,0,0,0,0), (1,0,0,1,0,0,0), (2,0,0,0,0,0,0),
|
||||
(0,1,0,0,0,0,0), (2,0,1,0,0,0,0), (1,0,1,0,0,0,0), (1,0,1,1,0,0,0),
|
||||
(3,1,0,0,0,0,0), (3,1,1,0,0,0,0), (11,2,0,0,1,2,6), (4,1,0,0,0,0,0),
|
||||
(0,0,0,0,0,0,0), (6,0,1,0,0,0,0), (0,0,0,1,0,0,0), (2,0,1,1,0,0,0),
|
||||
(7,0,0,1,0,0,0), (2,0,0,1,0,0,0), (0,1,1,0,0,0,0), (6,0,0,1,0,0,0),
|
||||
(10,2,0,0,1,1,5), (6,0,0,0,0,0,0), (3,0,0,0,0,0,0), (11,1,0,0,0,0,0),
|
||||
(4,1,1,0,0,0,0), (3,0,1,0,0,0,0), (0,0,1,1,0,0,0), (4,0,0,0,0,0,0),
|
||||
(5,0,1,0,0,0,0), (0,0,1,0,0,0,0), (7,0,1,0,0,0,0), (11,1,1,0,0,0,0),
|
||||
(7,0,0,0,0,0,0), (10,1,0,0,0,0,0), (12,2,0,0,1,3,7), (6,1,0,1,0,0,0),
|
||||
(7,1,0,1,0,0,0), (4,0,0,1,0,0,0), (3,0,0,1,0,0,0), (3,0,1,1,0,0,0),
|
||||
(4,0,1,0,0,0,0), (6,1,0,0,0,0,0), (11,0,0,0,0,0,0), (8,0,0,1,0,0,0),
|
||||
(5,0,0,1,0,0,0), (14,2,0,0,1,0,9), (5,0,0,0,0,0,0), (12,1,0,0,0,0,0),
|
||||
(10,1,1,0,0,0,0), (4,0,1,1,0,0,0), (12,1,1,0,0,0,0), (7,1,0,0,0,0,0),
|
||||
(11,0,1,0,0,0,0), (10,0,0,0,0,0,0), (13,2,0,0,1,4,8), (10,0,0,1,0,0,0),
|
||||
(11,0,0,1,0,0,0), (9,0,1,0,0,0,0), (8,0,1,0,0,0,0), (6,2,0,0,1,11,15),
|
||||
(8,0,0,0,0,0,0), (9,0,0,1,0,0,0), (14,1,0,0,0,0,0), (5,1,0,1,0,0,0),
|
||||
(16,0,1,1,0,0,0), (8,1,0,1,0,0,0), (5,1,0,0,0,0,0), (12,0,0,0,0,0,0),
|
||||
(7,2,0,0,1,12,16), (12,0,1,0,0,0,0), (10,0,1,0,0,0,0), (9,0,0,0,0,0,0),
|
||||
(13,1,0,0,0,0,0), (16,0,0,1,0,0,0), (15,0,1,1,0,0,0), (15,0,1,0,0,0,0),
|
||||
(16,0,1,0,0,0,0), (14,1,1,0,0,0,0), (13,1,1,0,0,0,0), (5,2,0,0,1,10,19),
|
||||
(8,1,0,0,0,0,0), (14,0,0,0,0,0,0), (9,1,0,1,0,0,0), (14,0,0,1,0,0,0),
|
||||
(17,0,0,1,0,0,0), (12,0,0,1,0,0,0), (16,0,0,0,0,0,0), (17,0,1,1,0,0,0),
|
||||
(15,0,0,1,0,0,0), (16,1,0,1,0,0,0), (9,1,0,0,0,0,0), (15,0,0,0,0,0,0),
|
||||
(13,0,0,0,0,0,0), (8,2,0,0,1,13,17), (13,0,1,0,0,0,0), (17,1,0,1,0,0,0),
|
||||
(19,0,1,0,0,0,0), (14,0,1,0,0,0,0), (19,0,1,1,0,0,0), (17,0,1,0,0,0,0),
|
||||
(13,0,0,1,0,0,0), (17,0,0,0,0,0,0), (16,1,0,0,0,0,0), (9,2,0,0,1,14,18),
|
||||
(15,1,0,1,0,0,0), (15,1,0,0,0,0,0), (18,0,1,1,0,0,0), (18,0,0,1,0,0,0),
|
||||
(19,0,0,1,0,0,0), (17,1,0,0,0,0,0), (19,0,0,0,0,0,0), (18,0,1,0,0,0,0),
|
||||
(18,1,0,1,0,0,0), (19,2,0,0,1,-1,-1), (19,1,0,0,0,0,0), (18,0,0,0,0,0,0),
|
||||
(19,1,0,1,0,0,0), (18,1,0,0,0,0,0),
|
||||
)
|
||||
|
||||
|
||||
# --- ijk coordinates ---
|
||||
|
||||
def _lround(x: float) -> int:
|
||||
"""C lroundl: halves round away from zero."""
|
||||
return int(math.floor(x + 0.5)) if x >= 0 else -int(math.floor(-x + 0.5))
|
||||
|
||||
|
||||
def _normalize(i: int, j: int, k: int) -> tuple[int, int, int]:
|
||||
if i < 0:
|
||||
j -= i
|
||||
k -= i
|
||||
i = 0
|
||||
if j < 0:
|
||||
i -= j
|
||||
k -= j
|
||||
j = 0
|
||||
if k < 0:
|
||||
i -= k
|
||||
j -= k
|
||||
k = 0
|
||||
m = min(i, j, k)
|
||||
if m > 0:
|
||||
i -= m
|
||||
j -= m
|
||||
k -= m
|
||||
return i, j, k
|
||||
|
||||
|
||||
def _add(a: tuple[int, int, int], b: tuple[int, int, int]) -> tuple[int, int, int]:
|
||||
return _normalize(a[0] + b[0], a[1] + b[1], a[2] + b[2])
|
||||
|
||||
|
||||
def _scaled_sum(c: tuple[int, int, int], iv, jv, kv) -> tuple[int, int, int]:
|
||||
i, j, k = c
|
||||
return _normalize(iv[0] * i + jv[0] * j + kv[0] * k,
|
||||
iv[1] * i + jv[1] * j + kv[1] * k,
|
||||
iv[2] * i + jv[2] * j + kv[2] * k)
|
||||
|
||||
|
||||
def _up_ap7(c):
|
||||
i, j = c[0] - c[2], c[1] - c[2]
|
||||
return _normalize(_lround((3 * i - j) / 7.0), _lround((i + 2 * j) / 7.0), 0)
|
||||
|
||||
|
||||
def _up_ap7r(c):
|
||||
i, j = c[0] - c[2], c[1] - c[2]
|
||||
return _normalize(_lround((2 * i + j) / 7.0), _lround((3 * j - i) / 7.0), 0)
|
||||
|
||||
|
||||
def _down_ap7(c):
|
||||
return _scaled_sum(c, (3, 0, 1), (1, 3, 0), (0, 1, 3))
|
||||
|
||||
|
||||
def _down_ap7r(c):
|
||||
return _scaled_sum(c, (3, 1, 0), (0, 3, 1), (1, 0, 3))
|
||||
|
||||
|
||||
def _rotate60ccw(c):
|
||||
return _scaled_sum(c, (1, 1, 0), (0, 1, 1), (1, 0, 1))
|
||||
|
||||
|
||||
def _rotate60cw(c):
|
||||
return _scaled_sum(c, (1, 0, 1), (1, 1, 0), (0, 1, 1))
|
||||
|
||||
|
||||
def _unit_to_digit(c) -> int:
|
||||
c = _normalize(*c)
|
||||
for d, u in enumerate(_UNIT_VECS):
|
||||
if c == u:
|
||||
return d
|
||||
return 7
|
||||
|
||||
|
||||
def _hex2d_to_ijk(x: float, y: float) -> tuple[int, int, int]:
|
||||
a1, a2 = abs(x), abs(y)
|
||||
x2 = a2 / _SQRT3_2
|
||||
x1 = a1 + x2 / 2.0
|
||||
m1, m2 = int(x1), int(x2)
|
||||
r1, r2 = x1 - m1, x2 - m2
|
||||
|
||||
if r1 < 0.5:
|
||||
if r1 < 1.0 / 3.0:
|
||||
i = m1
|
||||
j = m2 if r2 < (1.0 + r1) / 2.0 else m2 + 1
|
||||
else:
|
||||
j = m2 if r2 < (1.0 - r1) else m2 + 1
|
||||
i = m1 + 1 if (1.0 - r1) <= r2 < (2.0 * r1) else m1
|
||||
else:
|
||||
if r1 < 2.0 / 3.0:
|
||||
j = m2 if r2 < (1.0 - r1) else m2 + 1
|
||||
i = m1 if (2.0 * r1 - 1.0) < r2 < (1.0 - r1) else m1 + 1
|
||||
else:
|
||||
i = m1 + 1
|
||||
j = m2 if r2 < (r1 / 2.0) else m2 + 1
|
||||
|
||||
# fold across the axes if necessary
|
||||
if x < 0.0:
|
||||
if j % 2 == 0:
|
||||
i = i - 2 * (i - j // 2)
|
||||
else:
|
||||
i = i - (2 * (i - (j + 1) // 2) + 1)
|
||||
if y < 0.0:
|
||||
i = i - (2 * j + 1) // 2
|
||||
j = -j
|
||||
return _normalize(i, j, 0)
|
||||
|
||||
|
||||
def _ijk_to_hex2d(c) -> tuple[float, float]:
|
||||
i, j = c[0] - c[2], c[1] - c[2]
|
||||
return i - 0.5 * j, j * _SQRT3_2
|
||||
|
||||
|
||||
# --- sphere <-> face ---
|
||||
|
||||
def _pos_angle(rads: float) -> float:
|
||||
tmp = rads + _2PI if rads < 0.0 else rads
|
||||
if rads >= _2PI:
|
||||
tmp -= _2PI
|
||||
return tmp
|
||||
|
||||
|
||||
def _constrain_lng(lng: float) -> float:
|
||||
while lng > math.pi:
|
||||
lng -= _2PI
|
||||
while lng < -math.pi:
|
||||
lng += _2PI
|
||||
return lng
|
||||
|
||||
|
||||
def _azimuth(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
||||
return math.atan2(math.cos(lat2) * math.sin(lng2 - lng1),
|
||||
math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(lng2 - lng1))
|
||||
|
||||
|
||||
def _az_distance(lat1: float, lng1: float, az: float, distance: float) -> tuple[float, float]:
|
||||
if distance < _EPSILON:
|
||||
return lat1, lng1
|
||||
az = _pos_angle(az)
|
||||
if az < _EPSILON or abs(az - math.pi) < _EPSILON:
|
||||
lat2 = lat1 + distance if az < _EPSILON else lat1 - distance
|
||||
if abs(lat2 - math.pi / 2) < _EPSILON:
|
||||
return math.pi / 2, 0.0
|
||||
if abs(lat2 + math.pi / 2) < _EPSILON:
|
||||
return -math.pi / 2, 0.0
|
||||
return lat2, _constrain_lng(lng1)
|
||||
|
||||
sinlat = math.sin(lat1) * math.cos(distance) + math.cos(lat1) * math.sin(distance) * math.cos(az)
|
||||
sinlat = max(-1.0, min(1.0, sinlat))
|
||||
lat2 = math.asin(sinlat)
|
||||
if abs(lat2 - math.pi / 2) < _EPSILON:
|
||||
return math.pi / 2, 0.0
|
||||
if abs(lat2 + math.pi / 2) < _EPSILON:
|
||||
return -math.pi / 2, 0.0
|
||||
sinlng = math.sin(az) * math.sin(distance) / math.cos(lat2)
|
||||
coslng = (math.cos(distance) - math.sin(lat1) * math.sin(lat2)) / math.cos(lat1) / math.cos(lat2)
|
||||
sinlng = max(-1.0, min(1.0, sinlng))
|
||||
coslng = max(-1.0, min(1.0, coslng))
|
||||
return lat2, _constrain_lng(lng1 + math.atan2(sinlng, coslng))
|
||||
|
||||
|
||||
def _closest_face(lat: float, lng: float) -> tuple[int, float]:
|
||||
r = math.cos(lat)
|
||||
z, x, y = math.sin(lat), math.cos(lng) * r, math.sin(lng) * r
|
||||
face, sqd = 0, 5.0
|
||||
for f, (fx, fy, fz) in enumerate(FACE_CENTER_POINT):
|
||||
d = (fx - x) ** 2 + (fy - y) ** 2 + (fz - z) ** 2
|
||||
if d < sqd:
|
||||
face, sqd = f, d
|
||||
return face, sqd
|
||||
|
||||
|
||||
def _geo_to_hex2d(lat: float, lng: float, res: int) -> tuple[int, float, float]:
|
||||
face, sqd = _closest_face(lat, lng)
|
||||
r = math.acos(1 - sqd / 2)
|
||||
if r < _EPSILON:
|
||||
return face, 0.0, 0.0
|
||||
|
||||
flat, flng = FACE_CENTER_GEO[face]
|
||||
theta = _pos_angle(FACE_AXIS_AZ_CII[face] - _pos_angle(_azimuth(flat, flng, lat, lng)))
|
||||
if res % 2:
|
||||
theta = _pos_angle(theta - _AP7_ROT_RADS)
|
||||
|
||||
r = math.tan(r) / _RES0_U_GNOMONIC
|
||||
for _ in range(res):
|
||||
r *= _SQRT7
|
||||
return face, r * math.cos(theta), r * math.sin(theta)
|
||||
|
||||
|
||||
def _hex2d_to_geo(x: float, y: float, face: int, res: int) -> tuple[float, float]:
|
||||
r = math.hypot(x, y)
|
||||
if r < _EPSILON:
|
||||
return FACE_CENTER_GEO[face]
|
||||
theta = math.atan2(y, x)
|
||||
for _ in range(res):
|
||||
r /= _SQRT7
|
||||
r = math.atan(r * _RES0_U_GNOMONIC)
|
||||
if res % 2:
|
||||
theta = _pos_angle(theta + _AP7_ROT_RADS)
|
||||
theta = _pos_angle(FACE_AXIS_AZ_CII[face] - theta)
|
||||
flat, flng = FACE_CENTER_GEO[face]
|
||||
return _az_distance(flat, flng, theta, r)
|
||||
|
||||
|
||||
# --- H3 index bits ---
|
||||
|
||||
def get_resolution(h: int) -> int:
|
||||
return (h >> _RES_OFFSET) & 15
|
||||
|
||||
|
||||
def get_base_cell(h: int) -> int:
|
||||
return (h >> _BC_OFFSET) & 127
|
||||
|
||||
|
||||
def _digit(h: int, r: int) -> int:
|
||||
return (h >> ((MAX_RES - r) * 3)) & 7
|
||||
|
||||
|
||||
def _set_digit(h: int, r: int, d: int) -> int:
|
||||
off = (MAX_RES - r) * 3
|
||||
return (h & ~(7 << off)) | (d << off)
|
||||
|
||||
|
||||
def _leading_digit(h: int) -> int:
|
||||
for r in range(1, get_resolution(h) + 1):
|
||||
d = _digit(h, r)
|
||||
if d:
|
||||
return d
|
||||
return 0
|
||||
|
||||
|
||||
def _rotate_index60ccw(h: int) -> int:
|
||||
for r in range(1, get_resolution(h) + 1):
|
||||
h = _set_digit(h, r, _ROT_CCW[_digit(h, r)])
|
||||
return h
|
||||
|
||||
|
||||
def _rotate_index60cw(h: int) -> int:
|
||||
for r in range(1, get_resolution(h) + 1):
|
||||
h = _set_digit(h, r, _ROT_CW[_digit(h, r)])
|
||||
return h
|
||||
|
||||
|
||||
def _rotate_pent60ccw(h: int) -> int:
|
||||
found = False
|
||||
for r in range(1, get_resolution(h) + 1):
|
||||
h = _set_digit(h, r, _ROT_CCW[_digit(h, r)])
|
||||
if not found and _digit(h, r) != 0:
|
||||
found = True
|
||||
if _leading_digit(h) == _K:
|
||||
h = _rotate_index60ccw(h)
|
||||
return h
|
||||
|
||||
|
||||
def _is_pentagon_base(bc: int) -> bool:
|
||||
return 0 <= bc < NUM_BASE_CELLS and bool(BASE_CELL_DATA[bc][4])
|
||||
|
||||
|
||||
def _face_ijk_base(face: int, c) -> tuple[int, int]:
|
||||
return FACE_IJK_BASE_CELLS[face * 27 + c[0] * 9 + c[1] * 3 + c[2]]
|
||||
|
||||
|
||||
def _face_ijk_to_h3(face: int, c, res: int) -> int:
|
||||
h = _H3_INIT | (_CELL_MODE << _MODE_OFFSET) | (res << _RES_OFFSET)
|
||||
for r in range(res - 1, -1, -1):
|
||||
last = c
|
||||
if (r + 1) % 2:
|
||||
c = _up_ap7(c)
|
||||
center = _down_ap7(c)
|
||||
else:
|
||||
c = _up_ap7r(c)
|
||||
center = _down_ap7r(c)
|
||||
diff = _normalize(last[0] - center[0], last[1] - center[1], last[2] - center[2])
|
||||
h = _set_digit(h, r + 1, _unit_to_digit(diff))
|
||||
|
||||
if max(c) > 2:
|
||||
raise ValueError("coordinates off the base cell's face")
|
||||
bc, rots = _face_ijk_base(face, c)
|
||||
h = (h & ~(127 << _BC_OFFSET)) | (bc << _BC_OFFSET)
|
||||
|
||||
if _is_pentagon_base(bc):
|
||||
if _leading_digit(h) == _K:
|
||||
data = BASE_CELL_DATA[bc]
|
||||
h = _rotate_index60cw(h) if face in (data[5], data[6]) else _rotate_index60ccw(h)
|
||||
for _ in range(rots):
|
||||
h = _rotate_pent60ccw(h)
|
||||
else:
|
||||
for _ in range(rots):
|
||||
h = _rotate_index60ccw(h)
|
||||
return h
|
||||
|
||||
|
||||
def _adjust_overage_class2(face: int, c, res: int, pent_leading4: bool) -> tuple[int, tuple[int, int, int], bool]:
|
||||
"""Move coordinates that fell off the face onto the neighboring face. Returns (face, ijk, moved)."""
|
||||
max_dim = _max_dim(res)
|
||||
if sum(c) <= max_dim:
|
||||
return face, c, False
|
||||
|
||||
if c[2] > 0:
|
||||
if c[1] > 0:
|
||||
orient = FACE_NEIGHBORS[face][_JK_Q]
|
||||
else:
|
||||
orient = FACE_NEIGHBORS[face][_KI_Q]
|
||||
if pent_leading4:
|
||||
# rotate around the pentagon's center for its missing sequence
|
||||
tmp = _rotate60cw((c[0] - max_dim, c[1], c[2]))
|
||||
c = _normalize(tmp[0] + max_dim, tmp[1], tmp[2])
|
||||
else:
|
||||
orient = FACE_NEIGHBORS[face][_IJ_Q]
|
||||
|
||||
new_face, (ti, tj, tk), ccw = orient
|
||||
for _ in range(ccw):
|
||||
c = _rotate60ccw(c)
|
||||
s = _unit_scale(res)
|
||||
c = _normalize(c[0] + ti * s, c[1] + tj * s, c[2] + tk * s)
|
||||
return new_face, c, True
|
||||
|
||||
|
||||
def _h3_to_face_ijk(h: int) -> tuple[int, tuple[int, int, int]]:
|
||||
bc = get_base_cell(h)
|
||||
if not 0 <= bc < NUM_BASE_CELLS:
|
||||
raise ValueError("invalid base cell")
|
||||
if _is_pentagon_base(bc) and _leading_digit(h) == _IK:
|
||||
h = _rotate_index60cw(h)
|
||||
|
||||
data = BASE_CELL_DATA[bc]
|
||||
face, c = data[0], (data[1], data[2], data[3])
|
||||
res = get_resolution(h)
|
||||
possible_overage = _is_pentagon_base(bc) or not (res == 0 or c == (0, 0, 0))
|
||||
for r in range(1, res + 1):
|
||||
c = _down_ap7(c) if r % 2 else _down_ap7r(c)
|
||||
d = _digit(h, r)
|
||||
if 0 < d < 7:
|
||||
c = _add(c, _UNIT_VECS[d])
|
||||
if not possible_overage:
|
||||
return face, c
|
||||
|
||||
orig = c
|
||||
adj_res = res
|
||||
if adj_res % 2:
|
||||
c = _down_ap7r(c)
|
||||
adj_res += 1
|
||||
pent_leading4 = _is_pentagon_base(bc) and _leading_digit(h) == _I
|
||||
face, c, moved = _adjust_overage_class2(face, c, adj_res, pent_leading4)
|
||||
if moved:
|
||||
if _is_pentagon_base(bc):
|
||||
while moved:
|
||||
face, c, moved = _adjust_overage_class2(face, c, adj_res, False)
|
||||
if adj_res != res:
|
||||
c = _up_ap7r(c)
|
||||
elif adj_res != res:
|
||||
c = orig
|
||||
return face, c
|
||||
|
||||
|
||||
# --- public API ---
|
||||
|
||||
def latlng_to_cell(lat: float, lng: float, res: int) -> int:
|
||||
"""Cell containing a point given in degrees, like h3.latlng_to_cell."""
|
||||
if not 0 <= res <= MAX_RES:
|
||||
raise ValueError("resolution out of range")
|
||||
if not (math.isfinite(lat) and math.isfinite(lng)):
|
||||
raise ValueError("coordinates must be finite")
|
||||
face, x, y = _geo_to_hex2d(math.radians(lat), math.radians(lng), res)
|
||||
return _face_ijk_to_h3(face, _hex2d_to_ijk(x, y), res)
|
||||
|
||||
|
||||
def cell_to_latlng(h: int) -> tuple[float, float]:
|
||||
"""Center of a cell in degrees."""
|
||||
face, c = _h3_to_face_ijk(h)
|
||||
lat, lng = _hex2d_to_geo(*_ijk_to_hex2d(c), face, get_resolution(h))
|
||||
return math.degrees(lat), math.degrees(lng)
|
||||
|
||||
|
||||
def cell_to_parent(h: int, res: int) -> int:
|
||||
child_res = get_resolution(h)
|
||||
if not 0 <= res <= child_res:
|
||||
raise ValueError("parent resolution must be between 0 and the cell's resolution")
|
||||
h = (h & ~(15 << _RES_OFFSET)) | (res << _RES_OFFSET)
|
||||
for r in range(res + 1, child_res + 1):
|
||||
h = _set_digit(h, r, 7)
|
||||
return h
|
||||
|
||||
|
||||
def grid_disk1(h: int) -> set[int]:
|
||||
"""The cell and the cells touching it (h3.grid_disk(h, 1)).
|
||||
|
||||
Steps one cell along each of the six ijk axes on the cell's face and maps
|
||||
that center back to a cell, so it also works across face edges.
|
||||
"""
|
||||
face, c = _h3_to_face_ijk(h)
|
||||
res = get_resolution(h)
|
||||
out = {h}
|
||||
for u in _UNIT_VECS[1:]:
|
||||
x, y = _ijk_to_hex2d(_add(c, u))
|
||||
lat, lng = _hex2d_to_geo(x, y, face, res)
|
||||
out.add(latlng_to_cell(math.degrees(lat), math.degrees(lng), res))
|
||||
return out
|
||||
|
||||
|
||||
def cell_to_str(h: int) -> str:
|
||||
return format(h, "x")
|
||||
|
||||
|
||||
def str_to_cell(s: str) -> int:
|
||||
return int(s, 16)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Dry run of the community uploads: writes exactly what the car would send, and
|
||||
a map page that shows what would be shared and what stays on the car, into
|
||||
<achievements root>/preview/. Nothing is sent anywhere.
|
||||
|
||||
preview/daily-YYYY-MM-DD.json daily report per driven day (last 14 days), at level 2
|
||||
preview/tiles.json the next weekly tile batch (level 3)
|
||||
preview/delete.json tiles the car would ask the server to delete, if any
|
||||
preview/map.html open in a browser (copy it off the car first)
|
||||
|
||||
map.html shows your frequent stops, so it is for you only: don't share it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.sunnypilot.achievements.community import (LEVEL_DAILY, STOP_HOLD_DAYS, CommunityLog, Settings,
|
||||
public_block)
|
||||
from openpilot.sunnypilot.achievements.engine import State, achievements_root
|
||||
|
||||
PREVIEW_DAYS = 14
|
||||
|
||||
|
||||
def preview_dir() -> Path:
|
||||
return achievements_root() / "preview"
|
||||
|
||||
|
||||
def _write(path: Path, data) -> None:
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(data if isinstance(data, str) else json.dumps(data, indent=2) + "\n")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def build_preview(log: CommunityLog, state: State, today: dt.date, car: str | None, settings: Settings) -> dict:
|
||||
"""Everything the preview shows, as plain data."""
|
||||
view = log.tile_view(today)
|
||||
profile = log.profile_update(today, car, settings)
|
||||
days = log.driven_days(since=today - dt.timedelta(days=PREVIEW_DAYS - 1))
|
||||
daily = [log.daily_report(d, state, LEVEL_DAILY, profile=profile if d == days[-1] else None,
|
||||
include_undated=d == days[-1]) for d in days]
|
||||
region_days = log.region_days(today)
|
||||
return {
|
||||
"today": today.isoformat(),
|
||||
"level": settings.level,
|
||||
"daily": daily,
|
||||
"tiles": log.tile_batch(today),
|
||||
"delete": log.tile_deletes(today),
|
||||
"region": {"home": log.home_region(today), "shared": log.shared_region(today, settings),
|
||||
"choice": settings.region, "days": dict(region_days.most_common())},
|
||||
"map": {
|
||||
"shareable": sorted(view.shareable),
|
||||
"blocks": sorted({public_block(t) for t in view.shareable}),
|
||||
"held": view.held,
|
||||
"ring": sorted(view.ring),
|
||||
"frequent": sorted(view.frequent),
|
||||
"hidden": sorted(view.hidden & view.driven),
|
||||
"driven": len(view.driven),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_preview(log: CommunityLog, state: State, today: dt.date, car: str | None, settings: Settings,
|
||||
out: Path | None = None) -> Path:
|
||||
out = out or preview_dir()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
data = build_preview(log, state, today, car, settings)
|
||||
|
||||
for f in out.glob("daily-*.json"):
|
||||
f.unlink()
|
||||
for report in data["daily"]:
|
||||
_write(out / f"daily-{report['day']}.json", report)
|
||||
for name in ("tiles", "delete"):
|
||||
path = out / f"{name}.json"
|
||||
if data[name]:
|
||||
_write(path, data[name])
|
||||
elif path.exists():
|
||||
path.unlink()
|
||||
_write(out / "map.html", render_html(data))
|
||||
return out
|
||||
|
||||
|
||||
def render_html(data: dict) -> str:
|
||||
blob = json.dumps(data, separators=(",", ":")).replace("</", "<\\/")
|
||||
return HTML.replace("__DATA__", blob).replace("__HOLD_DAYS__", str(STOP_HOLD_DAYS))
|
||||
|
||||
|
||||
HTML = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>What your car would share</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
|
||||
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
||||
<script src="https://unpkg.com/h3-js@4.1.0/dist/h3-js.umd.js"></script>
|
||||
<style>
|
||||
:root { --ink:#1f2328; --muted:#59636e; --line:#d1d9e0; --bg:#f6f8fa; --send:#2563eb; --held:#9ca3af;
|
||||
--never:#dc2626; --hidden:#7c3aed; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font:15px/1.5 Inter, system-ui, sans-serif; color:var(--ink); background:#fff; }
|
||||
header { padding:20px 24px 8px; }
|
||||
h1 { font-size:22px; margin:0 0 4px; }
|
||||
.warn { background:#fff8c5; border:1px solid #d4a72c66; border-radius:8px; padding:8px 12px; margin:8px 0 0; color:#3b2300; }
|
||||
.stats { display:flex; flex-wrap:wrap; gap:8px; padding:8px 24px; }
|
||||
.stat { border:1px solid var(--line); border-radius:8px; padding:8px 12px; min-width:150px; }
|
||||
.stat b { display:block; font-size:20px; }
|
||||
.dot { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:6px; vertical-align:middle; }
|
||||
.bar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:8px 24px; }
|
||||
button { font:inherit; border:1px solid var(--line); background:#fff; border-radius:6px; padding:6px 12px; cursor:pointer; }
|
||||
button.on { background:var(--ink); color:#fff; border-color:var(--ink); }
|
||||
#map { height:62vh; min-height:360px; margin:0 24px; border:1px solid var(--line); border-radius:8px; }
|
||||
main { padding:8px 24px 40px; display:grid; align-items:start; grid-template-columns:repeat(auto-fit, minmax(320px, 1fr)); gap:16px; }
|
||||
section { border:1px solid var(--line); border-radius:8px; padding:12px 16px; }
|
||||
h2 { font-size:16px; margin:0 0 6px; }
|
||||
p { margin:4px 0; color:var(--muted); }
|
||||
pre { background:var(--bg); border-radius:6px; padding:10px; overflow:auto; font-size:12px; max-height:340px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>What your car would share</h1>
|
||||
<div id="sub"></div>
|
||||
<div class="warn">Dry run: nothing has been sent. This page marks your frequent stops, so keep it to yourself.</div>
|
||||
</header>
|
||||
<div class="stats" id="stats"></div>
|
||||
<div class="bar">
|
||||
<button id="mine" class="on">Your map (6 km tiles)</button>
|
||||
<button id="others">What others see (45 km blocks)</button>
|
||||
<label><input type="checkbox" id="kept" checked> Show what stays on the car</label>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
<main>
|
||||
<section><h2>Home region</h2><div id="region"></div></section>
|
||||
<section><h2>Latest daily report</h2>
|
||||
<p>Level 1 sends <code>unlocks</code> and <code>totals</code>. Level 2 adds <code>daily</code> and <code>profile</code>.</p>
|
||||
<pre id="daily"></pre></section>
|
||||
<section><h2>Next weekly tile batch (level 3)</h2>
|
||||
<p>Sorted tile ids, no dates, sent separately from the daily report.</p><pre id="tiles"></pre></section>
|
||||
<section><h2>Delete requests</h2>
|
||||
<p>Tiles already sent that became private later (moved house, new job, hidden).</p><pre id="delete"></pre></section>
|
||||
</main>
|
||||
<script>
|
||||
const D = __DATA__;
|
||||
const M = D.map;
|
||||
const held = Object.keys(M.held);
|
||||
const never = M.ring;
|
||||
document.getElementById('sub').textContent =
|
||||
`As of ${D.today}. Sharing level on this car: ${D.level} (0 = off). The preview always shows level 3.`;
|
||||
const stat = (color, n, label) =>
|
||||
`<div class="stat"><b>${n}</b><span class="dot" style="background:${color}"></span>${label}</div>`;
|
||||
document.getElementById('stats').innerHTML =
|
||||
stat('#6e7781', M.driven, 'tiles driven') +
|
||||
stat('var(--send)', M.shareable.length, 'would be shared') +
|
||||
stat('var(--held)', held.length, 'drive start/end tiles waiting __HOLD_DAYS__ days') +
|
||||
stat('var(--never)', never.length, `never leave the car (${M.frequent.length} frequent stops + the tiles around them)`) +
|
||||
stat('var(--hidden)', M.hidden.length, 'hidden by you') +
|
||||
stat('var(--send)', M.blocks.length, '45 km blocks others would see');
|
||||
|
||||
const r = D.region;
|
||||
const days = Object.entries(r.days).map(([k, v]) => `${k}: ${v} day${v === 1 ? '' : 's'}`).join(', ') || 'no engaged days yet';
|
||||
document.getElementById('region').innerHTML =
|
||||
`<p>Detected: <b>${r.home || 'none yet'}</b> (${days}, last 90 days).</p>` +
|
||||
`<p>Would be shared as: <b>${r.shared || 'nothing'}</b> (setting: ${r.choice}).</p>`;
|
||||
const latest = D.daily.length ? D.daily[D.daily.length - 1] : null;
|
||||
document.getElementById('daily').textContent = latest ? JSON.stringify(latest, null, 2) : 'No driving yet.';
|
||||
const t = D.tiles;
|
||||
document.getElementById('tiles').textContent = t ?
|
||||
JSON.stringify({...t, tiles: t.tiles.slice(0, 12).concat(t.tiles.length > 12 ? [`... ${t.tiles.length - 12} more`] : [])}, null, 2) :
|
||||
'Nothing new to send.';
|
||||
document.getElementById('delete').textContent = D.delete ? JSON.stringify(D.delete, null, 2) : 'None.';
|
||||
|
||||
const fc = (cells, kind) => ({type: 'FeatureCollection', features: cells.map(c => {
|
||||
const ring = h3.cellToBoundary(c, true); ring.push(ring[0]);
|
||||
return {type: 'Feature', properties: {kind, cell: c, until: M.held[c] || ''}, geometry: {type: 'Polygon', coordinates: [ring]}};
|
||||
})});
|
||||
const all = [...M.shareable, ...held, ...never, ...M.hidden];
|
||||
const map = new maplibregl.Map({
|
||||
container: 'map',
|
||||
style: {version: 8, sources: {osm: {type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256, attribution: '© OpenStreetMap contributors'}},
|
||||
layers: [{id: 'osm', type: 'raster', source: 'osm', paint: {'raster-saturation': -0.8, 'raster-opacity': 0.8}}]},
|
||||
center: [-83, 40], zoom: 5,
|
||||
});
|
||||
map.on('load', () => {
|
||||
const add = (id, data, color, fill, dash) => {
|
||||
map.addSource(id, {type: 'geojson', data});
|
||||
map.addLayer({id: id + '-fill', type: 'fill', source: id, paint: {'fill-color': color, 'fill-opacity': fill}});
|
||||
map.addLayer({id: id + '-line', type: 'line', source: id,
|
||||
paint: {'line-color': color, 'line-width': 1.2, ...(dash ? {'line-dasharray': [2, 2]} : {})}});
|
||||
};
|
||||
add('blocks', fc(M.blocks, 'block'), '#2563eb', 0.35);
|
||||
add('mine', fc(M.shareable, 'shared'), '#2563eb', 0.35);
|
||||
add('held', fc(held, 'held'), '#9ca3af', 0.45, true);
|
||||
add('never', fc(never, 'never'), '#dc2626', 0.25, true);
|
||||
add('hidden', fc(M.hidden, 'hidden'), '#7c3aed', 0.3, true);
|
||||
const kept = ['held', 'never', 'hidden'];
|
||||
const show = (id, on) => ['-fill', '-line'].forEach(s => map.setLayoutProperty(id + s, 'visibility', on ? 'visible' : 'none'));
|
||||
const fit = cells => {
|
||||
if (!cells.length) return;
|
||||
const pts = cells.flatMap(c => h3.cellToBoundary(c));
|
||||
const lats = pts.map(p => p[0]), lngs = pts.map(p => p[1]);
|
||||
map.fitBounds([[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]], {padding: 40, duration: 0});
|
||||
};
|
||||
let mode = 'mine';
|
||||
const refresh = () => {
|
||||
show('mine', mode === 'mine'); show('blocks', mode === 'others');
|
||||
const k = document.getElementById('kept').checked && mode === 'mine';
|
||||
kept.forEach(id => show(id, k));
|
||||
document.getElementById('mine').classList.toggle('on', mode === 'mine');
|
||||
document.getElementById('others').classList.toggle('on', mode === 'others');
|
||||
};
|
||||
document.getElementById('mine').onclick = () => { mode = 'mine'; refresh(); fit(all); };
|
||||
document.getElementById('others').onclick = () => { mode = 'others'; refresh(); fit(M.blocks); };
|
||||
document.getElementById('kept').onchange = refresh;
|
||||
refresh();
|
||||
fit(all);
|
||||
const label = {shared: 'Would be shared', held: 'Waiting until', never: 'Never leaves the car', hidden: 'Hidden by you',
|
||||
block: 'What others see'};
|
||||
map.on('click', e => {
|
||||
const f = map.queryRenderedFeatures(e.point).find(x => x.properties.kind);
|
||||
if (!f) return;
|
||||
new maplibregl.Popup().setLngLat(e.lngLat)
|
||||
.setHTML(`${label[f.properties.kind]} ${f.properties.until}<br><code>${f.properties.cell}</code>`).addTo(map);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
Binary file not shown.
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Home region lookup: map tile -> region code (US-OH, CA-ON, or a country code
|
||||
like GB), from the table in regions.json.gz. Works offline on the car; no
|
||||
geocoding service is ever called. See tools/build_regions.py for how the table
|
||||
is made.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.sunnypilot.achievements import h3lite
|
||||
|
||||
TABLE_PATH = Path(__file__).parent / "regions.json.gz"
|
||||
|
||||
|
||||
class RegionTable:
|
||||
def __init__(self, path: Path = TABLE_PATH):
|
||||
with gzip.open(path, "rt") as f:
|
||||
data = json.load(f)
|
||||
self.tile_res: int = data["tile_res"]
|
||||
self.base_res: int = data["base_res"]
|
||||
self.codes: list[str] = sorted(set(data["regions"]) | set(data["exceptions"]))
|
||||
index = {c: i for i, c in enumerate(self.codes)}
|
||||
self._base = self._arrays(data["regions"], index)
|
||||
self._exceptions = self._arrays(data["exceptions"], index)
|
||||
|
||||
@staticmethod
|
||||
def _arrays(groups: dict[str, list[str]], index: dict[str, int]) -> tuple[np.ndarray, np.ndarray]:
|
||||
pairs = sorted((int(cell, 16), index[code]) for code, cells in groups.items() for cell in cells)
|
||||
keys = np.array([p[0] for p in pairs], dtype=np.int64)
|
||||
vals = np.array([p[1] for p in pairs], dtype=np.uint16)
|
||||
return keys, vals
|
||||
|
||||
@staticmethod
|
||||
def _find(arrays: tuple[np.ndarray, np.ndarray], cell: int) -> int | None:
|
||||
keys, vals = arrays
|
||||
i = int(np.searchsorted(keys, cell))
|
||||
if i < len(keys) and int(keys[i]) == cell:
|
||||
return int(vals[i])
|
||||
return None
|
||||
|
||||
def lookup_cell(self, cell: int) -> str | None:
|
||||
"""Region for a tile at tile_res (or finer). None at sea or off the map."""
|
||||
res = h3lite.get_resolution(cell)
|
||||
if res > self.tile_res:
|
||||
cell, res = h3lite.cell_to_parent(cell, self.tile_res), self.tile_res
|
||||
for table, top in ((self._exceptions, res), (self._base, min(res, self.base_res))):
|
||||
for r in range(top, -1, -1):
|
||||
hit = self._find(table, h3lite.cell_to_parent(cell, r))
|
||||
if hit is not None:
|
||||
return self.codes[hit]
|
||||
return None
|
||||
|
||||
def lookup(self, lat: float, lng: float) -> str | None:
|
||||
return self.lookup_cell(h3lite.latlng_to_cell(lat, lng, self.tile_res))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def table() -> RegionTable:
|
||||
return RegionTable()
|
||||
|
||||
|
||||
def country_of(region: str) -> str:
|
||||
return region.split("-", 1)[0]
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/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.
|
||||
|
||||
Runs achievements and the privacy filter over the drives already saved on the
|
||||
car, then writes the dry-run preview. It uses its own folder, so live progress
|
||||
is not touched, and it sends nothing.
|
||||
|
||||
On the car (over SSH):
|
||||
cd /data/openpilot && python -m openpilot.sunnypilot.achievements.replay
|
||||
Then on your computer:
|
||||
scp comma@<car ip>:/data/media/0/achievements/replay/preview/map.html .
|
||||
|
||||
Reads qlogs from /data/media/0/realdata by default (--rlog for full logs, which
|
||||
also count lane changes; much slower). Each route counts as one drive.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import datetime
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.sunnypilot.achievements.community import CommunityLog, Settings
|
||||
from openpilot.sunnypilot.achievements.engine import (MAX_GPS_ACCURACY_M, AchievementEngine, Sample, State,
|
||||
achievements_root)
|
||||
from openpilot.sunnypilot.achievements.preview import write_preview
|
||||
|
||||
SEGMENT = re.compile(r"^(?P<route>.+)--(?P<seg>\d+)$")
|
||||
GPS_MAX_AGE_S = 2.0
|
||||
MIN_VALID_UNIX = 1.6e9 # anything earlier is a clock that wasn't set yet
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return datetime.datetime.now(datetime.UTC).timestamp()
|
||||
|
||||
|
||||
def find_routes(log_root: Path, rlog: bool, days: int | None) -> list[list[Path]]:
|
||||
names = ("rlog.zst", "rlog.bz2", "rlog") if rlog else ("qlog.zst", "qlog.bz2", "qlog")
|
||||
cutoff = _now() - days * 86400 if days else 0
|
||||
routes: dict[str, list[tuple[int, float, Path]]] = collections.defaultdict(list)
|
||||
for d in log_root.iterdir():
|
||||
m = SEGMENT.match(d.name)
|
||||
if not m or not d.is_dir():
|
||||
continue
|
||||
f = next((d / n for n in names if (d / n).exists()), None)
|
||||
if f is None or f.stat().st_mtime < cutoff:
|
||||
continue
|
||||
routes[m["route"]].append((int(m["seg"]), f.stat().st_mtime, f))
|
||||
ordered = sorted(routes.values(), key=lambda segs: min(s[1] for s in segs))
|
||||
return [[f for _, _, f in sorted(segs)] for segs in ordered]
|
||||
|
||||
|
||||
class RouteFeeder:
|
||||
"""Turns one route's log messages into Samples, the way achievementsd does live."""
|
||||
|
||||
def __init__(self, engine: AchievementEngine, log: CommunityLog):
|
||||
self.engine, self.log = engine, log
|
||||
self.op_active = self.mads_active = False
|
||||
self.lane_change = self.dm_alert = 0
|
||||
self.gps: tuple[float, float, int] | None = None
|
||||
self.clock: tuple[int, float] | None = None # (logMonoTime, unix seconds)
|
||||
self.car: str | None = None
|
||||
self.samples = 0
|
||||
|
||||
def feed(self, ev) -> None:
|
||||
w = ev.which()
|
||||
if w == "carState":
|
||||
self._sample(ev.logMonoTime, ev.carState.vEgo)
|
||||
elif w == "selfdriveState":
|
||||
self.op_active = ev.selfdriveState.active
|
||||
elif w == "selfdriveStateSP":
|
||||
self.mads_active = ev.selfdriveStateSP.mads.active
|
||||
elif w == "modelV2":
|
||||
self.lane_change = ev.modelV2.meta.laneChangeState.raw
|
||||
elif w == "driverMonitoringState":
|
||||
self.dm_alert = ev.driverMonitoringState.alertLevel.raw
|
||||
elif w in ("gpsLocationExternal", "gpsLocation"):
|
||||
g = getattr(ev, w)
|
||||
if g.hasFix and 0 < g.horizontalAccuracy < MAX_GPS_ACCURACY_M:
|
||||
self.gps = (g.latitude, g.longitude, ev.logMonoTime)
|
||||
if g.unixTimestampMillis / 1000.0 > MIN_VALID_UNIX:
|
||||
self.clock = (ev.logMonoTime, g.unixTimestampMillis / 1000.0)
|
||||
elif w == "initData" and self.clock is None:
|
||||
if ev.initData.wallTimeNanos / 1e9 > MIN_VALID_UNIX:
|
||||
self.clock = (ev.logMonoTime, ev.initData.wallTimeNanos / 1e9)
|
||||
elif w == "carParams" and not self.car:
|
||||
self.car = ev.carParams.carFingerprint or None
|
||||
|
||||
def _sample(self, mono: int, v_ego: float) -> None:
|
||||
fix = self.gps if self.gps and (mono - self.gps[2]) / 1e9 < GPS_MAX_AGE_S else None
|
||||
wall = self.clock[1] + (mono - self.clock[0]) / 1e9 if self.clock else None
|
||||
s = Sample(t=mono / 1e9, v_ego=v_ego, op_active=self.op_active, mads_active=self.mads_active,
|
||||
lane_change_state=self.lane_change, dm_alert_level=self.dm_alert,
|
||||
lat=fix[0] if fix else None, lon=fix[1] if fix else None, wall=wall)
|
||||
self.engine.update(s)
|
||||
self.log.update(s, self.engine.last_step)
|
||||
self.samples += 1
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
from openpilot.common.hardware.hw import Paths
|
||||
from openpilot.tools.lib.logreader import LogReader
|
||||
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[1], formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--logs", type=Path, default=Path(Paths.log_root()), help="folder with route segments")
|
||||
ap.add_argument("--out", type=Path, default=achievements_root() / "replay", help="where to write (wiped first)")
|
||||
ap.add_argument("--days", type=int, default=None, help="only routes from the last N days")
|
||||
ap.add_argument("--rlog", action="store_true", help="read full rlogs instead of qlogs")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
routes = find_routes(args.logs, args.rlog, args.days)
|
||||
if not routes:
|
||||
print(f"no {'rlogs' if args.rlog else 'qlogs'} found in {args.logs}")
|
||||
return 1
|
||||
if args.out.exists():
|
||||
shutil.rmtree(args.out)
|
||||
args.out.mkdir(parents=True)
|
||||
|
||||
engine = AchievementEngine(State())
|
||||
log = CommunityLog(args.out / "community.db")
|
||||
car = None
|
||||
n_segs = sum(len(r) for r in routes)
|
||||
done = 0
|
||||
start = time.monotonic()
|
||||
for segments in routes:
|
||||
feeder = RouteFeeder(engine, log)
|
||||
for path in segments:
|
||||
try:
|
||||
for ev in LogReader(str(path), sort_by_time=True):
|
||||
feeder.feed(ev)
|
||||
except Exception as e: # one bad segment shouldn't stop the rest
|
||||
print(f"skipping {path}: {e}", file=sys.stderr)
|
||||
done += 1
|
||||
print(f"\r{done}/{n_segs} segments", end="", file=sys.stderr, flush=True)
|
||||
engine.end_drive()
|
||||
log.end_drive(engine.utc_offset_h)
|
||||
car = feeder.car or car
|
||||
print(file=sys.stderr)
|
||||
|
||||
today = engine.local_date(_now())
|
||||
out = write_preview(log, engine.state, today, car, Settings.load(), args.out / "preview")
|
||||
view = log.tile_view(today)
|
||||
print(f"{len(routes)} drives, {n_segs} segments in {time.monotonic() - start:.0f} s")
|
||||
print(f"engaged: {engine.state.engaged_m / 1609.344:.0f} mi over {len(engine.state.days)} days")
|
||||
kept = len(view.ring & view.driven)
|
||||
print(f"tiles: {len(view.driven)} driven, {len(view.shareable)} would be shared, {len(view.held)} waiting, {kept} kept on the car")
|
||||
print(f"frequent stops (never sent, with the tiles around them): {len(view.frequent)}")
|
||||
print(f"home region: {log.home_region(today)}")
|
||||
print(f"preview: {out / 'map.html'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/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.
|
||||
|
||||
Made-up drives for the tests and for a demo preview without a car:
|
||||
|
||||
python -m openpilot.sunnypilot.achievements.simulate /tmp/demo
|
||||
|
||||
writes /tmp/demo/preview/map.html for two weeks of a commute from Hilliard to
|
||||
Fairborn, Ohio, plus two weekend trips.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.sunnypilot.achievements.community import CommunityLog
|
||||
from openpilot.sunnypilot.achievements.engine import AchievementEngine, Sample, State
|
||||
|
||||
HOME = (40.0334, -83.1588) # Hilliard
|
||||
WORK = (39.8209, -84.0194) # Fairborn
|
||||
SPRINGFIELD = (39.9242, -83.8088)
|
||||
CINCINNATI = (39.1031, -84.5120)
|
||||
DAYTON = (39.7589, -84.1916)
|
||||
CLEVELAND = (41.4993, -81.6944)
|
||||
MANSFIELD = (40.7584, -82.5154)
|
||||
COLUMBUS = (39.9612, -82.9988)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Drive:
|
||||
day: dt.date
|
||||
route: list[tuple[float, float]]
|
||||
start_utc_h: float = 13.0 # 8 am in Ohio
|
||||
engaged: bool = True
|
||||
|
||||
|
||||
def _km(a: tuple[float, float], b: tuple[float, float]) -> float:
|
||||
la1, lo1, la2, lo2 = map(math.radians, (*a, *b))
|
||||
h = math.sin((la2 - la1) / 2) ** 2 + math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2
|
||||
return 2 * 6371.0 * math.asin(math.sqrt(h))
|
||||
|
||||
|
||||
def drive_samples(drive: Drive, t0: float, speed_ms: float = 30.0):
|
||||
"""1 Hz samples along straight lines between the route's points."""
|
||||
wall = dt.datetime(drive.day.year, drive.day.month, drive.day.day, tzinfo=dt.UTC).timestamp() + drive.start_utc_h * 3600
|
||||
t = t0
|
||||
for a, b in zip(drive.route, drive.route[1:], strict=False):
|
||||
n = max(1, int(_km(a, b) * 1000 / speed_ms))
|
||||
for i in range(n):
|
||||
f = i / n
|
||||
lat, lon = a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f
|
||||
yield Sample(t=t, v_ego=speed_ms, op_active=drive.engaged, lat=lat, lon=lon, wall=wall)
|
||||
t += 1.0
|
||||
wall += 1.0
|
||||
end = drive.route[-1]
|
||||
yield Sample(t=t, v_ego=0.0, op_active=False, lat=end[0], lon=end[1], wall=wall)
|
||||
|
||||
|
||||
def run(drives: list[Drive], engine: AchievementEngine, log: CommunityLog, speed_ms: float = 30.0) -> None:
|
||||
t = 1000.0
|
||||
for d in drives:
|
||||
for s in drive_samples(d, t, speed_ms):
|
||||
engine.update(s)
|
||||
log.update(s, engine.last_step)
|
||||
t = s.t
|
||||
engine.end_drive()
|
||||
log.end_drive(engine.utc_offset_h)
|
||||
t += 3600.0
|
||||
|
||||
|
||||
def commute_weeks(first_monday: dt.date, weeks: int = 2) -> list[Drive]:
|
||||
drives = []
|
||||
for w in range(weeks):
|
||||
monday = first_monday + dt.timedelta(weeks=w)
|
||||
for i in range(5):
|
||||
day = monday + dt.timedelta(days=i)
|
||||
drives.append(Drive(day, [HOME, SPRINGFIELD, WORK], 12.5))
|
||||
drives.append(Drive(day, [WORK, SPRINGFIELD, HOME], 21.5))
|
||||
saturday = monday + dt.timedelta(days=5)
|
||||
trip = [HOME, DAYTON, CINCINNATI] if w % 2 == 0 else [HOME, COLUMBUS, MANSFIELD, CLEVELAND]
|
||||
drives.append(Drive(saturday, trip, 14.0))
|
||||
drives.append(Drive(saturday, list(reversed(trip)), 20.0))
|
||||
return drives
|
||||
|
||||
|
||||
def main(out: str) -> None:
|
||||
from openpilot.sunnypilot.achievements.community import Settings
|
||||
from openpilot.sunnypilot.achievements.preview import write_preview
|
||||
|
||||
root = Path(out)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
db = root / "community.db"
|
||||
if db.exists():
|
||||
os.remove(db)
|
||||
first_monday = dt.date(2026, 10, 26)
|
||||
engine = AchievementEngine(State())
|
||||
log = CommunityLog(db)
|
||||
run(commute_weeks(first_monday), engine, log)
|
||||
today = first_monday + dt.timedelta(days=15)
|
||||
path = write_preview(log, engine.state, today, "HYUNDAI_IONIQ_5", Settings(level=0), root / "preview")
|
||||
print(f"wrote {path / 'map.html'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1] if len(sys.argv) > 1 else "/tmp/achievements_demo")
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
The privacy filter, on made-up drives: two weeks of a Hilliard -> Fairborn
|
||||
commute plus two Saturday trips (see simulate.py).
|
||||
"""
|
||||
import datetime as dt
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.sunnypilot.achievements import h3lite
|
||||
from openpilot.sunnypilot.achievements.community import (LEVEL_ACHIEVEMENTS, LEVEL_DAILY, LEVEL_MAP, LEVEL_OFF,
|
||||
CommunityLog, Settings, public_block, ring_of, tile_of)
|
||||
from openpilot.sunnypilot.achievements.engine import AchievementEngine, State
|
||||
from openpilot.sunnypilot.achievements.preview import build_preview, write_preview
|
||||
from openpilot.sunnypilot.achievements.regions import table
|
||||
from openpilot.sunnypilot.achievements.simulate import (CINCINNATI, CLEVELAND, HOME, WORK, Drive, commute_weeks,
|
||||
run)
|
||||
|
||||
FIRST_MONDAY = dt.date(2026, 10, 26)
|
||||
TODAY = FIRST_MONDAY + dt.timedelta(days=15) # Tuesday after the second week
|
||||
HEX = re.compile(r"^86[0-9a-f]{13}$")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def world(tmp_path_factory):
|
||||
engine = AchievementEngine(State())
|
||||
log = CommunityLog(tmp_path_factory.mktemp("community") / "community.db")
|
||||
run(commute_weeks(FIRST_MONDAY), engine, log, speed_ms=60.0)
|
||||
return engine, log
|
||||
|
||||
|
||||
def test_home_and_work_never_leave(world):
|
||||
_, log = world
|
||||
view = log.tile_view(TODAY)
|
||||
home, work = tile_of(*HOME), tile_of(*WORK)
|
||||
assert view.frequent == {home, work}
|
||||
assert ring_of(home) | ring_of(work) <= view.ring
|
||||
assert len(ring_of(home)) == 7
|
||||
assert not view.shareable & view.ring
|
||||
batch = log.tile_batch(TODAY)
|
||||
assert not set(batch["tiles"]) & view.ring
|
||||
|
||||
|
||||
def test_trip_ends_wait_14_days(world):
|
||||
_, log = world
|
||||
cincinnati = tile_of(*CINCINNATI) # Saturday Oct 31
|
||||
cleveland = tile_of(*CLEVELAND) # Saturday Nov 7
|
||||
view = log.tile_view(TODAY)
|
||||
assert view.held == {cincinnati: "2026-11-14", cleveland: "2026-11-21"}
|
||||
assert cincinnati not in view.shareable
|
||||
|
||||
later = log.tile_view(dt.date(2026, 11, 14))
|
||||
assert cincinnati in later.shareable
|
||||
assert cleveland in later.held
|
||||
|
||||
|
||||
def test_tile_batch_has_no_dates(world):
|
||||
_, log = world
|
||||
batch = log.tile_batch(TODAY)
|
||||
assert set(batch) == {"v", "device", "tiles"}
|
||||
assert batch["tiles"] == sorted(batch["tiles"])
|
||||
assert all(HEX.match(t) for t in batch["tiles"])
|
||||
assert len(batch["tiles"]) == len(log.tile_view(TODAY).shareable)
|
||||
|
||||
|
||||
def test_nothing_finer_than_a_tile_is_stored(world):
|
||||
_, log = world
|
||||
db = sqlite3.connect(log.path)
|
||||
assert all(HEX.match(c) for (c,) in db.execute("SELECT cell FROM tiles UNION SELECT cell FROM stops"))
|
||||
days = {d for (d,) in db.execute("SELECT first_day FROM tiles UNION SELECT day FROM stops UNION SELECT day FROM days")}
|
||||
assert all(re.fullmatch(r"\d{4}-\d\d-\d\d", d) for d in days)
|
||||
|
||||
|
||||
def test_others_see_45km_blocks(world):
|
||||
_, log = world
|
||||
data = build_preview(log, world[0].state, TODAY, None, Settings())
|
||||
shareable = log.tile_view(TODAY).shareable
|
||||
assert set(data["map"]["blocks"]) == {public_block(t) for t in shareable}
|
||||
assert all(h3lite.get_resolution(h3lite.str_to_cell(b)) == 4 for b in data["map"]["blocks"])
|
||||
# the commute fills in the blocks around home and work, so no hole points at them
|
||||
home_block, work_block = public_block(tile_of(*HOME)), public_block(tile_of(*WORK))
|
||||
assert {home_block, work_block} <= set(data["map"]["blocks"])
|
||||
|
||||
|
||||
def test_daily_report_levels(world):
|
||||
engine, log = world
|
||||
day = "2026-11-06"
|
||||
assert log.daily_report(day, engine.state, LEVEL_OFF) is None
|
||||
|
||||
r1 = log.daily_report(day, engine.state, LEVEL_ACHIEVEMENTS)
|
||||
assert set(r1) == {"v", "device", "day", "unlocks", "totals"}
|
||||
assert r1["totals"]["engaged_mi"] % 10 == 0 and r1["totals"]["engaged_h"] % 10 == 0
|
||||
|
||||
r2 = log.daily_report(day, engine.state, LEVEL_DAILY, profile={"region": "US-OH"})
|
||||
assert set(r2) == {"v", "device", "day", "unlocks", "totals", "daily", "profile"}
|
||||
assert set(r2["daily"]) == {"engaged_mi", "engaged_min", "new_places"}
|
||||
assert all(isinstance(v, int) for v in r2["daily"].values())
|
||||
assert 80 < r2["daily"]["engaged_mi"] < 110 # two ~47 mile legs
|
||||
assert LEVEL_MAP > LEVEL_DAILY
|
||||
|
||||
|
||||
def test_unlocks_go_in_the_report_for_their_day(world):
|
||||
engine, log = world
|
||||
days = {}
|
||||
for uid in engine.state.unlocked:
|
||||
for day in log.driven_days():
|
||||
if any(u["id"] == uid for u in log.daily_report(day, engine.state, LEVEL_ACHIEVEMENTS)["unlocks"]):
|
||||
days.setdefault(uid, []).append(day)
|
||||
assert days and all(len(v) == 1 for v in days.values())
|
||||
|
||||
|
||||
def test_home_region(world):
|
||||
_, log = world
|
||||
assert log.home_region(TODAY) == "US-OH"
|
||||
assert log.shared_region(TODAY, Settings(region="country")) == "US"
|
||||
assert log.shared_region(TODAY, Settings(region="hidden")) is None
|
||||
assert log.shared_region(TODAY, Settings(region="US-KY")) == "US-KY"
|
||||
|
||||
|
||||
def test_profile_only_when_changed(tmp_path):
|
||||
engine, log = AchievementEngine(State()), CommunityLog(tmp_path / "c.db")
|
||||
run([Drive(dt.date(2026, 11, 2), [HOME, WORK])], engine, log, speed_ms=60.0)
|
||||
today = dt.date(2026, 11, 3)
|
||||
auto = Settings()
|
||||
first = log.profile_update(today, "HYUNDAI_IONIQ_5", auto)
|
||||
assert first == {"car": "HYUNDAI_IONIQ_5", "region": "US-OH"}
|
||||
log.mark_profile_sent(today, first, auto)
|
||||
assert log.profile_update(today, "HYUNDAI_IONIQ_5", auto) is None
|
||||
|
||||
# a road trip that moves the automatic home region waits 30 days after the last send
|
||||
for i in range(3):
|
||||
run([Drive(dt.date(2026, 11, 4 + i), [CINCINNATI, (38.25, -85.76)])], engine, log, speed_ms=60.0) # into Kentucky
|
||||
assert log.home_region(dt.date(2026, 11, 7)) != "US-OH"
|
||||
assert log.profile_update(dt.date(2026, 11, 7), "HYUNDAI_IONIQ_5", auto) is None
|
||||
assert "region" in log.profile_update(dt.date(2026, 12, 3), "HYUNDAI_IONIQ_5", auto)
|
||||
# a region the driver picks goes right away; hiding sends an empty region
|
||||
assert log.profile_update(today, "HYUNDAI_IONIQ_5", Settings(region="US-MI")) == {"region": "US-MI"}
|
||||
assert log.profile_update(today, "HYUNDAI_IONIQ_5", Settings(region="hidden")) == {"region": None}
|
||||
|
||||
|
||||
def test_late_catches_and_hidden_tiles_are_deleted(tmp_path):
|
||||
engine, log = AchievementEngine(State()), CommunityLog(tmp_path / "c.db")
|
||||
friend = (39.95, -82.85) # east Columbus
|
||||
run([Drive(dt.date(2026, 10, 1), [HOME, friend])], engine, log, speed_ms=60.0)
|
||||
today = dt.date(2026, 10, 20)
|
||||
view = log.tile_view(today)
|
||||
assert tile_of(*friend) in view.shareable # one visit, held 14 days, then shared
|
||||
log.mark_sent(view.to_send)
|
||||
assert log.tile_batch(today) is None
|
||||
|
||||
# three more days ending there: it's a regular stop now, so ask the server to delete it
|
||||
run([Drive(dt.date(2026, 10, 21 + i), [HOME, friend]) for i in range(3)], engine, log, speed_ms=60.0)
|
||||
deletes = log.tile_deletes(dt.date(2026, 10, 25))
|
||||
assert tile_of(*friend) in deletes["delete_tiles"]
|
||||
assert set(deletes["delete_tiles"]) <= ring_of(tile_of(*friend)) | ring_of(tile_of(*HOME))
|
||||
|
||||
middle = sorted(log.tile_view(dt.date(2026, 10, 25)).shareable)[0]
|
||||
log.hide_tile(middle)
|
||||
view = log.tile_view(dt.date(2026, 10, 25))
|
||||
assert middle not in view.shareable and middle in view.to_delete
|
||||
|
||||
|
||||
def test_no_gps_no_tiles(tmp_path):
|
||||
engine, log = AchievementEngine(State()), CommunityLog(tmp_path / "c.db")
|
||||
drive = Drive(dt.date(2026, 11, 2), [HOME, WORK])
|
||||
from openpilot.sunnypilot.achievements.simulate import drive_samples
|
||||
for s in drive_samples(drive, 0.0, 60.0):
|
||||
s.lat = s.lon = None
|
||||
engine.update(s)
|
||||
log.update(s, engine.last_step)
|
||||
log.end_drive()
|
||||
assert log.tile_view(dt.date(2026, 11, 3)).driven == set()
|
||||
m, secs, _ = log.day_stats("2026-11-02")
|
||||
assert m > 50_000 and secs > 0 # engaged miles still count
|
||||
|
||||
|
||||
def test_write_preview(world, tmp_path):
|
||||
engine, log = world
|
||||
out = write_preview(log, engine.state, TODAY, "HYUNDAI_IONIQ_5", Settings(), tmp_path / "preview")
|
||||
names = sorted(p.name for p in out.iterdir())
|
||||
assert "map.html" in names and "tiles.json" in names
|
||||
assert "daily-2026-11-07.json" in names
|
||||
html = (out / "map.html").read_text()
|
||||
assert "What your car would share" in html and tile_of(*HOME) in html
|
||||
|
||||
|
||||
def test_region_table():
|
||||
t = table()
|
||||
assert t.lookup(*HOME) == "US-OH"
|
||||
assert t.lookup(43.65, -79.38) == "CA-ON" # Toronto
|
||||
assert t.lookup(40.76, -73.98) == "US-NY" # Manhattan, next to New Jersey
|
||||
assert t.lookup(38.90, -77.04) == "US-DC"
|
||||
assert t.lookup(51.50, -0.12) == "GB" # countries without subdivisions in the table
|
||||
assert t.lookup(35.0, -40.0) is None # mid-Atlantic
|
||||
@@ -0,0 +1,219 @@
|
||||
import datetime as dt
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.sunnypilot.achievements.catalog import CATALOG_PATH, load_catalog, parse_catalog, statuses
|
||||
from openpilot.sunnypilot.achievements.engine import (AchievementEngine, LANE_CHANGE_FINISHING, LANE_CHANGE_STARTING, Sample, State,
|
||||
geohash, load_seen, load_state, save_seen, save_state, season)
|
||||
|
||||
T0 = dt.datetime(2026, 6, 1, 16, 0, tzinfo=dt.UTC).timestamp() # noon in New York
|
||||
|
||||
|
||||
def drive(engine, seconds, v=20.0, t0=T0, hz=10, **kw):
|
||||
"""Feed `seconds` of driving at `v` m/s; returns every unlock id along the way."""
|
||||
new = []
|
||||
n = int(seconds * hz)
|
||||
for i in range(n + 1):
|
||||
extra = {k: val for k, val in kw.items() if k != "op_active"}
|
||||
new += engine.update(Sample(t=t0 + i / hz, wall=t0 + i / hz, v_ego=v, op_active=kw.get("op_active", True), **extra))
|
||||
return new
|
||||
|
||||
|
||||
def test_catalog_loads_and_ids_are_unique():
|
||||
cat = load_catalog()
|
||||
ids = cat.all_unlock_ids()
|
||||
assert len(ids) == len(set(ids))
|
||||
assert "odometer.silver" in ids and "first_light" in ids
|
||||
engine_metrics = set(State().metrics())
|
||||
assert cat.metrics <= engine_metrics, f"catalog uses metrics the engine doesn't compute: {cat.metrics - engine_metrics}"
|
||||
|
||||
|
||||
def test_catalog_rejects_decreasing_tiers():
|
||||
data = json.loads(CATALOG_PATH.read_text())
|
||||
data["achievements"][1]["tiers"][1]["threshold"] = 1
|
||||
with pytest.raises(ValueError):
|
||||
parse_catalog(data)
|
||||
|
||||
|
||||
def test_engaged_distance_and_first_light():
|
||||
e = AchievementEngine()
|
||||
new = drive(e, 10, v=20.0)
|
||||
assert e.state.engaged_m == pytest.approx(200.0, rel=1e-6)
|
||||
assert e.state.engaged_s == pytest.approx(10.0, rel=1e-6)
|
||||
assert new == ["first_light"]
|
||||
|
||||
|
||||
def test_not_engaged_counts_nothing():
|
||||
e = AchievementEngine()
|
||||
drive(e, 10, op_active=False)
|
||||
assert e.state.engaged_m == 0.0
|
||||
assert e.state.unlocked == {}
|
||||
|
||||
|
||||
def test_gaps_and_bad_speeds_are_ignored():
|
||||
e = AchievementEngine()
|
||||
e.update(Sample(t=T0, v_ego=20.0, op_active=True))
|
||||
e.update(Sample(t=T0 + 30.0, v_ego=20.0, op_active=True)) # 30 s gap: process stalled
|
||||
e.update(Sample(t=T0 + 30.1, v_ego=500.0, op_active=True)) # implausible speed
|
||||
assert e.state.engaged_m == 0.0
|
||||
|
||||
|
||||
def test_odometer_tiers_unlock_in_order():
|
||||
e = AchievementEngine()
|
||||
e.state.engaged_m = 99_999.0
|
||||
new = drive(e, 1, v=10.0)
|
||||
assert "odometer.bronze" in new and "odometer.silver" not in new
|
||||
assert set(e.state.unlocked) >= {"first_light", "odometer.bronze"}
|
||||
|
||||
|
||||
def test_unlocks_only_once():
|
||||
e = AchievementEngine()
|
||||
assert drive(e, 5) == ["first_light"]
|
||||
assert drive(e, 5, t0=T0 + 100) == []
|
||||
|
||||
|
||||
def test_mads_only_distance():
|
||||
e = AchievementEngine()
|
||||
drive(e, 10, v=10.0, op_active=False, mads_active=True)
|
||||
drive(e, 10, v=10.0, t0=T0 + 60, op_active=True, mads_active=True)
|
||||
assert e.state.mads_m == pytest.approx(100.0, rel=1e-6)
|
||||
assert e.state.engaged_m == pytest.approx(200.0, rel=1e-6)
|
||||
|
||||
|
||||
def test_lane_change_counted_once_when_engaged():
|
||||
e = AchievementEngine()
|
||||
seq = [0, 1, 2, 2, 3, 3, 0, 0, 2, 3, 0]
|
||||
for i, lc in enumerate(seq):
|
||||
e.update(Sample(t=T0 + i * 0.1, v_ego=25.0, op_active=True, lane_change_state=lc))
|
||||
assert e.state.lane_changes == 2
|
||||
e.update(Sample(t=T0 + 5, v_ego=25.0, op_active=False, lane_change_state=LANE_CHANGE_STARTING))
|
||||
e.update(Sample(t=T0 + 5.1, v_ego=25.0, op_active=False, lane_change_state=LANE_CHANGE_FINISHING))
|
||||
assert e.state.lane_changes == 2
|
||||
|
||||
|
||||
def test_days_streak_and_best_day():
|
||||
e = AchievementEngine()
|
||||
for d in range(8):
|
||||
drive(e, 2, t0=T0 + d * 86400)
|
||||
assert len(e.state.days) == 8
|
||||
assert e.state.best_streak_days == 8
|
||||
assert "streak.bronze" in e.state.unlocked and "regular.bronze" in e.state.unlocked
|
||||
# a gap day resets the run but keeps the best
|
||||
drive(e, 2, t0=T0 + 10 * 86400)
|
||||
assert e.state.best_streak_days == 8
|
||||
|
||||
|
||||
def test_local_date_follows_gps_longitude():
|
||||
e = AchievementEngine()
|
||||
late = dt.datetime(2026, 6, 2, 2, 0, tzinfo=dt.UTC).timestamp() # 10 pm on June 1 in New York
|
||||
e.update(Sample(t=late, wall=late, v_ego=10.0, op_active=True, lat=40.7, lon=-74.0))
|
||||
e.update(Sample(t=late + 0.1, wall=late + 0.1, v_ego=10.0, op_active=True, lat=40.7, lon=-74.0))
|
||||
assert e.state.days == ["2026-06-01"]
|
||||
|
||||
|
||||
def test_invalid_clock_counts_distance_but_not_days():
|
||||
e = AchievementEngine()
|
||||
for i in range(11):
|
||||
e.update(Sample(t=i / 10, v_ego=10.0, op_active=True)) # wall=None: clock not set yet
|
||||
assert e.state.engaged_m == pytest.approx(10.0, rel=1e-6)
|
||||
assert e.state.days == [] and e.state.today_m == 0.0
|
||||
assert e.state.unlocked == {}
|
||||
|
||||
|
||||
def test_attentive_run_resets_on_red_alert():
|
||||
e = AchievementEngine()
|
||||
drive(e, 100)
|
||||
drive(e, 1, t0=T0 + 200, dm_alert_level=3)
|
||||
assert e.state.attentive_run_s < 1.0
|
||||
assert e.state.attentive_best_s == pytest.approx(100.0, rel=1e-3)
|
||||
|
||||
|
||||
def test_areas_are_hashed_and_counted():
|
||||
e = AchievementEngine()
|
||||
drive(e, 1, lat=40.70, lon=-74.00)
|
||||
drive(e, 1, t0=T0 + 10, lat=40.701, lon=-74.001) # same ~5 km cell
|
||||
drive(e, 1, t0=T0 + 20, lat=41.50, lon=-73.00) # different cell
|
||||
assert len(e.state.areas) == 2
|
||||
raw = json.dumps(e.state.__dict__)
|
||||
assert "40.7" not in raw and geohash(40.70, -74.00) not in raw
|
||||
|
||||
|
||||
def test_model_hopper_counts_models_over_50km():
|
||||
e = AchievementEngine()
|
||||
e.state.model_m = {"a": 50_000.0, "b": 50_000.0, "c": 10.0}
|
||||
drive(e, 1, model_ref="c", model_name="Model C")
|
||||
assert e.state.metrics()["models_50km"] == 2
|
||||
e.state.model_m["c"] = 50_000.0
|
||||
assert "model_hopper.bronze" in drive(e, 1, t0=T0 + 10, model_ref="c")
|
||||
assert e.state.model_names["c"] == "Model C"
|
||||
|
||||
|
||||
def test_dev_build_and_seasons():
|
||||
e = AchievementEngine()
|
||||
drive(e, 1, dev_build=True)
|
||||
assert e.state.dev_m > 0
|
||||
assert season(dt.date(2026, 1, 5)) == "winter"
|
||||
assert season(dt.date(2026, 1, 5), southern=True) == "summer"
|
||||
|
||||
|
||||
def test_sunnylink_flag_sticks():
|
||||
e = AchievementEngine()
|
||||
assert e.set_sunnylink_linked(True, T0) == ["linked_up"]
|
||||
e.set_sunnylink_linked(False, T0 + 1)
|
||||
assert e.state.sunnylink_linked
|
||||
|
||||
|
||||
def test_state_round_trip(tmp_path):
|
||||
e = AchievementEngine()
|
||||
drive(e, 10, lat=40.7, lon=-74.0, model_ref="x")
|
||||
path = tmp_path / "state.json"
|
||||
save_state(e.state, path)
|
||||
loaded = load_state(path)
|
||||
assert loaded == e.state
|
||||
|
||||
|
||||
def test_corrupt_state_is_moved_aside(tmp_path):
|
||||
path = tmp_path / "state.json"
|
||||
path.write_text("{not json")
|
||||
assert load_state(path).engaged_m == 0.0
|
||||
assert (tmp_path / "state.corrupt.json").exists()
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_unknown_fields_are_ignored(tmp_path):
|
||||
path = tmp_path / "state.json"
|
||||
path.write_text(json.dumps({"engaged_m": 5.0, "from_the_future": 1}))
|
||||
assert load_state(path).engaged_m == 5.0
|
||||
|
||||
|
||||
def test_seen_round_trip(tmp_path):
|
||||
path = tmp_path / "seen.json"
|
||||
assert load_seen(path) == set()
|
||||
save_seen({"a", "b"}, path)
|
||||
assert load_seen(path) == {"a", "b"}
|
||||
|
||||
|
||||
def test_status_summaries():
|
||||
cat = load_catalog()
|
||||
st = State(engaged_m=1_240_000.0)
|
||||
e = AchievementEngine(st)
|
||||
e.check_unlocks(T0)
|
||||
by_id = {s.achievement.id: s for s in statuses(cat, st.metrics(), st.unlocked)}
|
||||
assert by_id["odometer"].summary() == "silver · 1,240 / 10,000 km"
|
||||
assert by_id["odometer"].summary(is_metric=False) == "silver · 770 / 6,213 mi"
|
||||
assert by_id["first_light"].summary() == "done"
|
||||
assert by_id["regular"].summary() == "locked · 0 / 7 days"
|
||||
assert by_id["linked_up"].summary() == "locked"
|
||||
|
||||
|
||||
def test_last_step_for_the_community_log():
|
||||
e = AchievementEngine(State())
|
||||
wall = 1_762_000_000.0
|
||||
e.update(Sample(t=0.0, v_ego=20.0, op_active=True, wall=wall))
|
||||
e.update(Sample(t=0.5, v_ego=20.0, op_active=True, wall=wall + 0.5))
|
||||
assert e.last_step.dt_s == 0.5 and e.last_step.engaged_m == 10.0 and e.last_step.day is not None
|
||||
e.update(Sample(t=1.0, v_ego=20.0, op_active=False, wall=wall + 1.0))
|
||||
assert e.last_step.dt_s == 0.5 and e.last_step.engaged_m == 0.0
|
||||
e.update(Sample(t=9.0, v_ego=20.0, op_active=True, wall=None)) # a gap and no clock
|
||||
assert e.last_step.dt_s == 0.0 and e.last_step.day is None
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
h3lite must give the same cell ids as Uber's H3, because the site and the
|
||||
server use h3-js and h3-pg on the ids the car sends. The vectors below come
|
||||
from h3-py 4.1.2; with h3 installed, the last test also compares random points.
|
||||
"""
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.sunnypilot.achievements import h3lite
|
||||
|
||||
RESOLUTIONS = (0, 4, 6, 9, 15)
|
||||
CELLS = [
|
||||
(40.0334, -83.1588, ("802bfffffffffff", "842a90bffffffff", "862a90b4fffffff", "892a90b4917ffff", "8f2a90b4915d388")),
|
||||
(37.7749, -122.4194, ("8029fffffffffff", "8428309ffffffff", "86283082fffffff", "89283082803ffff", "8f283082800b390")),
|
||||
(51.5007, -0.1246, ("8019fffffffffff", "84194adffffffff", "86194ad17ffffff", "89194ad14c3ffff", "8f194ad14c254d3")),
|
||||
(-33.8688, 151.2093, ("80bffffffffffff", "84be0e3ffffffff", "86be0e35fffffff", "89be0e35cbbffff", "8fbe0e35cbad0a8")),
|
||||
(35.6762, 139.6503, ("802ffffffffffff", "842f5a3ffffffff", "862f5a367ffffff", "892f5a363bbffff", "8f2f5a363ba005a")),
|
||||
(64.8378, -147.7164, ("800dfffffffffff", "840d5a9ffffffff", "860d5a9afffffff", "890d5a9add3ffff", "8f0d5a9add25781")),
|
||||
(-54.8019, -68.303, ("80dffffffffffff", "84df451ffffffff", "86df45177ffffff", "89df4517557ffff", "8fdf45175542186")),
|
||||
(0.0, 0.0, ("8075fffffffffff", "84754a9ffffffff", "86754e64fffffff", "89754e64993ffff", "8f754e64992d6d8")),
|
||||
(89.9, 45.0, ("8001fffffffffff", "8403263ffffffff", "86032630fffffff", "89032630ac3ffff", "8f032630ac0311a")),
|
||||
(-89.9, -45.0, ("80f3fffffffffff", "84f2939ffffffff", "86f2938efffffff", "89f2938ec7bffff", "8ff2938ec78c80e")),
|
||||
(52.0, 179.999, ("8017fffffffffff", "841659bffffffff", "861659aefffffff", "891659acc9bffff", "8f1659acc9ad193")),
|
||||
(-16.5, -179.999, ("809bfffffffffff", "849b5ddffffffff", "869b5dc57ffffff", "899b5dc5597ffff", "8f9b5dc5596a500")),
|
||||
(-28.18676, -125.6943, ("80b1fffffffffff", "84b1713ffffffff", "86b171247ffffff", "89b1712423bffff", "8fb171242396a80")),
|
||||
(24.14952, -153.92294, ("805dfffffffffff", "845d35bffffffff", "865d35b0fffffff", "895d35b0b23ffff", "8f5d35b0b200c24")),
|
||||
(5.74112, -48.35199, ("805ffffffffffff", "845fa25ffffffff", "865fa248fffffff", "895fa248b23ffff", "8f5fa248b218936")),
|
||||
(-70.72017, 2.67686, ("80e7fffffffffff", "84e749dffffffff", "86e749c47ffffff", "89e749c44c3ffff", "8fe749c44c216ec")),
|
||||
(-74.00069, -23.88755, ("80effffffffffff", "84ee139ffffffff", "86ee13967ffffff", "89ee1396293ffff", "8fee139629208d2")),
|
||||
(-68.82313, -147.34332, ("80e3fffffffffff", "84e334dffffffff", "86e334c9fffffff", "89e334c983bffff", "8fe334c9839a3b1")),
|
||||
(-12.07693, 117.66676, ("8095fffffffffff", "849581dffffffff", "869581d97ffffff", "899581d9487ffff", "8f9581d9486a96e")),
|
||||
(-60.19169, -99.63397, ("80e9fffffffffff", "84e95c1ffffffff", "86e95c107ffffff", "89e95c1000bffff", "8fe95c1000b639c")),
|
||||
]
|
||||
PARENTS = [
|
||||
("8f2a90b4915d388", ("802bfffffffffff", "842a90bffffffff", "862a90b4fffffff", "892a90b4917ffff", "8f2a90b4915d388")),
|
||||
("8f283082800b390", ("8029fffffffffff", "8428309ffffffff", "86283082fffffff", "89283082803ffff", "8f283082800b390")),
|
||||
("8f194ad14c254d3", ("8019fffffffffff", "84194adffffffff", "86194ad17ffffff", "89194ad14c3ffff", "8f194ad14c254d3")),
|
||||
("8fbe0e35cbad0a8", ("80bffffffffffff", "84be0e3ffffffff", "86be0e35fffffff", "89be0e35cbbffff", "8fbe0e35cbad0a8")),
|
||||
("8f2f5a363ba005a", ("802ffffffffffff", "842f5a3ffffffff", "862f5a367ffffff", "892f5a363bbffff", "8f2f5a363ba005a")),
|
||||
("8f0d5a9add25781", ("800dfffffffffff", "840d5a9ffffffff", "860d5a9afffffff", "890d5a9add3ffff", "8f0d5a9add25781")),
|
||||
("8fdf45175542186", ("80dffffffffffff", "84df451ffffffff", "86df45177ffffff", "89df4517557ffff", "8fdf45175542186")),
|
||||
("8f754e64992d6d8", ("8075fffffffffff", "84754e7ffffffff", "86754e64fffffff", "89754e64993ffff", "8f754e64992d6d8")),
|
||||
("8f032630ac0311a", ("8003fffffffffff", "8403263ffffffff", "86032630fffffff", "89032630ac3ffff", "8f032630ac0311a")),
|
||||
("8ff2938ec78c80e", ("80f3fffffffffff", "84f2939ffffffff", "86f2938efffffff", "89f2938ec7bffff", "8ff2938ec78c80e")),
|
||||
("8f1659acc9ad193", ("8017fffffffffff", "841659bffffffff", "861659acfffffff", "891659acc9bffff", "8f1659acc9ad193")),
|
||||
("8f9b5dc5596a500", ("809bfffffffffff", "849b5ddffffffff", "869b5dc57ffffff", "899b5dc5597ffff", "8f9b5dc5596a500")),
|
||||
("8fb171242396a80", ("80b1fffffffffff", "84b1713ffffffff", "86b171247ffffff", "89b1712423bffff", "8fb171242396a80")),
|
||||
("8f5d35b0b200c24", ("805dfffffffffff", "845d35bffffffff", "865d35b0fffffff", "895d35b0b23ffff", "8f5d35b0b200c24")),
|
||||
("8f5fa248b218936", ("805ffffffffffff", "845fa25ffffffff", "865fa248fffffff", "895fa248b23ffff", "8f5fa248b218936")),
|
||||
("8fe749c44c216ec", ("80e7fffffffffff", "84e749dffffffff", "86e749c47ffffff", "89e749c44c3ffff", "8fe749c44c216ec")),
|
||||
("8fee139629208d2", ("80effffffffffff", "84ee139ffffffff", "86ee13967ffffff", "89ee1396293ffff", "8fee139629208d2")),
|
||||
("8fe334c9839a3b1", ("80e3fffffffffff", "84e334dffffffff", "86e334c9fffffff", "89e334c983bffff", "8fe334c9839a3b1")),
|
||||
("8f9581d9486a96e", ("8095fffffffffff", "849581dffffffff", "869581d97ffffff", "899581d9487ffff", "8f9581d9486a96e")),
|
||||
("8fe95c1000b639c", ("80e9fffffffffff", "84e95c1ffffffff", "86e95c107ffffff", "89e95c1000bffff", "8fe95c1000b639c")),
|
||||
("8f2a90b4915d388", ("802bfffffffffff", "842a90bffffffff", "862a90b4fffffff", "892a90b4917ffff", "8f2a90b4915d388")),
|
||||
]
|
||||
CENTERS = [
|
||||
("862a90b4fffffff", 40.05041861648768, -83.181315523919),
|
||||
("842a90bffffffff", 40.08105106496825, -83.41180170584032),
|
||||
("864c00007ffffff", 23.71792527122296, -67.13232636643565),
|
||||
("8f2a90b4915d388", 40.03339893697043, -83.1588004749386),
|
||||
("8001fffffffffff", 79.24239850975903, 38.02340700796989),
|
||||
]
|
||||
DISKS = {
|
||||
"862a90b4fffffff": ['862a90a67ffffff', '862a90b47ffffff', '862a90b4fffffff', '862a90b5fffffff', '862a90b6fffffff', '862a95697ffffff', '862a956b7ffffff'],
|
||||
# a pentagon: 5 neighbors
|
||||
"864c00007ffffff": [
|
||||
'864c00007ffffff',
|
||||
'864c00017ffffff',
|
||||
'864c0001fffffff',
|
||||
'864c00027ffffff',
|
||||
'864c0002fffffff',
|
||||
'864c00037ffffff',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lat,lng,expected", CELLS)
|
||||
def test_latlng_to_cell(lat, lng, expected):
|
||||
for res, want in zip(RESOLUTIONS, expected, strict=True):
|
||||
assert h3lite.cell_to_str(h3lite.latlng_to_cell(lat, lng, res)) == want
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cell,expected", PARENTS)
|
||||
def test_cell_to_parent(cell, expected):
|
||||
# a parent isn't always the cell that holds the same point: H3 children stick out a little
|
||||
for res, want in zip(RESOLUTIONS, expected, strict=True):
|
||||
assert h3lite.cell_to_str(h3lite.cell_to_parent(h3lite.str_to_cell(cell), res)) == want
|
||||
assert h3lite.get_resolution(h3lite.str_to_cell(want)) == res
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cell,lat,lng", CENTERS)
|
||||
def test_cell_to_latlng(cell, lat, lng):
|
||||
got = h3lite.cell_to_latlng(h3lite.str_to_cell(cell))
|
||||
assert got == pytest.approx((lat, lng), abs=1e-9)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cell,expected", DISKS.items())
|
||||
def test_grid_disk1(cell, expected):
|
||||
got = {h3lite.cell_to_str(c) for c in h3lite.grid_disk1(h3lite.str_to_cell(cell))}
|
||||
assert got == set(expected)
|
||||
|
||||
|
||||
def test_bad_input():
|
||||
with pytest.raises(ValueError):
|
||||
h3lite.latlng_to_cell(40.0, -83.0, 16)
|
||||
with pytest.raises(ValueError):
|
||||
h3lite.latlng_to_cell(float("nan"), -83.0, 6)
|
||||
with pytest.raises(ValueError):
|
||||
h3lite.cell_to_parent(h3lite.str_to_cell("862a90b4fffffff"), 7)
|
||||
|
||||
|
||||
def test_matches_h3_on_random_points():
|
||||
h3 = pytest.importorskip("h3")
|
||||
rnd = random.Random(0)
|
||||
for _ in range(1500):
|
||||
lat, lng = rnd.uniform(-90, 90), rnd.uniform(-180, 180)
|
||||
for res in range(16):
|
||||
want = h3.latlng_to_cell(lat, lng, res)
|
||||
cell = h3lite.latlng_to_cell(lat, lng, res)
|
||||
assert h3lite.cell_to_str(cell) == want
|
||||
assert h3lite.cell_to_latlng(cell) == pytest.approx(h3.cell_to_latlng(want), abs=1e-9)
|
||||
want_disk = {h3.str_to_int(c) for c in h3.grid_disk(h3.latlng_to_cell(lat, lng, 6), 1)}
|
||||
assert h3lite.grid_disk1(h3lite.latlng_to_cell(lat, lng, 6)) == want_disk
|
||||
@@ -0,0 +1,68 @@
|
||||
"""The replay tool, fed fake log messages (the real LogReader needs the full openpilot build)."""
|
||||
import datetime as dt
|
||||
import os
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
from openpilot.sunnypilot.achievements.community import CommunityLog, tile_of
|
||||
from openpilot.sunnypilot.achievements.engine import AchievementEngine, State
|
||||
from openpilot.sunnypilot.achievements.replay import RouteFeeder, find_routes
|
||||
from openpilot.sunnypilot.achievements.simulate import HOME, WORK
|
||||
|
||||
T0 = int(dt.datetime(2026, 11, 2, 13, 0, tzinfo=dt.UTC).timestamp() * 1e3)
|
||||
|
||||
|
||||
class Ev:
|
||||
def __init__(self, kind: str, mono_s: float, **fields):
|
||||
self._kind, self.logMonoTime = kind, int(mono_s * 1e9)
|
||||
setattr(self, kind, NS(**fields))
|
||||
|
||||
def which(self):
|
||||
return self._kind
|
||||
|
||||
|
||||
def fake_route(start, end, seconds=600):
|
||||
yield Ev("carParams", 0.0, carFingerprint="HYUNDAI_IONIQ_5")
|
||||
yield Ev("selfdriveState", 0.0, active=True)
|
||||
yield Ev("selfdriveStateSP", 0.0, mads=NS(active=False))
|
||||
yield Ev("driverMonitoringState", 0.0, alertLevel=NS(raw=0))
|
||||
for i in range(seconds * 10): # carState at 10 Hz, GPS at 1 Hz, like a qlog
|
||||
t = i / 10
|
||||
if i % 10 == 0:
|
||||
f = t / seconds
|
||||
yield Ev("gpsLocationExternal", t, hasFix=True, horizontalAccuracy=5.0,
|
||||
latitude=start[0] + (end[0] - start[0]) * f, longitude=start[1] + (end[1] - start[1]) * f,
|
||||
unixTimestampMillis=T0 + int(t * 1000))
|
||||
yield Ev("carState", t, vEgo=30.0)
|
||||
|
||||
|
||||
def test_route_feeder_counts_a_drive(tmp_path):
|
||||
engine, log = AchievementEngine(State()), CommunityLog(tmp_path / "c.db")
|
||||
feeder = RouteFeeder(engine, log)
|
||||
for ev in fake_route(HOME, WORK):
|
||||
feeder.feed(ev)
|
||||
engine.end_drive()
|
||||
log.end_drive(engine.utc_offset_h)
|
||||
|
||||
assert feeder.car == "HYUNDAI_IONIQ_5"
|
||||
assert 17_000 < engine.state.engaged_m < 18_500 # 600 s at 30 m/s
|
||||
assert engine.state.days == ["2026-11-02"]
|
||||
view = log.tile_view(dt.date(2026, 11, 3))
|
||||
assert tile_of(*HOME) in view.held # where the drive started
|
||||
assert len(view.driven) > 3
|
||||
assert log.home_region(dt.date(2026, 11, 3)) == "US-OH"
|
||||
|
||||
|
||||
def test_find_routes(tmp_path):
|
||||
for name, mtime in (("00000002--bbbb--0", 200), ("00000002--bbbb--1", 260), ("00000001--aaaa--0", 100),
|
||||
("00000001--aaaa--10", 160), ("00000001--aaaa--2", 130), ("boot", 0)):
|
||||
d = tmp_path / name
|
||||
d.mkdir()
|
||||
if name != "boot":
|
||||
(d / "qlog.zst").write_bytes(b"")
|
||||
os.utime(d / "qlog.zst", (mtime, mtime))
|
||||
routes = find_routes(tmp_path, rlog=False, days=None)
|
||||
assert [[p.parent.name for p in r] for r in routes] == [
|
||||
["00000001--aaaa--0", "00000001--aaaa--2", "00000001--aaaa--10"],
|
||||
["00000002--bbbb--0", "00000002--bbbb--1"],
|
||||
]
|
||||
assert find_routes(tmp_path, rlog=True, days=None) == []
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/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.
|
||||
|
||||
Builds regions.json.gz, the table the car uses to turn a map tile into a home
|
||||
region code. Dev tool only: it is not run on the car.
|
||||
|
||||
pip install h3 shapely
|
||||
curl -LO https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_1_states_provinces.geojson
|
||||
python openpilot/sunnypilot/achievements/tools/build_regions.py \
|
||||
ne_10m_admin_1_states_provinces.geojson openpilot/sunnypilot/achievements/regions.json.gz
|
||||
|
||||
Rules:
|
||||
- Region codes are ISO 3166-2 (US-OH) in the countries in SUBDIVISIONS, and the
|
||||
ISO 3166-1 country code (GB) everywhere else.
|
||||
- A resolution-6 tile (about 6 km) belongs to the region its center is in.
|
||||
Coastal tiles whose center is in the water go to the region whose coastline
|
||||
passes through them most.
|
||||
- Stored as the majority region of each resolution-4 cell ("regions") plus the
|
||||
resolution-6 tiles that differ from it ("exceptions"), each compacted. That
|
||||
keeps 6 km accuracy at borders in about 200 KB.
|
||||
|
||||
Boundaries: Natural Earth 1:10m admin 1, public domain.
|
||||
"""
|
||||
import collections
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
import h3
|
||||
from shapely.geometry import box, shape
|
||||
|
||||
SUBDIVISIONS = {"US", "CA", "MX", "BR", "AU", "DE"}
|
||||
TILE_RES = 6
|
||||
BASE_RES = 4
|
||||
CUT_DEG = 10.0 # H3 can't fill huge polygons at resolution 6, so cut them into squares first
|
||||
ISO_3166_2 = re.compile(r"^[A-Z]{2}-[A-Z0-9]{1,3}$")
|
||||
|
||||
|
||||
def region_code(props: dict) -> str | None:
|
||||
country = props.get("iso_a2") or ""
|
||||
if not re.fullmatch(r"[A-Z]{2}", country):
|
||||
return None
|
||||
code = props.get("iso_3166_2") or ""
|
||||
if country in SUBDIVISIONS and ISO_3166_2.match(code):
|
||||
return code
|
||||
return country
|
||||
|
||||
|
||||
def pieces(geom: dict):
|
||||
"""Polygon rings, as (lng, lat) lists, of the geometry cut into CUT_DEG squares."""
|
||||
g = shape(geom)
|
||||
if not g.is_valid:
|
||||
g = g.buffer(0)
|
||||
minx, miny, maxx, maxy = g.bounds
|
||||
x = minx
|
||||
while x < maxx:
|
||||
y = miny
|
||||
while y < maxy:
|
||||
part = g.intersection(box(x, y, x + CUT_DEG, y + CUT_DEG))
|
||||
for poly in getattr(part, "geoms", [part]):
|
||||
if poly.geom_type == "Polygon" and not poly.is_empty:
|
||||
yield [list(poly.exterior.coords)] + [list(r.coords) for r in poly.interiors]
|
||||
y += CUT_DEG
|
||||
x += CUT_DEG
|
||||
|
||||
|
||||
def outer_rings(geom: dict):
|
||||
polys = [geom["coordinates"]] if geom["type"] == "Polygon" else geom["coordinates"]
|
||||
for rings in polys:
|
||||
yield rings[0]
|
||||
|
||||
|
||||
def winner(counter: collections.Counter) -> str:
|
||||
return max(sorted(counter), key=lambda k: counter[k])
|
||||
|
||||
|
||||
def main(src: str, dst: str) -> None:
|
||||
features = json.load(open(src))["features"]
|
||||
by_center: dict[str, str] = {}
|
||||
by_coast: dict[str, collections.Counter] = collections.defaultdict(collections.Counter)
|
||||
for n, f in enumerate(features):
|
||||
code = region_code(f["properties"])
|
||||
if code is None or f["geometry"] is None:
|
||||
continue
|
||||
for rings in pieces(f["geometry"]):
|
||||
outer, holes = rings[0], rings[1:]
|
||||
poly = h3.LatLngPoly([(lat, lng) for lng, lat in outer], *[[(lat, lng) for lng, lat in r] for r in holes])
|
||||
for cell in h3.polygon_to_cells(poly, TILE_RES):
|
||||
by_center[cell] = code
|
||||
for ring in outer_rings(f["geometry"]):
|
||||
for lng, lat in ring:
|
||||
by_coast[h3.latlng_to_cell(lat, lng, TILE_RES)][code] += 1
|
||||
if n % 500 == 0:
|
||||
print(f"{n}/{len(features)} boundaries", file=sys.stderr)
|
||||
|
||||
tiles = dict(by_center)
|
||||
for cell, votes in by_coast.items():
|
||||
tiles.setdefault(cell, winner(votes))
|
||||
|
||||
base_votes: dict[str, collections.Counter] = collections.defaultdict(collections.Counter)
|
||||
for cell, code in tiles.items():
|
||||
base_votes[h3.cell_to_parent(cell, BASE_RES)][code] += 1
|
||||
base = {cell: winner(votes) for cell, votes in base_votes.items()}
|
||||
|
||||
regions: dict[str, list[str]] = collections.defaultdict(list)
|
||||
for cell, code in base.items():
|
||||
regions[code].append(cell)
|
||||
exceptions: dict[str, list[str]] = collections.defaultdict(list)
|
||||
for cell, code in tiles.items():
|
||||
if base[h3.cell_to_parent(cell, BASE_RES)] != code:
|
||||
exceptions[code].append(cell)
|
||||
|
||||
data = {
|
||||
"v": 1,
|
||||
"source": "Natural Earth 1:10m admin 1 (public domain); built by tools/build_regions.py",
|
||||
"subdivisions": sorted(SUBDIVISIONS),
|
||||
"tile_res": TILE_RES,
|
||||
"base_res": BASE_RES,
|
||||
"regions": {k: sorted(h3.compact_cells(v)) for k, v in sorted(regions.items())},
|
||||
"exceptions": {k: sorted(h3.compact_cells(v)) for k, v in sorted(exceptions.items())},
|
||||
}
|
||||
raw = json.dumps(data, separators=(",", ":"), sort_keys=True).encode()
|
||||
with gzip.GzipFile(dst, "wb", mtime=0) as out:
|
||||
out.write(raw)
|
||||
n_regions = sum(map(len, data["regions"].values()))
|
||||
n_exceptions = sum(map(len, data["exceptions"].values()))
|
||||
print(f"{len(tiles)} tiles, {len(data['regions'])} regions, {n_regions} base cells, {n_exceptions} exceptions", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.sunnypilot.modeld_v2.constants import ModelConstants
|
||||
|
||||
LANE_WIDTH_DEFAULT = 3.6 # m
|
||||
LANE_WIDTH_CLIP = (2.8, 4.5) # m
|
||||
LOOKAHEAD_TIME = 1.5 # s
|
||||
MIN_LOOKAHEAD = 10.0 # m
|
||||
MIN_SPEED = 5.0 # m/s
|
||||
LINE_STD = (0.2, 0.6)
|
||||
POINT_STD = 0.1
|
||||
YIELD = (0.2, 0.45)
|
||||
X_IDXS = np.array(ModelConstants.X_IDXS)
|
||||
|
||||
|
||||
class Blendful:
|
||||
def __init__(self):
|
||||
self.lane_width = FirstOrderFilter(LANE_WIDTH_DEFAULT, 10.0, DT_MDL, initialized=False)
|
||||
self.line_gone = FirstOrderFilter(np.zeros(2), 2.0, DT_MDL)
|
||||
self.line_probs_slow = FirstOrderFilter(np.zeros(2), 3.0, DT_MDL)
|
||||
self.line_probs = FirstOrderFilter(np.zeros(2), 0.5, DT_MDL)
|
||||
self.usual_offset = FirstOrderFilter(0.0, 10.0, DT_MDL, initialized=False)
|
||||
self.going_around = False
|
||||
self.since_turning = 0.0
|
||||
self.trust = 0.0
|
||||
self.line_trust = 0.0
|
||||
self.dk = 0.0 # delta kappa; difference between lane center and model plan
|
||||
self.l_prob = 0.0
|
||||
self.r_prob = 0.0
|
||||
self.model_curvature = 0.0
|
||||
self.smooth = 0.0 # smooths the correction target below .53 lat accel
|
||||
self.curvature = 0.0
|
||||
|
||||
def update(self, model_output: dict[str, np.ndarray], v_ego: float, active: bool, model_curvature: float, enabled: bool = True) -> float:
|
||||
self.l_prob = self.r_prob = 0.0
|
||||
self.model_curvature = model_curvature
|
||||
if not enabled:
|
||||
if self.curvature or self.trust:
|
||||
self.__init__()
|
||||
return 0.0
|
||||
fading = not active or v_ego < MIN_SPEED
|
||||
if fading:
|
||||
self.trust = self.line_trust = 0.0
|
||||
self.usual_offset.initialized = False
|
||||
self.lane_width.initialized = False
|
||||
self.going_around = False
|
||||
target = 0.0 if fading else self._target(model_output, v_ego, model_curvature)
|
||||
target = self.smooth = self.smooth + (target - self.smooth) * DT_MDL / (DT_MDL + np.interp(abs(model_curvature) * v_ego ** 2, [0.18, 0.53], [0.5, 0.0]))
|
||||
build = 0.5 / max(v_ego, 1.0) ** 2 * DT_MDL # jerk over 1 second
|
||||
release = (0.5 if fading else 2.0) / max(v_ego, 1.0) ** 2 * DT_MDL # lets go fast, but past zero it builds like any other pull
|
||||
k = self.curvature
|
||||
self.curvature = float(np.clip(target, max(k - release, -build) if k > 0 else k - build, min(k + release, build) if k < 0 else k + build))
|
||||
return self.curvature
|
||||
|
||||
def _target(self, model_output: dict[str, np.ndarray], v_ego: float, model_curvature: float) -> float:
|
||||
lookahead = max(v_ego * LOOKAHEAD_TIME, MIN_LOOKAHEAD)
|
||||
lines = model_output['lane_lines'][0, :, :, 0]
|
||||
stds = model_output['lane_lines_stds'][0, :, :, 0]
|
||||
|
||||
lone_weight = np.interp(abs(model_curvature) * v_ego ** 2, [0.18, 0.53], [0.5, 1.0]) # 0.5 on straights, 1.0 in curves
|
||||
probs = self.line_probs.update(model_output['lane_lines_prob'][0, 1::2][1:3])
|
||||
absent = (probs < 0.3) & (lone_weight > 0.5)
|
||||
gone = self.line_gone.update((model_output['lane_lines_prob'][0, 1::2][1:3] < 0.3).astype(float))
|
||||
missing = np.where(absent, 1.0, gone)
|
||||
dropping = bool(np.any((probs < 0.6 * self.line_probs_slow.update(model_output['lane_lines_prob'][0, 1::2][1:3])) & ~absent))
|
||||
l_prob = float(probs[0] * np.interp(np.interp(0.5 * lookahead, X_IDXS, stds[1]), LINE_STD, [1.0, 0.0]))
|
||||
r_prob = float(probs[1] * np.interp(np.interp(0.5 * lookahead, X_IDXS, stds[2]), LINE_STD, [1.0, 0.0]))
|
||||
self.l_prob, self.r_prob = l_prob, r_prob
|
||||
# one line steadily on its own still centers at lone_weight. a line that is disappearing gives control back to laneless
|
||||
if missing[1] > 0.8 and l_prob > 0.5:
|
||||
lane_trust = l_prob * lone_weight
|
||||
elif missing[0] > 0.8 and r_prob > 0.5:
|
||||
lane_trust = r_prob * lone_weight
|
||||
elif dropping:
|
||||
lane_trust = min(l_prob, r_prob)
|
||||
else:
|
||||
lane_trust = l_prob + r_prob - l_prob * r_prob
|
||||
if lone_weight > 0.5 and lane_trust < self.line_trust:
|
||||
lane_trust = self.line_trust + (lane_trust - self.line_trust) * DT_MDL / (lone_weight - 0.5 + DT_MDL)
|
||||
self.line_trust = lane_trust
|
||||
if lane_trust <= 0.0:
|
||||
self.trust = 0.0
|
||||
return 0.0
|
||||
if l_prob + r_prob <= 0.0: # keep the last fit while trust lets go
|
||||
self.trust = min(self.trust, lane_trust)
|
||||
return float(self.trust * self.dk)
|
||||
|
||||
win = (X_IDXS >= 0.5 * lookahead) & (X_IDXS <= 1.5 * lookahead)
|
||||
x = X_IDXS[win]
|
||||
half_width = self.lane_width.x / 2
|
||||
if l_prob * r_prob > 0.5:
|
||||
width = np.interp(lookahead, X_IDXS, lines[2]) - np.interp(lookahead, X_IDXS, lines[1])
|
||||
self.lane_width.update(float(np.clip(width, *LANE_WIDTH_CLIP)))
|
||||
half_width = np.clip(lines[2, win] - lines[1, win], *LANE_WIDTH_CLIP) / 2
|
||||
l_var = stds[1, win] ** 2 / max(l_prob, 1e-4)
|
||||
r_var = stds[2, win] ** 2 / max(r_prob, 1e-4)
|
||||
center = (r_var * (lines[1, win] + half_width) + l_var * (lines[2, win] - half_width)) / (l_var + r_var)
|
||||
weight = 1.0 / (l_var * r_var / (l_var + r_var) + POINT_STD ** 2)
|
||||
|
||||
# Fit curvature to place the plant in center: e ~ dk x^2 / 2
|
||||
plan_x = np.interp(x, model_output['plan'][0, :, 0], model_output['plan'][0, :, 1])
|
||||
plan_share = np.interp(abs(model_curvature) * lookahead ** 2 / 2, [0.5, 2.0], [0.3, 0.5]) # up to 50% plan y vs curvature parabola in sharp curves
|
||||
error = np.clip(center - ((1 - plan_share) * model_curvature * x ** 2 / 2 + plan_share * plan_x), -1.0, 1.0)
|
||||
dk = 2.0 * np.sum(weight * error * x ** 2) / np.sum(weight * x ** 4)
|
||||
|
||||
# A model planning to end up further from its usual spot in the lane than the plant is now is likely going around something
|
||||
hw_now = np.clip(lines[2, 0] - lines[1, 0], *LANE_WIDTH_CLIP) / 2 if l_prob * r_prob > 0.5 else self.lane_width.x / 2
|
||||
center_now = (l_prob * (lines[1, 0] + hw_now) + r_prob * (lines[2, 0] - hw_now)) / (l_prob + r_prob)
|
||||
plan_y = np.interp(lookahead, model_output['plan'][0, :, 0], model_output['plan'][0, :, 1])
|
||||
offset = plan_y - np.interp(lookahead, x, center)
|
||||
if min(gone) > 0.5: # both lines gone: turns, intersections, relearn ideal model position
|
||||
self.usual_offset.initialized = False
|
||||
usual = self.usual_offset.x if self.usual_offset.initialized else offset
|
||||
swerve = abs(offset - usual) - 0.25 * abs(model_curvature) * lookahead ** 2 / 2
|
||||
away = swerve - abs(-center_now - usual)
|
||||
lane_curvature = 2.0 * np.polyfit(x, center, 2)[0]
|
||||
inside_curve = abs(lane_curvature) * lookahead ** 2 / 2 > 0.1 and (offset - usual) * lane_curvature > 0
|
||||
self.since_turning = 0.0 if abs(model_curvature) * lookahead ** 2 / 2 > 0.5 else self.since_turning + DT_MDL
|
||||
if inside_curve:
|
||||
self.going_around = False
|
||||
elif away > YIELD[0] and self.since_turning > 2.0:
|
||||
self.going_around = True
|
||||
elif swerve < YIELD[0] / 2:
|
||||
self.going_around = False
|
||||
self.usual_offset.update_alpha(20.0 if self.going_around else 10.0)
|
||||
self.usual_offset.update(offset)
|
||||
|
||||
if inside_curve:
|
||||
lane_trust *= np.interp(abs(offset) - abs(center_now) - 0.25 * abs(model_curvature) * lookahead ** 2 / 2, (0.5, 1.0), [1.0, 0.0])
|
||||
else:
|
||||
lane_trust *= np.interp(swerve, (YIELD[0] / 2, YIELD[0]), [1.0, 0.0]) if self.going_around else np.interp(away, YIELD, [1.0, 0.0])
|
||||
|
||||
if lane_trust < self.trust:
|
||||
dk = float(np.clip(dk, min(self.dk, 0.0), max(self.dk, 0.0)))
|
||||
self.trust = lane_trust if lane_trust < self.trust else self.trust + (lane_trust - self.trust) * DT_MDL / (1.5 + DT_MDL)
|
||||
self.dk = dk
|
||||
return float(self.trust * dk)
|
||||
@@ -371,7 +371,7 @@ def _compile_warp_resolution_worker(cam_w, cam_h, model_w, model_h, benchmark_ru
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if 'USB' in os.getenv('DEV', '') or os.getenv('CHESTNUT'):
|
||||
if ('USB' in os.getenv('DEV', '') or os.getenv('CHESTNUT')) and not os.getenv('DEV', '').startswith('MOCK'):
|
||||
from openpilot.system.hardware.chestnut.flash import link_up
|
||||
for _ in range(10):
|
||||
if link_up():
|
||||
|
||||
@@ -17,16 +17,18 @@ def main():
|
||||
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)
|
||||
parser.add_argument("--light", action="store_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)
|
||||
n = 1 if args.light else 2
|
||||
run = TinyJit(lambda input_frame, M_inv: Tensor.stack(*(warp(input_frame[i], M_inv[i]) for i in range(n))), 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()}
|
||||
"M_inv": Tensor((rng.standard_normal((n, 3, 3)) * 8).astype(np.float32), device=Device.DEFAULT).realize()}
|
||||
with record_kernel_linear() as recorded:
|
||||
run(**inputs)
|
||||
run(**inputs)
|
||||
@@ -34,7 +36,7 @@ def main():
|
||||
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), "<f4", Device.DEFAULT)}
|
||||
specs = {"input_frame": ((2, frame.size), "|u1", Device.DEFAULT), "M_inv": ((n, 3, 3), "<f4", Device.DEFAULT)}
|
||||
write_bundle(args.output, {"run": part}, {"input_specs": specs, "metadata": {}})
|
||||
print(f"Saved warp to {args.output} ({len(part[0]['calls'])} kernels)")
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import argparse
|
||||
import dataclasses
|
||||
import inspect
|
||||
import itertools
|
||||
import mmap
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import tempfile
|
||||
@@ -45,6 +47,11 @@ def _address(v):
|
||||
return _root(v.src[0]), add + start, size
|
||||
|
||||
|
||||
def _buffer(u):
|
||||
from tinygrad.uop.ops import Ops
|
||||
return u.buffer if u.op is Ops.BUFFER else getattr(u.arg, "buffer", None)
|
||||
|
||||
|
||||
def _patched(buf, topo) -> tuple[bytearray, dict]:
|
||||
from tinygrad.uop.ops import Ops
|
||||
blob, rows = bytearray(buf.max_numel() * buf.dtype.itemsize), {}
|
||||
@@ -60,47 +67,75 @@ def _patched(buf, topo) -> tuple[bytearray, dict]:
|
||||
return blob, rows
|
||||
|
||||
|
||||
def _evaluate(u, env):
|
||||
def _packed_args(program) -> dict[int, int]:
|
||||
from tinygrad.device import TinyELF
|
||||
params = getattr(program.arg, "params", None) or ()
|
||||
return {off: p.arg.slot for (off, _), p in zip(TinyELF.iter_sig(tuple((None, p.addrspace, p.dtype, ()) for p in params)), params, strict=True)}
|
||||
|
||||
|
||||
def _outer_slot(u, packed: dict[int, int]) -> int | None:
|
||||
from tinygrad.uop.ops import Ops
|
||||
if u.op is Ops.PARAM:
|
||||
return None if packed else u.arg.slot
|
||||
if u.op.name == "REFPARAM":
|
||||
return u.arg.slot
|
||||
if packed and u.op is Ops.LOAD and u.src[0].op is Ops.INDEX and _root(u.src[0].src[0]).op is Ops.PARAM:
|
||||
index = _evaluate(u.src[0].src[1], {}, packed)
|
||||
return None if index is None else packed.get(index * u.dtype.itemsize)
|
||||
return None
|
||||
|
||||
|
||||
def _evaluate(u, env, packed: dict[int, int]):
|
||||
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)
|
||||
return _evaluate(u.src[0], env, packed)
|
||||
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.RANGE:
|
||||
return env.get(u)
|
||||
if (slot := _outer_slot(u, packed)) is not None:
|
||||
return env.get(slot)
|
||||
if u.op is Ops.LOAD:
|
||||
table, index = _root(u.src[0].src[0]), _evaluate(u.src[0].src[1], env)
|
||||
table, index = _root(u.src[0].src[0]), _evaluate(u.src[0].src[1], env, packed)
|
||||
return struct.unpack_from(u.dtype.fmt, table.arg, index * u.dtype.itemsize)[0] if table.op is Ops.BINARY and index is not None else None
|
||||
if u.op in GroupOp.ALU and None not in (values := [_evaluate(x, env) for x in u.src]):
|
||||
return exec_alu(u.op, u.dtype, values, False)
|
||||
if u.op in GroupOp.ALU and None not in (values := [_evaluate(x, env, packed) for x in u.src]):
|
||||
return exec_alu(u.op, u.dtype, values, *([False] if "truncate_output" in inspect.signature(exec_alu).parameters else []))
|
||||
return None
|
||||
|
||||
|
||||
def _input_pokes(submit, kernargs) -> dict[int, tuple[int, int]]:
|
||||
from tinygrad.uop.ops import Ops
|
||||
outer = dict(enumerate(submit.src[1:]))
|
||||
outer, packed = dict(enumerate(submit.src[1:])), _packed_args(submit.src[0])
|
||||
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}:
|
||||
used = list({slot for x in value.toposort() if (slot := _outer_slot(x, packed)) is not None} & inputs.keys())
|
||||
if len(used) != 1 or not kernargs_params & {_outer_slot(x, packed) for x in address.toposort()}:
|
||||
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))
|
||||
if (offset := _evaluate(address, env, packed)) % 8 == 0:
|
||||
pokes[offset] = (inputs[used[0]], _evaluate(value, env, packed))
|
||||
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("<IIQQQQIIQQ", name, kind, 2 if kind == 1 else 0, address, offset, len(content), 0, 0, 1, 0))
|
||||
def _elf(image: bytes, rodata: int, entry: int) -> bytes:
|
||||
spans = [(".text", 0, rodata), (".rodata", rodata, len(image))] if entry < rodata else \
|
||||
[(".data", 0, rodata), (".rodata", rodata, entry), (".text", entry, len(image))]
|
||||
sections, names = [(0, 0, 0, 0, b"")], b"\0"
|
||||
for name, start, end in (span for span in spans if span[1] < span[2]):
|
||||
sections.append((len(names), 1, 6 if name == ".text" else 2, ALIGNMENT + start, image[start:end]))
|
||||
names += name.encode() + b"\0"
|
||||
sections.append((len(names), 3, 0, 0, names + b".shstrtab\0"))
|
||||
offset, data, headers = 64, b"", []
|
||||
for name, kind, flags, address, content in sections:
|
||||
headers.append(struct.pack("<IIQQQQIIQQ", name, kind, flags, address, offset, len(content), 0, 0, 1, 0))
|
||||
data, offset = data + content, offset + len(content)
|
||||
return b"\x7fELF\x02\x01\x01" + bytes(9) + struct.pack("<HHIQQQIHHHHHH", 1, 0xe0, 1, 0, 0, offset, 0, 64, 0, 0, 64, 4, 3) + data + b"".join(headers)
|
||||
header = struct.pack("<HHIQQQIHHHHHH", 1, 0xe0, 1, 0, 0, offset, 0, 64, 0, 0, 64, len(sections), len(sections) - 1)
|
||||
return b"\x7fELF\x02\x01\x01" + bytes(9) + header + data + b"".join(headers)
|
||||
|
||||
|
||||
def convert(jit, kwargs: dict, frame: dict, target) -> tuple[dict, memoryview]:
|
||||
@@ -112,7 +147,7 @@ def convert(jit, kwargs: dict, frame: dict, target) -> tuple[dict, memoryview]:
|
||||
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}
|
||||
allocs = {str(name): u for u in topo if u.op is Ops.ALLOC and (name := getattr(u, "tag", None) or u.arg.name) is not None}
|
||||
(commands, patched), kernargs = _patched(allocs["cmdbuf_compute_0"], topo), allocs["kernargs_compute_0"]
|
||||
argument_words, pokes = _patched(kernargs, topo)[1], _input_pokes(submit, kernargs)
|
||||
sh = {name: getattr(amd_gpu, f"reg{name}") + amd_gpu.GC_BASE__INST0_SEG0 - pm4.PACKET3_SET_SH_REG_START
|
||||
@@ -149,7 +184,7 @@ def convert(jit, kwargs: dict, frame: dict, target) -> tuple[dict, memoryview]:
|
||||
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("<q", image, r + 16)[0] == entry)
|
||||
programs[image_alloc] = blob.add(_elf(image, rodata))
|
||||
programs[image_alloc] = blob.add(_elf(image, rodata, entry))
|
||||
start, size = _view(arguments.src[0])
|
||||
args = []
|
||||
for slot in range(start, start + unwrap(size), 8):
|
||||
@@ -158,20 +193,22 @@ def convert(jit, kwargs: dict, frame: dict, target) -> tuple[dict, memoryview]:
|
||||
args.append({"input": pokes[slot][0]})
|
||||
elif slot in argument_words:
|
||||
base, at, nbytes = _address(argument_words[slot])
|
||||
args.append({"buffer": buffers.setdefault(base, len(buffers)), "offset": at, "nbytes": nbytes or base.arg.buffer.nbytes, "dtype": u8})
|
||||
args.append({"buffer": buffers.setdefault(base, len(buffers)), "offset": at, "nbytes": nbytes or base.max_numel() * base.dtype.itemsize, "dtype": u8})
|
||||
outs, ins = kernel[6]
|
||||
calls.append({"kind": "program", "name": re.sub(r"\x1b\[[0-9;]*m", "", kernel[1]), "lib": programs[image_alloc], "target": dataclasses.asdict(target),
|
||||
device_target = dataclasses.asdict(dataclasses.replace(target, interface=target.interface.removeprefix("MOCK")))
|
||||
calls.append({"kind": "program", "name": re.sub(r"\x1b\[[0-9;]*m", "", kernel[1]), "lib": programs[image_alloc], "target": device_target,
|
||||
"signature": [[f"data{i}", i, u8, [a.get("nbytes", 1)]] for i, a in enumerate(args)], "global_size": grid, "local_size": local,
|
||||
"globals": list(range(len(args))), "outs": list(outs), "ins": list(ins), "args": args})
|
||||
needs_init |= {args[i]["buffer"] for i in ins if "buffer" in args[i] and args[i]["buffer"] not in written}
|
||||
written |= {args[i]["buffer"] for i in outs if "buffer" in args[i]}
|
||||
|
||||
assert all(_buffer(b) is not None for b, i in buffers.items() if i in needs_init), "a kernel reads a scratch before any kernel writes it"
|
||||
names = list(jit.captured.expected_names)
|
||||
inputs = [{"slot": slot, "name": names[slot] if slot < len(names) else None, "dtype": repr(u.dtype), "numel": int(u.max_numel()), "device": u.device}
|
||||
for slot, u in enumerate(_prepare_jit_inputs((), kwargs)[0])]
|
||||
header = {"input_names": names, "calls": calls, "returns": {"kind": "NoneType", "tensors": []}, "inputs": inputs,
|
||||
"buffers": [{"nbytes": b.arg.buffer.nbytes, "device": b.arg.buffer.device, "dtype": u8}
|
||||
| ({"init": blob.add(b.arg.buffer.ensure_allocated().as_memoryview())} if i in needs_init else {}) for b, i in buffers.items()],
|
||||
"buffers": [{"nbytes": bb.nbytes if (bb := _buffer(b)) else b.max_numel() * b.dtype.itemsize, "device": bb.device if bb else b.device, "dtype": u8}
|
||||
| ({"init": blob.add(bb.ensure_allocated().as_memoryview())} if i in needs_init else {}) for b, i in buffers.items()],
|
||||
"check": {"inputs": [[path, store(array), device] for path, array, device in frame["inputs"]],
|
||||
"outputs": [store(array) for array in frame["outputs"]], "output_container": frame["output_container"]}}
|
||||
with sink:
|
||||
@@ -200,14 +237,16 @@ def main():
|
||||
frame, _ = capture_frame(artifact["run"], kwargs, "output_buffers")
|
||||
part = convert(artifact["run"], kwargs, frame, Device[Device.DEFAULT].renderer.target)
|
||||
|
||||
plain = PlainJit(*part) # replays the frame, within tolerance
|
||||
replay = {path[0]: Tensor(array, device=device).realize() for path, array, device in frame["inputs"] if len(path) == 1}
|
||||
replay["output_buffers"] = {path[1]: Tensor(array, device=device).realize() for path, array, device in frame["inputs"] if len(path) == 2}
|
||||
flatten(plain(**replay))
|
||||
for index, (got, expected) in enumerate(zip([t.numpy() for t in replay["output_buffers"].values()], frame["outputs"], strict=True)):
|
||||
assert np.array_equal(got, expected), f"output {index} of the plain artifact differs from the pickle"
|
||||
if not os.getenv("MOCKUSB_SOCKET"):
|
||||
plain = PlainJit(*part)
|
||||
replay = {path[0]: Tensor(array, device=device).realize() for path, array, device in frame["inputs"] if len(path) == 1}
|
||||
replay["output_buffers"] = {path[1]: Tensor(array, device=device).realize() for path, array, device in frame["inputs"] if len(path) == 2}
|
||||
flatten(plain(**replay))
|
||||
for index, (got, expected) in enumerate(zip([t.numpy() for t in replay["output_buffers"].values()], frame["outputs"], strict=True)):
|
||||
assert np.array_equal(got, expected), f"output {index} of the plain artifact differs from the pickle"
|
||||
write_bundle(args.output, {"run": part}, {key: artifact[key] for key in ("metadata", "input_specs", "output_specs")})
|
||||
print(f"converted {len(part[0]['calls'])} kernels, bit-exact with the pickle: {args.output}")
|
||||
checked = "left to mock_parity" if os.getenv("MOCKUSB_SOCKET") else "bit-exact with the pickle"
|
||||
print(f"converted {len(part[0]['calls'])} kernels, {checked}: {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -20,6 +20,11 @@ def pack_layout(shapes: dict[str, tuple], frame_size: int) -> tuple[dict[str, in
|
||||
return offsets | {"frames": offset}, offset + 2 * frame_size
|
||||
|
||||
|
||||
def host_shapes(input_shapes: dict[str, tuple], states) -> dict[str, tuple]:
|
||||
shapes = {name: shape for name, shape in input_shapes.items() if name not in states and name not in ("new_img", "light_img")}
|
||||
return shapes | ({"light_tfm": (1, 3, 3)} if "light_img" in input_shapes else {})
|
||||
|
||||
|
||||
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]]))
|
||||
@@ -30,18 +35,23 @@ class _Blobs: # the model's and the warp's blobs as one address space, for a pa
|
||||
|
||||
|
||||
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")
|
||||
models = f"{BASEDIR}/openpilot/sunnypilot/modeld_v2/models"
|
||||
return partial(frame_part, f"{models}/{'big_' if chestnut else ''}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl",
|
||||
light_warp_path=f"{models}/driving_lightwarp_{cam_w}x{cam_h}_tinygrad.pkl")
|
||||
|
||||
|
||||
def frame_part(warp_path: str, parts: dict, extras: dict) -> dict:
|
||||
def frame_part(warp_path: str, parts: dict, extras: dict, light_warp_path: str | None = None) -> 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"}
|
||||
host = host_shapes({name: shape for name, (shape, _, _) in extras["input_specs"].items()}, states)
|
||||
light = "light_img" in extras["input_specs"]
|
||||
light_warp, light_blob = read_parts(light_warp_path)[0]["run"] if light else ({"buffers": [], "calls": [], "inputs": []}, memoryview(b""))
|
||||
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"))
|
||||
shift, light_shift = len(model_blob), len(model_blob) + len(warp_blob)
|
||||
upload, u8 = len(model["buffers"]) + len(warp["buffers"]) + len(light_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}
|
||||
@@ -49,12 +59,15 @@ def frame_part(warp_path: str, parts: dict, extras: dict) -> dict:
|
||||
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]
|
||||
def warped(part: dict, base: int) -> dict:
|
||||
returned = part["returns"]["tensors"][0]
|
||||
return {k: v for k, v in returned.items() if k != "shape"} | {"buffer": returned["buffer"] + base}
|
||||
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
|
||||
warped(warp, len(model["buffers"])) if s["name"] == "new_img" else
|
||||
warped(light_warp, len(model["buffers"]) + len(warp["buffers"])) if s["name"] == "light_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 warp_inputs(part: dict, tfm: str) -> dict:
|
||||
return {s["slot"]: view("frames" if s["name"] == "input_frame" else tfm, s["numel"] * _dtype(s["dtype"]).itemsize, s["dtype"]) for s in part["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 {}) |
|
||||
@@ -62,8 +75,11 @@ def frame_part(warp_path: str, parts: dict, extras: dict) -> dict:
|
||||
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"]] +
|
||||
"buffers": model["buffers"] + [b | ({"init": [b["init"][0] + d, b["init"][1] + d]} if "init" in b else {})
|
||||
for part, d in ((warp, shift), (light_warp, light_shift)) for b in part["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
|
||||
remap(warp["calls"], warp_inputs(warp, "tfm"), len(model["buffers"]), shift) +
|
||||
remap(light_warp["calls"], warp_inputs(light_warp, "light_tfm"), len(model["buffers"]) + len(warp["buffers"]), light_shift) +
|
||||
remap(model["calls"], model_inputs, 0, 0)}
|
||||
return {"run": (header, _Blobs(model_blob, warp_blob, light_blob) if light else _Blobs(model_blob, warp_blob))}
|
||||
|
||||
@@ -250,6 +250,12 @@ def _allocate_buffers(header: dict, blob: memoryview, device_map: dict | None, s
|
||||
return buffers
|
||||
|
||||
|
||||
def _fields(what, /, **values): # a tinygrad dataclass built or replaced with the fields it has today: a dropped field is fine, a new required one is not
|
||||
known = {field.name for field in dataclasses.fields(what)}
|
||||
values = {name: value for name, value in values.items() if name in known}
|
||||
return what(**values) if isinstance(what, type) else dataclasses.replace(what, **values)
|
||||
|
||||
|
||||
def _program_uop(call: dict, lib: bytes):
|
||||
from tinygrad.helpers import Target
|
||||
from tinygrad.uop.ops import KernelInfo, Ops, ProgramInfo, UOp
|
||||
@@ -257,9 +263,9 @@ def _program_uop(call: dict, lib: bytes):
|
||||
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"]))
|
||||
parameters.append(parameter.replace(arg=_fields(parameter.arg, name=name, image=image)))
|
||||
info = _fields(ProgramInfo, global_size=tuple(call["global_size"]), local_size=tuple(call["local_size"]), params=tuple(parameters),
|
||||
globals=tuple(call["globals"]), outs=tuple(call["outs"]), ins=tuple(call["ins"]), target=_fields(Target, **call["target"]))
|
||||
return UOp(Ops.PROGRAM, src=(UOp(Ops.SINK, arg=KernelInfo(name=call["name"])), UOp(Ops.LINEAR, src=tuple(parameters)),
|
||||
UOp(Ops.SOURCE, arg=""), UOp(Ops.BINARY, arg=lib)), arg=info)
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Copyright (c) 2021-, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.transformations.camera import get_view_frame_from_calib_frame, view_frame_from_device_frame
|
||||
from openpilot.common.transformations.orientation import rot_from_euler
|
||||
|
||||
LIGHT_INPUT_SIZE = (512, 256)
|
||||
light_intrinsics = np.array([
|
||||
[1000.0, 0.0, 256.0],
|
||||
[0.0, 1000.0, 200.0],
|
||||
[0.0, 0.0, 1.0]])
|
||||
calib_from_light = np.linalg.inv((light_intrinsics @ get_view_frame_from_calib_frame(0, 0, 0, 0))[:, :3])
|
||||
|
||||
|
||||
def get_light_warp_matrix(device_from_calib_euler: np.ndarray, intrinsics: np.ndarray) -> np.ndarray:
|
||||
camera_from_calib = intrinsics @ view_frame_from_device_frame @ rot_from_euler(device_from_calib_euler)
|
||||
return (camera_from_calib @ calib_from_light).astype(np.float32)
|
||||
@@ -0,0 +1,153 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
os.environ.setdefault("DEV", "MOCKUSB+AMD")
|
||||
QCOM = "QCOM" in os.environ["DEV"]
|
||||
ON_MOCK = os.environ["DEV"].startswith("MOCK")
|
||||
ARCH, HARDWARE = ("a630", "comma 3x/4 (a630)") if QCOM else ("gfx1200", "chestnut")
|
||||
os.environ.setdefault("CHECK_OOB", "0")
|
||||
os.environ.setdefault("GMMU", "0")
|
||||
if sys.platform == "darwin" and os.environ["DEV"].startswith("MOCKUSB"): # the mock's VRAM is a memfd, which macOS lacks: an unlinked file is the same
|
||||
from tinygrad.runtime.autogen import libc
|
||||
|
||||
def _memfd_create(name, flags):
|
||||
fd, path = tempfile.mkstemp()
|
||||
os.unlink(path)
|
||||
return fd
|
||||
libc.memfd_create = _memfd_create
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import ALIGNMENT, MAGIC, PlainJit, _device, read_parts
|
||||
|
||||
|
||||
class ParityError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _emulator():
|
||||
try:
|
||||
if QCOM:
|
||||
from test.mockgpu.qcom import emu
|
||||
else:
|
||||
from test.mockgpu.amd import emu
|
||||
except ImportError as e:
|
||||
raise ParityError(f"tinygrad's {'qcom' if QCOM else 'amd'} mock is not importable: put tinygrad_repo on PYTHONPATH") from e
|
||||
if not hasattr(emu, "hw_unverified"):
|
||||
raise ParityError(f"this tinygrad's emulator has no {ARCH} rounding model, its check frames would not match a {HARDWARE}")
|
||||
return emu
|
||||
|
||||
|
||||
def unverified(header: dict, blob: memoryview) -> dict[str, int]:
|
||||
emu, out = _emulator(), {}
|
||||
for call in header["calls"]:
|
||||
if call["kind"] != "program":
|
||||
continue
|
||||
if (arch := call["target"].get("arch", "")).split(",")[0] != ARCH: # IMAGE builds append ",IMAGE_PITCH_ALIGNMENT=64"
|
||||
out[f"arch={arch}"] = out.get(f"arch={arch}", 0) + 1
|
||||
continue
|
||||
for sig, n in emu.hw_unverified(bytes(blob[slice(*call["lib"])])).items():
|
||||
out[sig] = out.get(sig, 0) + n
|
||||
return out
|
||||
|
||||
|
||||
def _array(blob: memoryview, stored: dict) -> np.ndarray:
|
||||
return np.frombuffer(blob[slice(*stored["data"])], dtype=np.dtype(stored["dtype"])).reshape(stored["shape"]).copy()
|
||||
|
||||
|
||||
def replay(header: dict, blob: memoryview, shared: dict) -> list[np.ndarray]:
|
||||
from tinygrad import Tensor
|
||||
check = header["check"]
|
||||
jit = PlainJit(header, blob, None, verify=False, shared=shared)
|
||||
kwargs: dict = {}
|
||||
for path, stored, device in check["inputs"]:
|
||||
tensor = Tensor(_array(blob, stored), device=_device(device, None)).realize()
|
||||
kwargs[path[0]] = tensor if len(path) == 1 else {**kwargs.get(path[0], {}), path[1]: tensor}
|
||||
result = jit(**kwargs)
|
||||
outputs = kwargs[check["output_container"]].values() if check["output_container"] else result
|
||||
return [t.numpy() for t in (outputs if isinstance(outputs, (list, tuple)) or check["output_container"] else [outputs])]
|
||||
|
||||
|
||||
def _parts_with_offsets(path: str) -> tuple[dict, dict]:
|
||||
parts, _ = read_parts(path)
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(len(MAGIC) + 8)
|
||||
start = len(MAGIC) + 8 + struct.unpack_from("<Q", head, len(MAGIC))[0]
|
||||
f.seek(len(MAGIC) + 8)
|
||||
entries = json.loads(f.read(start - len(MAGIC) - 8))["parts"]
|
||||
base = start + -start % ALIGNMENT
|
||||
offsets = {tuple(e["key"]) if isinstance(e["key"], list) else e["key"]: base + e["blob"][0] for e in entries}
|
||||
return parts, offsets
|
||||
|
||||
|
||||
def _gate(parts: dict) -> None:
|
||||
missing: dict[str, int] = {}
|
||||
for header, blob in parts.values():
|
||||
if "check" not in header:
|
||||
raise ParityError("artifact has no check frame")
|
||||
for sig, n in unverified(header, blob).items():
|
||||
missing[sig] = missing.get(sig, 0) + n
|
||||
if missing:
|
||||
raise ParityError(f"kernels use instructions the emulator is not verified against a {HARDWARE} for: " +
|
||||
", ".join(f"{s} ({n}x)" for s, n in sorted(missing.items(), key=lambda kv: -kv[1])))
|
||||
|
||||
|
||||
def check(path: str) -> None:
|
||||
parts, _ = read_parts(path)
|
||||
if ON_MOCK:
|
||||
_gate(parts)
|
||||
shared: dict = {}
|
||||
for key, (header, blob) in parts.items():
|
||||
got = replay(header, blob, shared)
|
||||
for i, (g, stored) in enumerate(zip(got, header["check"]["outputs"], strict=True)):
|
||||
want = _array(blob, stored)
|
||||
if g.shape != want.shape or not np.array_equal(g.view(np.uint8), want.view(np.uint8)):
|
||||
n = int(np.count_nonzero(g != want)) if g.shape == want.shape else -1
|
||||
where = "mock" if ON_MOCK else "device"
|
||||
raise ParityError(f"part {key} output {i}: the {where}'s check frame differs from the stored one in {n} of {want.size} values")
|
||||
|
||||
|
||||
def record(path: str, out: str) -> None:
|
||||
parts, offsets = _parts_with_offsets(path)
|
||||
_gate(parts)
|
||||
shared, writes = {}, []
|
||||
for key, (header, blob) in parts.items():
|
||||
for g, stored in zip(replay(header, blob, shared), header["check"]["outputs"], strict=True):
|
||||
want = np.dtype(stored["dtype"])
|
||||
if tuple(g.shape) != tuple(stored["shape"]) or g.dtype != want:
|
||||
raise ParityError(f"part {key}: replayed output {g.dtype}{g.shape} does not fit the stored {want}{tuple(stored['shape'])}")
|
||||
writes.append((offsets[key] + stored["data"][0], np.ascontiguousarray(g).tobytes()))
|
||||
shutil.copyfile(path, out)
|
||||
with open(out, "r+b") as f:
|
||||
for offset, data in writes:
|
||||
f.seek(offset)
|
||||
f.write(data)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) < 2 or argv[0] not in ("check", "record") or (argv[0] == "record" and len(argv) != 3):
|
||||
print("usage: mock_parity check ARTIFACT... | record ARTIFACT OUT", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
if argv[0] == "record":
|
||||
st = time.perf_counter()
|
||||
record(argv[1], argv[2])
|
||||
print(f"{argv[2]}: check frame recorded on the mock in {time.perf_counter() - st:.0f}s")
|
||||
return 0
|
||||
for path in argv[1:]:
|
||||
st = time.perf_counter()
|
||||
check(path)
|
||||
print(f"{os.path.basename(path)}: bit-identical to its {HARDWARE} check frame on the {'mock' if ON_MOCK else 'device'} ({time.perf_counter() - st:.0f}s)")
|
||||
return 0
|
||||
except ParityError as e:
|
||||
print(f"PARITY FAIL: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -11,7 +11,7 @@ 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.frame_fusion import host_shapes, 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)
|
||||
@@ -172,7 +172,7 @@ class NativeTinygradAdapter(BaseModelAdapter):
|
||||
|
||||
self.input_queues = {name: Tensor(np.zeros(shape, dtype=dtype), device=self.model_device).realize()
|
||||
for name, (shape, dtype) in self.input_shapes.items() if name in self.state_pairs}
|
||||
shapes = {name: shape for name, (shape, _) in self.input_shapes.items() if name not in self.state_pairs and name != 'new_img'}
|
||||
shapes = host_shapes({name: shape for name, (shape, _) in self.input_shapes.items()}, self.state_pairs)
|
||||
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')
|
||||
|
||||
@@ -7,6 +7,7 @@ See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
import gc
|
||||
import os
|
||||
os.environ['GMMU'] = '0'
|
||||
@@ -39,9 +40,11 @@ from openpilot.selfdrive.modeld.modeld import ChestnutGpuState
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.fill_model_msg import fill_model_msg, fill_pose_msg, PublishState, get_curvature_from_output
|
||||
from openpilot.sunnypilot.modeld_v2.parse_model_outputs import Parser
|
||||
from openpilot.sunnypilot.modeld_v2.blendful import Blendful
|
||||
from openpilot.sunnypilot.modeld_v2.constants import ModelConstants, Plan
|
||||
from openpilot.sunnypilot.modeld_v2.meta_helper import load_meta_constants
|
||||
from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper
|
||||
from openpilot.sunnypilot.modeld_v2.light_frame import get_light_warp_matrix
|
||||
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_model
|
||||
@@ -169,7 +172,7 @@ class ModelState(ModelStateBase):
|
||||
np.where(inputs[desire_key] - self.prev_desire > .99, inputs[desire_key], 0)
|
||||
self.prev_desire[:] = inputs[desire_key]
|
||||
|
||||
for key in ('traffic_convention', 'lateral_control_params', 'action_t'):
|
||||
for key in ('traffic_convention', 'lateral_control_params', 'action_t', 'speed_limit'):
|
||||
if key in self.numpy_inputs and key in inputs:
|
||||
self.numpy_inputs[key][:] = inputs[key]
|
||||
|
||||
@@ -177,6 +180,8 @@ class ModelState(ModelStateBase):
|
||||
for i, key in enumerate(self._vision_input_names):
|
||||
if key in transforms:
|
||||
self.numpy_inputs['tfm'][i] = transforms[key].reshape(3, 3)
|
||||
if 'light_tfm' in self.numpy_inputs and 'light' in transforms:
|
||||
self.numpy_inputs['light_tfm'][0] = transforms['light'].reshape(3, 3)
|
||||
else:
|
||||
self.numpy_inputs['tfm'][:, :] = transforms[self._road_key].reshape(3, 3)
|
||||
self.numpy_inputs['big_tfm'][:, :] = transforms[self._wide_key].reshape(3, 3)
|
||||
@@ -228,7 +233,8 @@ class ModelState(ModelStateBase):
|
||||
return outputs
|
||||
|
||||
def get_action_from_model(self, model_output: dict[str, np.ndarray], prev_action: log.ModelDataV2.Action,
|
||||
lat_action_t: float, long_action_t: float, v_ego: float) -> log.ModelDataV2.Action:
|
||||
lat_action_t: float, long_action_t: float, v_ego: float,
|
||||
blendful: Callable[[float], float] | None = None) -> log.ModelDataV2.Action:
|
||||
if 'action' not in model_output:
|
||||
plan = model_output['plan'][0]
|
||||
desired_accel = get_accel_from_plan(plan[:, Plan.VELOCITY][:, 0], plan[:, Plan.ACCELERATION][:, 0], self.constants.T_IDXS,
|
||||
@@ -239,7 +245,9 @@ class ModelState(ModelStateBase):
|
||||
desired_curvature = get_curvature_from_output(model_output, curvature_plan, v_ego, lat_action_t, self.mlsim)
|
||||
else:
|
||||
desired_accel = model_output['action'][0, 1]
|
||||
desired_curvature = model_output['action'][0, 0] / (max(1.0, v_ego))**2
|
||||
desired_curvature = model_output['action'][0, 0] / (max(2.5, v_ego))**2
|
||||
if blendful is not None:
|
||||
desired_curvature += blendful(desired_curvature)
|
||||
|
||||
stop = v_ego < 0.3 and desired_accel < 0.1
|
||||
desired_accel = smooth_value(desired_accel, prev_action.desiredAcceleration, self.LONG_SMOOTH_SECONDS)
|
||||
@@ -333,7 +341,7 @@ def main(demo=False):
|
||||
pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutGpuState"] if CHESTNUT else [])
|
||||
pm = PubMaster(pub_socks)
|
||||
sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState",
|
||||
"carControl", "controlsState", "lateralDelay"])
|
||||
"carControl", "controlsState", "lateralDelay", "longitudinalPlanSP"])
|
||||
|
||||
publish_state = PublishState()
|
||||
chestnut_state = ChestnutGpuState(pm, model.chestnut) if CHESTNUT else None
|
||||
@@ -346,6 +354,7 @@ def main(demo=False):
|
||||
|
||||
model_transform_main = np.zeros((3, 3), dtype=np.float32)
|
||||
model_transform_extra = np.zeros((3, 3), dtype=np.float32)
|
||||
model_transform_light = np.zeros((3, 3), dtype=np.float32)
|
||||
live_calib_seen = False
|
||||
buf_main, buf_extra = None, None
|
||||
meta_main = FrameMeta()
|
||||
@@ -364,8 +373,13 @@ def main(demo=False):
|
||||
prev_action = log.ModelDataV2.Action()
|
||||
|
||||
DH = DesireHelper()
|
||||
desire_frames = [q.shape[0] for m in (model, small_model) if m is not None for name, q in m.adapter.input_queues.items() if 'desire' in name]
|
||||
if desire_frames:
|
||||
DH.turn_repulse = min(desire_frames) - 2
|
||||
meta_constants = load_meta_constants()
|
||||
RELC = RoadEdgeLaneChangeController()
|
||||
blendful = Blendful()
|
||||
blendful_enabled = False
|
||||
|
||||
while True:
|
||||
# Keep receiving frames until we are at least 1 frame ahead of previous extra frame
|
||||
@@ -409,6 +423,7 @@ def main(demo=False):
|
||||
model.lat_delay = get_lat_delay(params, sm["lateralDelay"].lateralDelay)
|
||||
model.PLANPLUS_CONTROL = params.get("PlanplusControl", return_default=True)
|
||||
camera_offset_helper.set_offset(params.get("CameraOffset", return_default=True))
|
||||
blendful_enabled = params.get_bool("Blendful")
|
||||
lat_delay = model.lat_delay + model.LAT_SMOOTH_SECONDS
|
||||
if sm.updated["extrinsicsCalibration"] and sm.seen['narrowRoadCameraState'] and sm.seen['deviceState']:
|
||||
device_from_calib_euler = np.array(sm["extrinsicsCalibration"].rpyCalib, dtype=np.float32)
|
||||
@@ -417,6 +432,7 @@ def main(demo=False):
|
||||
model_transform_main = get_warp_matrix(device_from_calib_euler, main_intrinsics, False).astype(np.float32)
|
||||
model_transform_extra = get_warp_matrix(device_from_calib_euler, dc.wide_road.intrinsics, True).astype(np.float32)
|
||||
model_transform_main, model_transform_extra = camera_offset_helper.update(model_transform_main, model_transform_extra, sm, main_wide_camera)
|
||||
model_transform_light = get_light_warp_matrix(device_from_calib_euler, main_intrinsics)
|
||||
live_calib_seen = True
|
||||
|
||||
traffic_convention = np.zeros(2)
|
||||
@@ -438,6 +454,7 @@ def main(demo=False):
|
||||
|
||||
bufs = {name: buf_extra if 'big' in name else buf_main for name in model.vision_input_names}
|
||||
transforms = {name: model_transform_extra if 'big' in name else model_transform_main for name in model.vision_input_names}
|
||||
transforms['light'] = model_transform_light
|
||||
|
||||
frame_delay = DT_MDL # compensate for time passed since the frame was captured: current_time - timestamp_eof is 50ms on average
|
||||
action_delay = DT_MDL / 2 # middle of the interval between model output (current state) and next frame (expected state)
|
||||
@@ -455,6 +472,10 @@ def main(demo=False):
|
||||
if 'action_t' in model.numpy_inputs:
|
||||
inputs['action_t'] = np.array([lat_action_t, long_action_t], dtype=np.float32)
|
||||
|
||||
if 'speed_limit' in model.numpy_inputs:
|
||||
resolver = sm['longitudinalPlanSP'].speedLimit.resolver
|
||||
inputs['speed_limit'] = np.array([resolver.speedLimitFinal if resolver.speedLimitValid else 0.0], dtype=np.float32)
|
||||
|
||||
mt1 = time.perf_counter()
|
||||
try:
|
||||
send_chestnut = (chestnut_state is not None and
|
||||
@@ -482,7 +503,9 @@ def main(demo=False):
|
||||
|
||||
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)
|
||||
blendful_active = desire == log.Desire.none and DH.lane_change_state == log.LaneChangeState.off
|
||||
action = model.get_action_from_model(model_output, prev_action, lat_action_t, long_action_t, v_ego,
|
||||
partial(blendful.update, model_output, v_ego, blendful_active, enabled=blendful_enabled))
|
||||
prev_action = action
|
||||
fill_model_msg(drivingdata_send, modelv2_send, model_output, action,
|
||||
publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id,
|
||||
@@ -499,6 +522,13 @@ def main(demo=False):
|
||||
modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction
|
||||
mdv2sp_send.valid = modelv2_send.valid
|
||||
mdv2sp_send.modelDataV2SP.laneTurnDirection = DH.lane_turn_direction
|
||||
mdv2sp_send.modelDataV2SP.blendfulCurvature = blendful.curvature
|
||||
mdv2sp_send.modelDataV2SP.blendfulTrust = float(blendful.trust)
|
||||
mdv2sp_send.modelDataV2SP.modelCurvature = float(blendful.model_curvature)
|
||||
mdv2sp_send.modelDataV2SP.blendfulGoingAround = bool(blendful.going_around)
|
||||
mdv2sp_send.modelDataV2SP.blendfulLeftProb = blendful.l_prob
|
||||
mdv2sp_send.modelDataV2SP.blendfulRightProb = blendful.r_prob
|
||||
mdv2sp_send.modelDataV2SP.blendfulLaneWidth = float(blendful.lane_width.x)
|
||||
drivingdata_send.drivingModelData.meta.laneChangeState = DH.lane_change_state
|
||||
drivingdata_send.drivingModelData.meta.laneChangeDirection = DH.lane_change_direction
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c08cffa27671d6773e316fdf4dcab80ffce2c4cc6fa28775615bc6726a63d7ba
|
||||
size 3473263
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6cddff2e3320057735fd4812d1ef9cc7d6d9fe405e3b6e7c4df85ee654a0e886
|
||||
size 7699823
|
||||
@@ -23,7 +23,7 @@ def record_kernel_linear():
|
||||
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]):
|
||||
with mock.patch.object(realize, "hcq_compile", lambda linear, *a, **k: (linear.src and recorded.append(linear), hcq_compile(linear, *a, **k))[1]):
|
||||
return compile_linear(*args, **kwargs)
|
||||
|
||||
with mock.patch.object(jit, "compile_linear", recording_compile_linear):
|
||||
@@ -38,6 +38,18 @@ def capture_frame(jit, kwargs: dict, output_container: str | None = None):
|
||||
return {"inputs": inputs, "outputs": [tensor.numpy().copy() for tensor in outputs], "output_container": output_container}, result
|
||||
|
||||
|
||||
def kernel_signature(ast, elf) -> list:
|
||||
from tinygrad.dtype import AddrSpace
|
||||
if hasattr(ast, "kernel_params"):
|
||||
if any(p.arg.addrspace is AddrSpace.ALU for p in ast.kernel_params):
|
||||
raise ValueError(f"{elf.name} takes a variable, which plain artifacts don't support")
|
||||
compact = {slot: i for i, slot in enumerate(ast.arg.globals)}
|
||||
return [[p.arg.name, compact[p.arg.slot], repr(p.dtype), [int(x) for x in p._shape]] for p in ast.kernel_params]
|
||||
if not all(isinstance(slot, int) for _, slot, _, _ in elf.signature):
|
||||
raise ValueError("TinyELF.signature lost its slots and this tinygrad has no UOp.kernel_params: update kernel_signature")
|
||||
return [[name, slot, repr(dtype), [int(x) for x in shape]] for name, slot, dtype, shape in elf.signature]
|
||||
|
||||
|
||||
def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict, memoryview]:
|
||||
from tinygrad.uop.ops import Ops
|
||||
sink = tempfile.TemporaryFile(dir=".")
|
||||
@@ -51,6 +63,12 @@ def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict,
|
||||
if uop.op is Ops.PARAM:
|
||||
input_specs.setdefault(uop.arg.slot, {"dtype": repr(uop.dtype), "numel": int(uop.max_numel()), "device": uop.device})
|
||||
return {"input": uop.arg.slot}
|
||||
root, offset = uop, 0
|
||||
while root.op in (Ops.AFTER, Ops.BITCAST, Ops.SHRINK):
|
||||
offset, root = offset + (root.src[1].arg * root.src[0].dtype.itemsize if root.op is Ops.SHRINK else 0), root.src[0]
|
||||
if root.op is Ops.ALLOC:
|
||||
base = bases.setdefault(id(root), (len(bases), root, root.dtype))
|
||||
return {"buffer": base[0], "offset": offset, "nbytes": int(uop.max_numel()) * uop.dtype.itemsize, "dtype": repr(uop.dtype)}
|
||||
buffer = uop.buffer
|
||||
root = buffer._base if buffer._base is not None else buffer
|
||||
base = bases.setdefault(id(root), (len(bases), root, uop.base.dtype if buffer._base is None else uop.dtype))
|
||||
@@ -62,8 +80,9 @@ def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict,
|
||||
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],
|
||||
device_target = dataclasses.asdict(dataclasses.replace(elf.target, interface=elf.target.interface.removeprefix("MOCK")))
|
||||
calls.append({"kind": "program", "name": elf.name, "lib": blob.add(bytes(elf.lib)), "target": device_target,
|
||||
"signature": kernel_signature(ast, elf),
|
||||
"global_size": [int(x) for x in global_size], "local_size": [int(x) for x in local_size],
|
||||
"globals": list(info.globals), "outs": list(info.outs), "ins": list(info.ins), "args": arguments})
|
||||
outs, ins = info.outs, info.ins
|
||||
@@ -77,7 +96,9 @@ def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict,
|
||||
|
||||
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)}
|
||||
from tinygrad.device import Buffer
|
||||
assert not [base for index, base, _ in bases.values() if index in needs_init and not isinstance(base, Buffer)], "a kernel reads a scratch before writing it"
|
||||
buffers = [{"nbytes": base.nbytes if isinstance(base, Buffer) else int(base.max_numel()) * dtype.itemsize, "device": base.device, "dtype": repr(dtype)}
|
||||
| ({"init": blob.add(base.as_memoryview())} if index in needs_init else {}) for index, base, dtype in bases.values()]
|
||||
names = list(jit.captured.expected_names)
|
||||
if check is not None:
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.sunnypilot.modeld_v2.blendful import Blendful
|
||||
from openpilot.sunnypilot.modeld_v2.constants import ModelConstants
|
||||
|
||||
X = np.array(ModelConstants.X_IDXS)
|
||||
BUILD, RELEASE = 0.5, 2.0
|
||||
|
||||
|
||||
def frame(center=0.0, plan=0.0, k=0.0, probs=(0.9, 0.9), stds=(0.1, 0.1), width=3.5):
|
||||
road = k * X ** 2 / 2
|
||||
lines = np.zeros((1, 4, 33, 2))
|
||||
lane_stds = np.ones((1, 4, 33, 2))
|
||||
for i, off in enumerate((-1.5 * width, -width / 2, width / 2, 1.5 * width)):
|
||||
lines[0, i, :, 0] = road + center + off
|
||||
lane_stds[0, 1, :, 0], lane_stds[0, 2, :, 0] = stds
|
||||
lane_probs = np.zeros((1, 8))
|
||||
lane_probs[0, 3], lane_probs[0, 5] = probs
|
||||
path = np.zeros((1, 33, 3))
|
||||
path[0, :, 0] = X
|
||||
path[0, :, 1] = road + plan * np.clip(X / 10, 0, 1)
|
||||
return {'lane_lines': lines, 'lane_lines_stds': lane_stds, 'lane_lines_prob': lane_probs, 'plan': path}
|
||||
|
||||
|
||||
def drive(bf, seconds, v=20.0, k=0.0, **kw):
|
||||
out = [bf.update(frame(k=k, **kw), v, True, k) for _ in range(int(seconds / DT_MDL))]
|
||||
return np.array(out) * v ** 2 # m/s^2
|
||||
|
||||
|
||||
class TestBlendful(OpenpilotTestCase):
|
||||
def setup_method(self):
|
||||
self.bf = Blendful()
|
||||
|
||||
def test_centered_adds_nothing(self):
|
||||
push = drive(self.bf, 5.0)
|
||||
assert self.bf.trust > 0.9
|
||||
assert np.abs(push).max() < 1e-3
|
||||
|
||||
def test_pulls_toward_lane_center(self):
|
||||
push = drive(self.bf, 5.0, center=0.5)
|
||||
assert push[-1] > 0.02 # + y side, where the lane center is
|
||||
assert self.bf.trust > 0.9
|
||||
assert np.all(np.diff(push) <= BUILD * DT_MDL + 1e-9)
|
||||
|
||||
def test_flipping_sides_builds_slowly(self):
|
||||
k = 0.002 # 0.8 m/s^2 at 20 m/s
|
||||
drive(self.bf, 5.0, k=k, center=0.5)
|
||||
push = drive(self.bf, 5.0, k=k, center=-0.5)
|
||||
rate = np.diff(push) / DT_MDL
|
||||
assert push[-1] < 0
|
||||
assert np.all(rate >= -RELEASE - 1e-6)
|
||||
assert np.all(rate[push[:-1] <= 0] >= -BUILD - 1e-6) # once past zero it builds
|
||||
crossing = push[1:][(push[:-1] > 0) & (push[1:] <= 0)]
|
||||
assert len(crossing) and np.all(crossing >= -BUILD * DT_MDL - 1e-9)
|
||||
|
||||
def test_weak_lines_drive_laneless(self):
|
||||
clear = drive(Blendful(), 5.0, center=0.5)[-1]
|
||||
faint = Blendful()
|
||||
push = drive(faint, 5.0, center=0.5, probs=(0.1, 0.1))
|
||||
assert faint.trust < 0.25 and abs(push[-1]) < 0.25 * clear # faint lines = trust and push scale down
|
||||
blurry = Blendful()
|
||||
push = drive(blurry, 5.0, center=0.5, stds=(0.8, 0.8))
|
||||
assert blurry.trust == 0.0 and np.abs(push).max() < 1e-6 # lines too uncertain = laneless
|
||||
|
||||
def test_lone_line_holds_through_a_blink_in_a_curve(self):
|
||||
k, v = 1 / 20, 6.0
|
||||
drive(self.bf, 6.0, v=v, k=k, plan=0.4, probs=(0.9, 0.05))
|
||||
before = self.bf.trust
|
||||
drive(self.bf, 0.25, v=v, k=k, plan=0.4, probs=(0.1, 0.05), stds=(1.0, 0.1))
|
||||
bottom = self.bf.trust
|
||||
drive(self.bf, 2.0, v=v, k=k, plan=0.4, probs=(0.9, 0.05))
|
||||
assert before > 0.5
|
||||
assert bottom > 0.5 * before # keeps latched through flicker
|
||||
assert self.bf.trust > 0.85 * before
|
||||
|
||||
def test_yields_when_the_model_goes_around(self):
|
||||
drive(self.bf, 6.0)
|
||||
push = drive(self.bf, 3.0, plan=0.8)
|
||||
assert self.bf.going_around
|
||||
assert self.bf.trust < 0.05
|
||||
assert abs(push[-1]) < 0.01
|
||||
|
||||
def test_fit_does_not_grow_while_trust_falls(self):
|
||||
drive(self.bf, 5.0, center=0.3)
|
||||
for i in range(40):
|
||||
dk, trust = self.bf.dk, self.bf.trust
|
||||
p = 0.9 - 0.02 * i
|
||||
self.bf.update(frame(center=0.3, plan=-0.02 * i, probs=(p, p)), 20.0, True, 0.0)
|
||||
if self.bf.trust < trust:
|
||||
assert abs(self.bf.dk) <= abs(dk) + 1e-12
|
||||
|
||||
def test_toggle_off_returns_zero_and_resets(self):
|
||||
drive(self.bf, 5.0, center=0.5)
|
||||
assert self.bf.curvature != 0.0
|
||||
assert self.bf.update(frame(center=0.5), 20.0, True, 0.0, enabled=False) == 0.0
|
||||
assert self.bf.curvature == 0.0 and self.bf.trust == 0.0
|
||||
|
||||
def test_fades_below_min_speed(self):
|
||||
drive(self.bf, 5.0, v=10.0, center=0.5)
|
||||
start = self.bf.curvature
|
||||
out = np.array([self.bf.update(frame(center=0.5), 4.0, True, 0.0) for _ in range(100)])
|
||||
assert self.bf.trust == 0.0
|
||||
assert abs(out[0]) < abs(start)
|
||||
assert abs(out[-1]) < 1e-6
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
import numpy as np
|
||||
|
||||
os.environ.setdefault("CHECK_OOB", "0")
|
||||
os.environ.setdefault("GMMU", "0")
|
||||
if sys.platform == "darwin" and os.environ.get("DEV", "").startswith("MOCKUSB"): # the mock's VRAM is a memfd, which macOS lacks: an unlinked file is the same
|
||||
from tinygrad.runtime.autogen import libc
|
||||
|
||||
def _memfd_create(name, flags):
|
||||
fd, path = tempfile.mkstemp()
|
||||
os.unlink(path)
|
||||
return fd
|
||||
libc.memfd_create = _memfd_create
|
||||
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.sunnypilot.modeld_v2 import convert_upstream_pkl
|
||||
from openpilot.sunnypilot.modeld_v2.convert_upstream_pkl import _elf, convert
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import ALIGNMENT, PlainJit, load_model
|
||||
from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame
|
||||
|
||||
IMG, DESIRE, STATE, OUT = (6, 8, 8), (8,), (4, 16), (1, 32)
|
||||
|
||||
|
||||
class TestConvertUpstreamPkl(OpenpilotTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
if not os.environ.get("TINYGRAD_TESTS"):
|
||||
raise unittest.SkipTest("TINYGRAD_TESTS not set. skipping converter test")
|
||||
from tinygrad import Device, Tensor
|
||||
assert Device.DEFAULT == "AMD" and getattr(Device["AMD"], "is_usb", False), f"{Device.DEFAULT} is not an AMD device behind the USB bridge"
|
||||
assert (Tensor([1, 2, 3]) * 2).tolist() == [2, 4, 6]
|
||||
cls.tmp = tempfile.TemporaryDirectory()
|
||||
cls.rng = np.random.default_rng(0)
|
||||
cls.weights = Tensor(cls.rng.standard_normal((16, 32)).astype(np.float32)).realize()
|
||||
cls.bias = Tensor(cls.rng.standard_normal((1, 32)).astype(np.float32)).realize()
|
||||
cls.input_specs = {"input_imgs": (IMG, "uint8", Device.DEFAULT), "desire": (DESIRE, "float32", Device.DEFAULT),
|
||||
"features": (STATE, "float32", Device.DEFAULT)}
|
||||
cls.output_specs = {"outputs": (OUT, "float32", Device.DEFAULT), "next_features": (STATE, "float32", Device.DEFAULT)}
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.tmp.cleanup()
|
||||
super().tearDownClass()
|
||||
|
||||
def model(self, input_imgs, desire, features, output_buffers):
|
||||
from tinygrad import Tensor
|
||||
feat = features.mean(axis=0).reshape(1, 16) + input_imgs.float().mean() + desire.sum()
|
||||
Tensor.realize(output_buffers["outputs"].assign((feat @ self.weights + self.bias).relu()),
|
||||
output_buffers["next_features"].assign(features[1:].cat(feat * 2)))
|
||||
|
||||
def inputs(self, seed: int) -> dict:
|
||||
from examples.openpilot.helpers import allocate_inputs
|
||||
rng = np.random.default_rng(seed)
|
||||
|
||||
def initialize(arrays: dict):
|
||||
for value in arrays.values():
|
||||
value[...] = rng.standard_normal(value.shape) if value.dtype.kind == "f" else rng.integers(0, 256, value.shape)
|
||||
return allocate_inputs(self.input_specs, initialize) | {"output_buffers": allocate_inputs(self.output_specs)}
|
||||
|
||||
def jit(self):
|
||||
from tinygrad import TinyJit
|
||||
run = TinyJit(self.model, prune=True)
|
||||
kwargs = self.inputs(1)
|
||||
run(**kwargs)
|
||||
run(**kwargs)
|
||||
return run
|
||||
|
||||
def assert_matches_eager(self, plain, seed: int):
|
||||
fresh, expected = self.inputs(seed), self.inputs(seed)
|
||||
plain(**fresh)
|
||||
self.model(**expected)
|
||||
for name in self.output_specs:
|
||||
assert np.array_equal(fresh["output_buffers"][name].numpy(), expected["output_buffers"][name].numpy()), name
|
||||
|
||||
def convert(self, jit) -> tuple[dict, memoryview]:
|
||||
from tinygrad import Device
|
||||
kwargs = self.inputs(2)
|
||||
frame, _ = capture_frame(jit, kwargs, "output_buffers")
|
||||
header, blob = convert(jit, kwargs, frame, Device[Device.DEFAULT].renderer.target)
|
||||
kernels = jit.captured._linear.src[0].without_after.arg.aux.kernels
|
||||
assert [call["name"] for call in header["calls"]] == [re.sub(r"\x1b\[[0-9;]*m", "", kernel[1]) for kernel in kernels]
|
||||
assert all(re.fullmatch(r"\w+", call["name"]) for call in header["calls"]), "kernel names: failure point 1"
|
||||
assert [(tuple(call["outs"]), tuple(call["ins"])) for call in header["calls"]] == [tuple(kernel[6]) for kernel in kernels], "outs/ins: failure point 1"
|
||||
bound = {argument["input"] for call in header["calls"] for argument in call["args"] if "input" in argument}
|
||||
assert bound == {spec["slot"] for spec in header["inputs"]}, f"input slots bound by pokes {sorted(bound)}: failure point 4"
|
||||
assert [spec["name"] for spec in header["inputs"]][:len(self.input_specs)] == sorted(self.input_specs)
|
||||
return header, blob
|
||||
|
||||
def test_converted_jit_matches_eager(self): # the submit decoded into plain calls, replayed against its own frame
|
||||
jit = self.jit()
|
||||
plain = PlainJit(*self.convert(jit))
|
||||
self.assert_matches_eager(plain, 3)
|
||||
self.assert_matches_eager(plain, 4)
|
||||
|
||||
def test_pickle_round_trip_through_main(self):
|
||||
from examples.openpilot.helpers import dump_pickle, load_pickle
|
||||
from tinygrad.engine.realize import lower_and_compile
|
||||
pkl, out = os.path.join(self.tmp.name, "tiny_tinygrad.pkl"), os.path.join(self.tmp.name, "tiny_plain.pkl")
|
||||
dump_pickle({"run": self.jit(), "input_specs": self.input_specs, "output_specs": self.output_specs, "metadata": {"name": "tiny"}}, pkl)
|
||||
|
||||
artifact = load_pickle(pkl, out_of_band=True)
|
||||
artifact["run"].captured._linear = lower_and_compile(artifact["run"].captured._linear)
|
||||
self.assert_matches_eager(PlainJit(*self.convert(artifact["run"])), 5)
|
||||
|
||||
with mock.patch.object(sys, "argv", ["convert_upstream_pkl", pkl, out]):
|
||||
convert_upstream_pkl.main()
|
||||
bundle = load_model(out, cache=False, verify=True)
|
||||
assert bundle["metadata"] == {"name": "tiny"} and bundle["input_specs"] == self.input_specs and bundle["output_specs"] == self.output_specs
|
||||
self.assert_matches_eager(bundle["run"], 6)
|
||||
|
||||
|
||||
class TestKernelElf(OpenpilotTestCase):
|
||||
def test_sections_follow_the_code(self):
|
||||
from tinygrad.runtime.support.elf import elf_loader
|
||||
code = bytes(range(1, 129))
|
||||
for rodata, entry in ((len(code), 0), (0, 256)):
|
||||
image = bytearray(code + bytes(64) if entry < rodata else bytes(256) + code)
|
||||
struct.pack_into("<q", image, rodata + 16, entry - rodata)
|
||||
loaded, sections, _ = elf_loader(_elf(bytes(image), rodata, entry))
|
||||
headers = {section.name: section.header for section in sections}
|
||||
assert bytes(loaded[ALIGNMENT:]) == bytes(image), "the uploaded program differs from the pickle's"
|
||||
assert headers[".rodata"].sh_addr == ALIGNMENT + rodata, "the loader reads the kernel descriptor at .rodata"
|
||||
text = headers[".text"]
|
||||
assert text.sh_addr <= ALIGNMENT + entry < text.sh_addr + text.sh_size, "the amd mock sizes the code from the entry to the end of .text"
|
||||
@@ -17,4 +17,4 @@ class TestRegistryModels(unittest.TestCase):
|
||||
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)
|
||||
assert "metadata" in bundle and jits and all(len(jit.captured._linear.src) > 0 for jit in jits)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import dataclasses
|
||||
import functools
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
import numpy as np
|
||||
import unittest
|
||||
|
||||
import openpilot.sunnypilot.modeld_v2.helpers as helpers
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.sunnypilot.modeld_v2 import compile_warp
|
||||
from openpilot.sunnypilot.modeld_v2.frame_fusion import frame_part, pack_layout
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, cache_is_fresh, load_model, read_parts, write_bundle
|
||||
from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit
|
||||
|
||||
CAM_W, CAM_H, MODEL_W, MODEL_H = 32, 16, 16, 8
|
||||
FRAME = f"{CAM_W},{CAM_H},{CAM_W},{CAM_H},{CAM_H // 2},{CAM_W * CAM_H * 3 // 2}"
|
||||
FRAME_SIZE = CAM_W * CAM_H * 3 // 2
|
||||
HOST = {"desire": (8,), "traffic_convention": (2,)}
|
||||
STATE, OUT = (4, 16), (1, 32)
|
||||
|
||||
|
||||
def compile_jit(fn, inputs: dict, output_container: str | None = None) -> tuple[dict, memoryview]:
|
||||
from tinygrad import TinyJit
|
||||
run = TinyJit(fn, prune=True)
|
||||
with record_kernel_linear() as recorded:
|
||||
run(**inputs)
|
||||
run(**inputs)
|
||||
check, _ = capture_frame(run, inputs, output_container)
|
||||
assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}"
|
||||
return serialize_jit(run, recorded[0], check)
|
||||
|
||||
|
||||
class TestPlainArtifact(OpenpilotTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
if not os.environ.get("TINYGRAD_TESTS"):
|
||||
raise unittest.SkipTest("TINYGRAD_TESTS env var not set, skipping artifact test.")
|
||||
from tinygrad import Tensor
|
||||
cls.tmp = tempfile.TemporaryDirectory()
|
||||
cls.rng = np.random.default_rng(0)
|
||||
cls.weights = Tensor(cls.rng.standard_normal((16, 32)).astype(np.float32)).realize()
|
||||
cls.bias = Tensor(cls.rng.standard_normal((1, 32)).astype(np.float32)).realize()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.tmp.cleanup()
|
||||
super().tearDownClass()
|
||||
|
||||
def model(self, x, h): # a two input, one weight model: the shape of a policy without the size
|
||||
return ((x @ self.weights + self.bias).relu() + h.mean()).contiguous()
|
||||
|
||||
def tensors(self, seed: int):
|
||||
from tinygrad import Tensor
|
||||
rng = np.random.default_rng(seed)
|
||||
return {"x": Tensor(rng.standard_normal((1, 16)).astype(np.float32)).realize(), "h": Tensor(rng.standard_normal((4, 16)).astype(np.float32)).realize()}
|
||||
|
||||
def artifact(self, name: str = "model") -> str:
|
||||
path = os.path.join(self.tmp.name, f"{name}_tinygrad.pkl")
|
||||
if not os.path.exists(path):
|
||||
write_bundle(path, {"run": compile_jit(self.model, self.tensors(1))}, {"metadata": {"name": name}})
|
||||
return path
|
||||
|
||||
def test_round_trip_matches_jit(self): # compiler -> bundle -> PlainJit, replay verified, then fresh inputs bit exact with eager tinygrad
|
||||
bundle = load_model(self.artifact(), cache=False, verify=True)
|
||||
assert bundle["metadata"] == {"name": "model"} and isinstance(bundle["run"], PlainJit)
|
||||
fresh = self.tensors(2)
|
||||
assert np.array_equal(bundle["run"](**fresh).numpy(), self.model(**fresh).numpy())
|
||||
|
||||
def test_replay_check_catches_a_changed_kernel(self):
|
||||
parts, extras = read_parts(self.artifact())
|
||||
header, blob = parts["run"]
|
||||
poisoned = dict(header, check=dict(header["check"], outputs=[header["check"]["inputs"][0][1]]))
|
||||
with self.assertRaises(helpers.ArtifactMismatch):
|
||||
PlainJit(poisoned, blob)
|
||||
|
||||
def test_cache_round_trip_holds_no_weights_and_is_rebuilt_when_stale(self):
|
||||
path = self.artifact("cached")
|
||||
expected = load_model(path, cache=False, verify=False)["run"](**self.tensors(3)).numpy()
|
||||
load_model(path)
|
||||
assert cache_is_fresh(path)
|
||||
with open(f"{path}.cache", "rb") as file:
|
||||
cache = file.read()
|
||||
assert self.weights.numpy().tobytes() not in cache and len(cache) < os.path.getsize(path)
|
||||
with mock.patch.object(helpers, "PlainJit", side_effect=AssertionError("a fresh cache must not rebuild")):
|
||||
cached = load_model(path)
|
||||
assert np.array_equal(cached["run"](**self.tensors(3)).numpy(), expected)
|
||||
with mock.patch.object(helpers, "tinygrad_identity", return_value="another tinygrad"):
|
||||
assert not cache_is_fresh(path)
|
||||
assert isinstance(load_model(path)["run"], PlainJit)
|
||||
assert cache_is_fresh(path)
|
||||
assert not cache_is_fresh(path) # keyed on the tinygrad that wrote it, so the next boot after a bump rebuilds once
|
||||
os.utime(path)
|
||||
with mock.patch.object(helpers, "tinygrad_identity", return_value="another tinygrad"):
|
||||
assert not cache_is_fresh(path)
|
||||
|
||||
def test_fused_with_warp_matches_warp_then_model(self):
|
||||
from tinygrad import Tensor
|
||||
warp_path = os.path.join(self.tmp.name, "warp_tinygrad.pkl")
|
||||
with mock.patch.object(sys, "argv", ["compile_warp", "--frame", FRAME, "--warp-to", f"{MODEL_W}x{MODEL_H}", "--output", warp_path]):
|
||||
compile_warp.main()
|
||||
warp = load_model(warp_path, cache=False)["run"]
|
||||
|
||||
def native(new_img, desire, traffic_convention, state_feat_q, output_buffers):
|
||||
feat = state_feat_q.mean(axis=0).reshape(1, 16) + new_img.float().mean() + desire.sum() + traffic_convention[0]
|
||||
Tensor.realize(output_buffers["outputs"].assign((feat @ self.weights + self.bias).relu()),
|
||||
output_buffers["next_state_feat_q"].assign(state_feat_q[1:].cat(feat * 2)))
|
||||
|
||||
def model_inputs(new_img):
|
||||
return {"new_img": new_img} | {name: Tensor(np.ones(shape, np.float32) * 0.5).realize() for name, shape in HOST.items()} | \
|
||||
{"state_feat_q": Tensor(self.rng.standard_normal(STATE).astype(np.float32)).realize(),
|
||||
"output_buffers": {"outputs": Tensor.zeros(OUT).contiguous().realize(), "next_state_feat_q": Tensor.zeros(STATE).contiguous().realize()}}
|
||||
frames = Tensor(self.rng.integers(0, 256, (2, FRAME_SIZE), dtype=np.uint8)).realize()
|
||||
tfm = Tensor(np.tile(np.eye(3, dtype=np.float32), (2, 1, 1))).realize()
|
||||
path = os.path.join(self.tmp.name, "native_tinygrad.pkl")
|
||||
part = compile_jit(native, model_inputs(warp(input_frame=frames, M_inv=tfm).realize()), "output_buffers")
|
||||
extras = {"input_specs": {"new_img": ((2, 6, MODEL_H // 2, MODEL_W // 2), "|u1", "CPU"), "state_feat_q": (STATE, "<f4", "CPU")}
|
||||
| {name: (shape, "<f4", "CPU") for name, shape in HOST.items()},
|
||||
"output_specs": {"outputs": (OUT, "<f4", "CPU"), "next_state_feat_q": (STATE, "<f4", "CPU")},
|
||||
"metadata": {"output_shapes": {"outputs": OUT, "next_state_feat_q": STATE}}}
|
||||
write_bundle(path, {"run": part}, extras)
|
||||
|
||||
fused = load_model(path, cache=False, extend=functools.partial(frame_part, warp_path))
|
||||
assert fused["run"].input_names == ["host", "state_feat_q"]
|
||||
offsets, packed = pack_layout(HOST, FRAME_SIZE)
|
||||
host = np.zeros(packed, np.uint8)
|
||||
inputs = model_inputs(None)
|
||||
for name, shape in ({"tfm": (2, 3, 3)} | HOST).items():
|
||||
np.ndarray(shape, np.float32, host, offsets[name])[...] = tfm.numpy() if name == "tfm" else inputs[name].numpy()
|
||||
host[offsets["frames"]:] = frames.numpy().ravel()
|
||||
fused["run"](host=Tensor(host, device="NPY"), state_feat_q=inputs["state_feat_q"], output_buffers=inputs["output_buffers"])
|
||||
|
||||
expected = model_inputs(warp(input_frame=frames, M_inv=tfm).realize()) | {k: inputs[k] for k in ("state_feat_q",)}
|
||||
native(**expected)
|
||||
for name in ("outputs", "next_state_feat_q"):
|
||||
assert np.array_equal(inputs["output_buffers"][name].numpy(), expected["output_buffers"][name].numpy()), name
|
||||
|
||||
def test_tinygrad_api_the_loader_reads(self):
|
||||
import tinygrad.engine.jit as jit
|
||||
from tinygrad.device import Buffer, TinyELF
|
||||
from tinygrad.helpers import Target
|
||||
from tinygrad.uop.ops import ParamArg, ProgramInfo, UOp
|
||||
fields = {cls: {f.name for f in dataclasses.fields(cls)} for cls in (ProgramInfo, ParamArg, Target)}
|
||||
assert fields[ProgramInfo] >= {"global_size", "local_size", "globals", "outs", "ins", "target"}, "ProgramInfo changed: update helpers._program_uop"
|
||||
assert fields[ParamArg] >= {"slot", "dtype", "name", "image"}, "ParamArg changed: update helpers._program_uop"
|
||||
assert fields[Target] >= {"device", "renderer", "arch"}, "Target changed: update the 'target' key in plain_artifact_compiler.serialize_jit"
|
||||
assert "signature" in getattr(TinyELF, "__annotations__", {}) or hasattr(UOp, "kernel_params"), \
|
||||
"kernel signatures moved: update plain_artifact_compiler.serialize_jit"
|
||||
assert callable(getattr(UOp, "param", None)) and callable(getattr(UOp, "from_buffer", None)), "UOp.param/from_buffer: update helpers.PlainJit"
|
||||
assert callable(getattr(UOp, "store_call", None)) and callable(getattr(UOp, "call", None)), "UOp.call/store_call: update helpers.PlainJit"
|
||||
assert callable(getattr(Buffer, "view", None)) and callable(getattr(Buffer, "ensure_allocated", None)), "Buffer.view: update helpers._upload"
|
||||
assert callable(getattr(jit, "compile_linear", None)) and callable(getattr(jit, "_prepare_jit_inputs", None)), "engine.jit: update helpers._Jit"
|
||||
assert "expected_names" in inspect.signature(jit.CapturedJit).parameters, "CapturedJit: update helpers.PlainJit and plain_artifact_compiler.serialize_jit"
|
||||
@@ -7,7 +7,10 @@ See the LICENSE.md file in the root directory for more details.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import requests
|
||||
from openpilot.common.params import Params
|
||||
@@ -22,12 +25,101 @@ from openpilot.sunnypilot.models.helpers import (ACTIVE_BUNDLE_KEYS, get_active_
|
||||
|
||||
# (connect, read) seconds. read is per-request inactivity, not a total cap
|
||||
DOWNLOAD_TIMEOUT = (30, 30)
|
||||
# HuggingFace throttles each connection to ~1-2 MB/s but not the connection count; 12 saturates the device link
|
||||
MAX_CONCURRENT_PIECES = 12
|
||||
PIECE_SIZE = 8 * 1024 * 1024
|
||||
PIECE_ATTEMPTS = 3
|
||||
PIECE_BACKOFF = 1.0 # seconds, growing linearly: waits 1 s then 2 s between a piece's three attempts
|
||||
TRANSIENT_HTTP_STATUS = frozenset({408, 429, 500, 502, 503, 504})
|
||||
REPORT_INTERVAL = 0.5 # seconds between progress publications
|
||||
# byte ranges count encoded bytes but iter_content yields decoded ones, so ask for an unencoded body
|
||||
NO_COMPRESSION = {"Accept-Encoding": "identity"}
|
||||
SPEED_SMOOTHING = 0.7 # weight of the previous speed sample in the published speed
|
||||
SPEED_FLOOR = 0.05 * 1024 ** 2 # bytes/s; anything slower would only display as 0.0 MB/s, so it reads as no rate
|
||||
|
||||
|
||||
class DownloadCancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _is_transient(e: BaseException) -> bool:
|
||||
if isinstance(e, requests.HTTPError):
|
||||
return e.response is not None and e.response.status_code in TRANSIENT_HTTP_STATUS
|
||||
return isinstance(e, (requests.ConnectionError, requests.Timeout, requests.exceptions.ChunkedEncodingError))
|
||||
|
||||
|
||||
class _Progress:
|
||||
def __init__(self):
|
||||
self._bytes = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def add_bytes(self, n: int) -> None:
|
||||
with self._lock:
|
||||
self._bytes += n
|
||||
|
||||
def snapshot(self) -> int:
|
||||
with self._lock:
|
||||
return self._bytes
|
||||
|
||||
|
||||
def _piece_ranges(total: int, piece_size: int) -> list[tuple[int, int]]:
|
||||
return [(start, min(start + piece_size, total)) for start in range(0, total, piece_size)]
|
||||
|
||||
|
||||
def _prepare_target(path: str, total: int) -> None:
|
||||
with open(path, "wb") as f:
|
||||
f.truncate(total)
|
||||
|
||||
|
||||
def _content_range_total(response: requests.Response) -> int | None:
|
||||
if response.status_code != 206:
|
||||
return None
|
||||
match = re.fullmatch(r"bytes \d+-\d+/(\d+)", response.headers.get("Content-Range", ""))
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _probe_size(url: str) -> tuple[int, bool]:
|
||||
with requests.get(url, headers={**NO_COMPRESSION, "Range": "bytes=0-0"}, stream=True, timeout=DOWNLOAD_TIMEOUT) as response:
|
||||
response.raise_for_status()
|
||||
total = _content_range_total(response)
|
||||
if total is not None:
|
||||
return total, True
|
||||
return int(response.headers.get("Content-Length", 0)), False
|
||||
|
||||
|
||||
def _fetch_piece(url: str, path: str, index: int, start: int, end: int | None, progress: _Progress,
|
||||
cancel: threading.Event, block_size: int) -> None:
|
||||
headers = {**NO_COMPRESSION, "Range": f"bytes={start}-{end - 1}"} if end is not None else NO_COMPRESSION
|
||||
label = f"{os.path.basename(path)} piece {index}"
|
||||
for attempt in range(PIECE_ATTEMPTS):
|
||||
written = 0
|
||||
try:
|
||||
with requests.get(url, headers=headers, stream=True, timeout=DOWNLOAD_TIMEOUT) as response:
|
||||
response.raise_for_status()
|
||||
if end is not None and response.status_code != 206:
|
||||
raise ValueError(f"server ignored byte range for {label}")
|
||||
with open(path, "r+b") as f:
|
||||
f.seek(start)
|
||||
for block in response.iter_content(chunk_size=block_size):
|
||||
if cancel.is_set():
|
||||
raise DownloadCancelled("Download cancelled")
|
||||
f.write(block)
|
||||
written += len(block)
|
||||
progress.add_bytes(len(block))
|
||||
if end is None:
|
||||
f.truncate() # unknown length: the body defines the file size
|
||||
if end is not None and written != end - start:
|
||||
raise requests.exceptions.ConnectionError(f"short read: {written} of {end - start} bytes")
|
||||
return
|
||||
except requests.RequestException as e:
|
||||
progress.add_bytes(-written) # the piece restarts from scratch
|
||||
if not _is_transient(e) or attempt == PIECE_ATTEMPTS - 1:
|
||||
raise
|
||||
cloudlog.warning(f"retrying {label} after {type(e).__name__}: {e}")
|
||||
if cancel.wait(PIECE_BACKOFF * (1 + attempt)):
|
||||
raise DownloadCancelled("Download cancelled") from None
|
||||
|
||||
|
||||
class ModelManagerSP:
|
||||
"""Manages model downloads and status reporting"""
|
||||
|
||||
@@ -41,7 +133,7 @@ class ModelManagerSP:
|
||||
self.source_models: dict[str, list[custom.ModelManagerSP.ModelBundle]] = {}
|
||||
self.selected_bundle: custom.ModelManagerSP.ModelBundle = None
|
||||
self.active_bundle: custom.ModelManagerSP.ModelBundle = get_active_bundle(self.params, chestnut=self.chestnut_present)
|
||||
self._chunk_size = 128 * 1000 # 128 KB chunks
|
||||
self._block_size = 128 * 1000 # 128 KB network read blocks
|
||||
self._download_start_times: dict[str, float] = {} # Track start time per model
|
||||
self._download_ref: bytes | str | None = None
|
||||
|
||||
@@ -56,7 +148,6 @@ class ModelManagerSP:
|
||||
self._download_ref = None
|
||||
|
||||
def _sync_artifact_progress(self, source_artifact) -> None:
|
||||
"""Mirror download progress to all artifacts sharing the same filename in the selected bundle."""
|
||||
if not self.selected_bundle:
|
||||
return
|
||||
for model in self.selected_bundle.models:
|
||||
@@ -65,9 +156,9 @@ class ModelManagerSP:
|
||||
artifact.downloadProgress.status = source_artifact.downloadProgress.status
|
||||
artifact.downloadProgress.progress = source_artifact.downloadProgress.progress
|
||||
artifact.downloadProgress.eta = source_artifact.downloadProgress.eta
|
||||
artifact.downloadProgress.speed = source_artifact.downloadProgress.speed
|
||||
|
||||
def _calculate_eta(self, filename: str, progress: float) -> int:
|
||||
"""Calculate ETA based on elapsed time and current progress"""
|
||||
if filename not in self._download_start_times or progress <= 0:
|
||||
return 60 # Default ETA for new downloads
|
||||
|
||||
@@ -75,39 +166,74 @@ class ModelManagerSP:
|
||||
if elapsed_time <= 0:
|
||||
return 60
|
||||
|
||||
# If we're at X% after Y seconds, we can estimate total time as (Y / X) * 100
|
||||
total_estimated_time = (elapsed_time / progress) * 100
|
||||
eta = total_estimated_time - elapsed_time
|
||||
|
||||
return max(1, int(eta)) # Return at least 1 second if download is ongoing
|
||||
|
||||
async def _download_file(self, url: str, path: str, model) -> None:
|
||||
"""Downloads a file with progress tracking"""
|
||||
self._download_start_times[model.fileName] = time.monotonic()
|
||||
def _set_progress(self, artifact, status, progress: float, eta: int = 0, speed: float = 0.0) -> None:
|
||||
artifact.downloadProgress.status = status
|
||||
artifact.downloadProgress.progress = progress
|
||||
artifact.downloadProgress.eta = eta
|
||||
artifact.downloadProgress.speed = speed
|
||||
self._sync_artifact_progress(artifact)
|
||||
self._report_status()
|
||||
|
||||
with requests.get(url, stream=True, timeout=DOWNLOAD_TIMEOUT) as response: # noqa: ASYNC210
|
||||
response.raise_for_status()
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
bytes_downloaded = 0
|
||||
def _publish_progress(self, artifact, done_bytes: int, total: int, speed: float) -> None:
|
||||
# held at 99 until the file passes its hash check
|
||||
progress = min(99.0, done_bytes / total * 100) if total > 0 else 0.0
|
||||
if speed > 0 and total > 0:
|
||||
eta = max(1, int((total - done_bytes) / speed))
|
||||
else:
|
||||
eta = self._calculate_eta(artifact.fileName, progress)
|
||||
self._set_progress(artifact, custom.ModelManagerSP.DownloadStatus.downloading, progress, eta, speed)
|
||||
|
||||
with open(path, 'wb') as f: # noqa: ASYNC230
|
||||
for chunk in response.iter_content(chunk_size=self._chunk_size): # type: bytes
|
||||
f.write(chunk)
|
||||
bytes_downloaded += len(chunk)
|
||||
async def _report_until_done(self, tasks: list[asyncio.Future], artifact, progress: _Progress, total: int,
|
||||
started: tuple[int, float]) -> None:
|
||||
pending = set(tasks)
|
||||
last_bytes, last_time = started
|
||||
speed = 0.0
|
||||
while pending:
|
||||
done, pending = await asyncio.wait(pending, timeout=REPORT_INTERVAL)
|
||||
for task in done:
|
||||
task.result() # re-raises a worker failure
|
||||
if self._download_interrupted():
|
||||
raise DownloadCancelled("Download cancelled")
|
||||
now = time.monotonic()
|
||||
done_bytes = progress.snapshot()
|
||||
if (dt := now - last_time) > 0:
|
||||
instant = max(0.0, (done_bytes - last_bytes) / dt) # bytes/s this tick; clamped since retried pieces can lower the byte count
|
||||
speed = instant if speed == 0 else SPEED_SMOOTHING * speed + (1 - SPEED_SMOOTHING) * instant
|
||||
if speed < SPEED_FLOOR: # a stall decays toward 0 without reaching it, and a near-0 speed overflows the eta
|
||||
speed = 0.0
|
||||
last_time, last_bytes = now, done_bytes
|
||||
self._publish_progress(artifact, done_bytes, total, speed)
|
||||
|
||||
if self._download_interrupted():
|
||||
raise DownloadCancelled("Download cancelled")
|
||||
async def _download_file(self, url: str, path: str, artifact) -> None:
|
||||
self._download_start_times[artifact.fileName] = time.monotonic()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
if total_size > 0:
|
||||
progress = (bytes_downloaded / total_size) * 100
|
||||
model.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading
|
||||
model.downloadProgress.progress = progress
|
||||
model.downloadProgress.eta = self._calculate_eta(model.fileName, progress)
|
||||
self._sync_artifact_progress(model)
|
||||
self._report_status()
|
||||
total, ranged = await loop.run_in_executor(None, _probe_size, url)
|
||||
pieces: list[tuple[int, int | None]] = list(_piece_ranges(total, PIECE_SIZE)) if ranged else [(0, None)]
|
||||
await loop.run_in_executor(None, _prepare_target, path, total)
|
||||
progress = _Progress()
|
||||
cancel = threading.Event()
|
||||
started = progress.snapshot(), time.monotonic() # before any worker can land bytes
|
||||
|
||||
# Clean up start time after download completes
|
||||
del self._download_start_times[model.fileName]
|
||||
with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_PIECES) as pool:
|
||||
tasks = [loop.run_in_executor(pool, _fetch_piece, url, path, i, start, end, progress, cancel, self._block_size)
|
||||
for i, (start, end) in enumerate(pieces)]
|
||||
try:
|
||||
await self._report_until_done(tasks, artifact, progress, total, started)
|
||||
except BaseException as e:
|
||||
cancel.set()
|
||||
if isinstance(e, DownloadCancelled):
|
||||
artifact.downloadProgress.canceling = True
|
||||
self._set_progress(artifact, custom.ModelManagerSP.DownloadStatus.downloading, artifact.downloadProgress.progress)
|
||||
for task in tasks:
|
||||
task.cancel() # drops queued pieces; running ones see the event at their next block
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
raise
|
||||
del self._download_start_times[artifact.fileName]
|
||||
|
||||
async def _process_artifact(self, artifact, destination_path: str) -> None:
|
||||
if not artifact.downloadUri.uri:
|
||||
@@ -119,33 +245,31 @@ class ModelManagerSP:
|
||||
expected_hash = artifact.downloadUri.sha256
|
||||
filename = artifact.fileName
|
||||
full_path = os.path.join(destination_path, filename)
|
||||
status = custom.ModelManagerSP.DownloadStatus
|
||||
artifact.downloadProgress.canceling = False
|
||||
|
||||
try:
|
||||
self._set_progress(artifact, status.verifying, 0)
|
||||
if await verify_file(full_path, expected_hash):
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached
|
||||
artifact.downloadProgress.progress = 100
|
||||
artifact.downloadProgress.eta = 0
|
||||
self._sync_artifact_progress(artifact)
|
||||
self._report_status()
|
||||
self._set_progress(artifact, status.cached, 100)
|
||||
return
|
||||
|
||||
self._set_progress(artifact, status.downloading, 0)
|
||||
await self._download_file(url, full_path, artifact)
|
||||
self._set_progress(artifact, status.verifying, 99)
|
||||
if not await verify_file(full_path, expected_hash):
|
||||
raise ValueError(f"Hash validation failed for {filename}")
|
||||
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloaded
|
||||
artifact.downloadProgress.progress = 100
|
||||
artifact.downloadProgress.eta = 0
|
||||
self._sync_artifact_progress(artifact)
|
||||
self._report_status()
|
||||
self._set_progress(artifact, status.downloaded, 100)
|
||||
|
||||
except DownloadCancelled:
|
||||
self._download_start_times.pop(artifact.fileName, None)
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed
|
||||
artifact.downloadProgress.status = status.failed
|
||||
artifact.downloadProgress.eta = 0
|
||||
artifact.downloadProgress.speed = 0
|
||||
self._sync_artifact_progress(artifact)
|
||||
if self.selected_bundle:
|
||||
self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed
|
||||
self.selected_bundle.status = status.failed
|
||||
self._report_status()
|
||||
raise
|
||||
|
||||
@@ -153,11 +277,12 @@ class ModelManagerSP:
|
||||
cloudlog.error(f"Error downloading {filename}: {str(e)}")
|
||||
if os.path.isfile(full_path): # noqa: ASYNC240
|
||||
os.remove(full_path)
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed
|
||||
artifact.downloadProgress.status = status.failed
|
||||
artifact.downloadProgress.eta = 0
|
||||
artifact.downloadProgress.speed = 0
|
||||
self._sync_artifact_progress(artifact)
|
||||
if self.selected_bundle:
|
||||
self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed
|
||||
self.selected_bundle.status = status.failed
|
||||
self._report_status()
|
||||
self._download_start_times.pop(artifact.fileName, None)
|
||||
raise
|
||||
@@ -197,6 +322,7 @@ class ModelManagerSP:
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached
|
||||
artifact.downloadProgress.progress = 100
|
||||
artifact.downloadProgress.eta = 0
|
||||
artifact.downloadProgress.speed = 0
|
||||
else:
|
||||
seen_artifacts.add(artifact.fileName)
|
||||
await self._process_artifact(artifact, destination_path)
|
||||
@@ -272,12 +398,7 @@ class ModelManagerSP:
|
||||
rk.keep_time()
|
||||
|
||||
def clear_model_cache(self) -> None:
|
||||
"""
|
||||
Clears the model cache directory of all files except those in the active model bundle.
|
||||
"""
|
||||
|
||||
# Get list of files used by both slots' selected bundles (either may become
|
||||
# the truly active bundle depending on hardware availability)
|
||||
""" Clears the model cache directory of all files except those in the active model bundle."""
|
||||
active_files = []
|
||||
for source in ACTIVE_BUNDLE_KEYS:
|
||||
if selected_bundle := get_selected_bundle(self.params, source):
|
||||
@@ -285,7 +406,6 @@ class ModelManagerSP:
|
||||
if model.artifact.fileName:
|
||||
active_files.append(model.artifact.fileName)
|
||||
|
||||
# Remove all files except active ones
|
||||
model_dir = Paths.model_root()
|
||||
try:
|
||||
for filename in os.listdir(model_dir):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -126,6 +126,7 @@ def initialize_params(params) -> list[dict[str, Any]]:
|
||||
keys.extend([
|
||||
"ToyotaEnforceStockLongitudinal",
|
||||
"ToyotaStopAndGoHack",
|
||||
"ToyotaTSS2Smooth",
|
||||
])
|
||||
|
||||
return [{k: params.get(k, return_default=True)} for k in keys]
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Copyright (c) 2021-, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from openpilot.cereal import log
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD
|
||||
|
||||
ACCEL_BOOST_MIN_SPEED = 1.0
|
||||
ACCEL_BOOST_MAX = (0.4, 0.25, 0.1) # aggressive, standard, relaxed, m/s^2
|
||||
ACCEL_BOOST_BUILD, ACCEL_BOOST_RELEASE = 0.2, 1.0 # m/s^3
|
||||
BRAKE_PROB = (0.1, 0.3) # the model's brake press in 4 s: it wants to slow
|
||||
GAS_PROB = (0.6, 0.8) # predicts a human gas press at this hypothesis at about 23%
|
||||
CURVE_LAT_ACCEL = (1.5, 2.5)
|
||||
|
||||
|
||||
def get_boost_max(personality=log.LongitudinalPersonality.standard):
|
||||
if personality == log.LongitudinalPersonality.relaxed:
|
||||
return ACCEL_BOOST_MAX[2]
|
||||
elif personality == log.LongitudinalPersonality.standard:
|
||||
return ACCEL_BOOST_MAX[1]
|
||||
elif personality == log.LongitudinalPersonality.aggressive:
|
||||
return ACCEL_BOOST_MAX[0]
|
||||
else:
|
||||
raise NotImplementedError("Longitudinal personality not supported")
|
||||
|
||||
|
||||
class AccelBoostSP:
|
||||
def __init__(self):
|
||||
self.params = Params()
|
||||
self.frame = -1
|
||||
self.enabled = False
|
||||
self.boost = 0.0
|
||||
self.total_boost = 0.0
|
||||
|
||||
def update_params(self):
|
||||
self.frame += 1
|
||||
if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) != 0:
|
||||
return False
|
||||
enabled = self.params.get_bool("AccelBoostSP")
|
||||
if enabled == self.enabled:
|
||||
return False
|
||||
self.enabled = enabled
|
||||
self.boost = self.total_boost = 0.0
|
||||
return True
|
||||
|
||||
def update(self, sm, output_a_target_e2e, output_a_target_mpc, a_cruise):
|
||||
model = sm['modelV2']
|
||||
brake_probs = model.meta.disengagePredictions.brakePressProbs
|
||||
gas_probs = model.meta.disengagePredictions.gasPressProbs
|
||||
if not sm['selfdriveState'].enabled or len(brake_probs) < 2 or len(gas_probs) < 2:
|
||||
self.boost = self.total_boost = 0.0
|
||||
return output_a_target_e2e
|
||||
|
||||
want = max(float(np.interp(brake_probs[1], BRAKE_PROB, [1.0, 0.0])), float(np.interp(gas_probs[1], GAS_PROB, [0.0, 1.0])))
|
||||
want = 0.0 if model.action.shouldStop else want
|
||||
want *= float(np.interp(abs(model.action.desiredCurvature) * sm['carState'].vEgo ** 2, CURVE_LAT_ACCEL, [1.0, 0.0]))
|
||||
target = want * get_boost_max(sm['selfdriveState'].personality)
|
||||
self.boost = float(np.clip(target, self.boost - ACCEL_BOOST_RELEASE * DT_MDL, self.boost + ACCEL_BOOST_BUILD * DT_MDL))
|
||||
speed_scale = np.interp(sm['carState'].vEgo, [ACCEL_BOOST_MIN_SPEED, 2 * ACCEL_BOOST_MIN_SPEED], [0.0, 1.0])
|
||||
self.total_boost = self.boost * speed_scale * np.interp(output_a_target_e2e, [-0.7, -0.2, 5.0], [0.0, 1.0, 1.0], right=0.0)
|
||||
return output_a_target_e2e + self.total_boost
|
||||
@@ -10,6 +10,8 @@ from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
|
||||
|
||||
|
||||
|
||||
class AutoLaneChangeMode:
|
||||
OFF = -1
|
||||
NUDGE = 0 # default
|
||||
@@ -31,6 +33,7 @@ AUTO_LANE_CHANGE_TIMER = {
|
||||
}
|
||||
|
||||
ONE_SECOND_DELAY = -1
|
||||
EDGE_DELAY = -2
|
||||
|
||||
|
||||
class AutoLaneChangeController:
|
||||
@@ -48,6 +51,7 @@ class AutoLaneChangeController:
|
||||
self.prev_brake_pressed = False
|
||||
self.auto_lane_change_allowed = False
|
||||
self.prev_lane_change = False
|
||||
self.lane_change_blocked = False
|
||||
|
||||
self.read_params()
|
||||
|
||||
@@ -68,7 +72,7 @@ class AutoLaneChangeController:
|
||||
self.read_params()
|
||||
self.param_read_counter += 1
|
||||
|
||||
def update_lane_change_timers(self, blindspot_detected: bool) -> None:
|
||||
def update_lane_change_timers(self, blindspot_detected: bool, edge_detected: bool) -> None:
|
||||
self.lane_change_delay = AUTO_LANE_CHANGE_TIMER.get(self.lane_change_set_timer,
|
||||
AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.NUDGE])
|
||||
|
||||
@@ -80,6 +84,12 @@ class AutoLaneChangeController:
|
||||
else:
|
||||
self.lane_change_wait_timer = self.lane_change_delay + ONE_SECOND_DELAY
|
||||
|
||||
if edge_detected and self.lane_change_delay > 0:
|
||||
if self.lane_change_delay == AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.NUDGELESS]:
|
||||
self.lane_change_wait_timer = min(self.lane_change_wait_timer, EDGE_DELAY)
|
||||
else:
|
||||
self.lane_change_wait_timer = min(self.lane_change_wait_timer, self.lane_change_delay + EDGE_DELAY)
|
||||
|
||||
def update_allowed(self) -> bool:
|
||||
# Auto lane change allowed if:
|
||||
# 1. A valid delay is set (non-zero)
|
||||
@@ -97,14 +107,23 @@ class AutoLaneChangeController:
|
||||
|
||||
return bool(self.lane_change_wait_timer > self.lane_change_delay)
|
||||
|
||||
def update_lane_change(self, blindspot_detected: bool, brake_pressed: bool) -> None:
|
||||
def update_lane_change(self, blindspot_detected: bool, edge_detected: bool, brake_pressed: bool,
|
||||
torque_applied: bool = False) -> None:
|
||||
if brake_pressed and not self.prev_brake_pressed:
|
||||
self.prev_brake_pressed = brake_pressed
|
||||
|
||||
self.update_lane_change_timers(blindspot_detected)
|
||||
self.update_lane_change_timers(blindspot_detected, edge_detected)
|
||||
|
||||
self.auto_lane_change_allowed = self.update_allowed()
|
||||
|
||||
# BSM always blocks. Edge blocks auto mode but nudge can override.
|
||||
if blindspot_detected:
|
||||
self.lane_change_blocked = True
|
||||
elif edge_detected:
|
||||
self.lane_change_blocked = not torque_applied
|
||||
else:
|
||||
self.lane_change_blocked = False
|
||||
|
||||
def update_state(self):
|
||||
if self.DH.lane_change_state == log.LaneChangeState.laneChangeStarting:
|
||||
self.prev_lane_change = True
|
||||
|
||||
@@ -9,6 +9,8 @@ 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.selfdrive.controls.lib.accel_boost import AccelBoost
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_boost import AccelBoostSP
|
||||
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
|
||||
@@ -29,6 +31,8 @@ class LongitudinalPlannerSP:
|
||||
self.generation = int(model_bundle.generation) if (model_bundle := get_active_bundle()) else None
|
||||
self.source = LongitudinalPlanSource.cruise
|
||||
self.e2e_alerts_helper = E2EAlertsHelper()
|
||||
self.accel_boost_sp = AccelBoostSP()
|
||||
self.openpilot_longitudinal = CP.openpilotLongitudinalControl
|
||||
|
||||
self.output_v_target = 0.
|
||||
self.output_a_target = 0.
|
||||
@@ -64,6 +68,9 @@ class LongitudinalPlannerSP:
|
||||
return self.output_v_target, self.output_a_target
|
||||
|
||||
def update(self, sm: messaging.SubMaster) -> None:
|
||||
if self.accel_boost_sp.update_params():
|
||||
self.accel_boost = self.accel_boost_sp if self.accel_boost_sp.enabled and self.openpilot_longitudinal else AccelBoost()
|
||||
|
||||
self.events_sp.clear()
|
||||
self.e2e_alerts_helper.update(sm, self.events_sp)
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Copyright (c) 2021-, 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 types import SimpleNamespace
|
||||
|
||||
from openpilot.common.parameterized import parameterized
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
|
||||
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_boost import AccelBoostSP, ACCEL_BOOST_MAX
|
||||
|
||||
|
||||
def make_sm(brake_prob=0.05, gas_prob=0.3, lat_accel=0.0, v_ego=30.0, personality=0, enabled=True, should_stop=False):
|
||||
meta = SimpleNamespace(disengagePredictions=SimpleNamespace(brakePressProbs=[0.0, brake_prob, 0.0, 0.0, 0.0, 0.0],
|
||||
gasPressProbs=[0.0, gas_prob, 0.0, 0.0, 0.0, 0.0]))
|
||||
action = SimpleNamespace(shouldStop=should_stop, desiredCurvature=lat_accel / v_ego ** 2)
|
||||
return {'modelV2': SimpleNamespace(meta=meta, action=action), 'carState': SimpleNamespace(vEgo=v_ego),
|
||||
'selfdriveState': SimpleNamespace(enabled=enabled, personality=personality)}
|
||||
|
||||
|
||||
def settle(sm, frames=100):
|
||||
boost = AccelBoostSP()
|
||||
for _ in range(frames):
|
||||
boost.update(sm, 0.0, 9.0, 9.0)
|
||||
return boost.total_boost
|
||||
|
||||
|
||||
class TestAccelBoostSP(OpenpilotTestCase):
|
||||
@parameterized.expand([(0,), (1,), (2,)])
|
||||
def test_personality_max(self, personality):
|
||||
assert abs(settle(make_sm(personality=personality)) - ACCEL_BOOST_MAX[personality]) < 1e-6
|
||||
|
||||
def test_brake_gate_closes(self):
|
||||
assert settle(make_sm(brake_prob=0.5)) == 0.0
|
||||
|
||||
def test_gas_reopens(self):
|
||||
assert abs(settle(make_sm(brake_prob=0.5, gas_prob=0.9)) - ACCEL_BOOST_MAX[0]) < 1e-6
|
||||
|
||||
def test_should_stop(self):
|
||||
assert settle(make_sm(gas_prob=0.9, should_stop=True)) == 0.0
|
||||
|
||||
@parameterized.expand([(1.5, 1.0), (2.0, 0.5), (2.5, 0.0), (-2.0, 0.5)])
|
||||
def test_curve_fade(self, lat_accel, scale):
|
||||
assert abs(settle(make_sm(lat_accel=lat_accel)) - scale * ACCEL_BOOST_MAX[0]) < 1e-6
|
||||
|
||||
def test_low_speed(self):
|
||||
assert settle(make_sm(v_ego=0.5)) == 0.0
|
||||
|
||||
def test_disabled_resets(self):
|
||||
boost = AccelBoostSP()
|
||||
for _ in range(100):
|
||||
boost.update(make_sm(), 0.0, 9.0, 9.0)
|
||||
assert boost.update(make_sm(enabled=False), 0.0, 9.0, 9.0) == 0.0
|
||||
assert boost.boost == 0.0 and boost.total_boost == 0.0
|
||||
|
||||
def test_build_and_release_rates(self):
|
||||
boost = AccelBoostSP()
|
||||
boost.update(make_sm(), 0.0, 9.0, 9.0)
|
||||
assert abs(boost.total_boost - 0.01) < 1e-6
|
||||
for _ in range(100):
|
||||
boost.update(make_sm(), 0.0, 9.0, 9.0)
|
||||
boost.update(make_sm(brake_prob=0.5), 0.0, 9.0, 9.0)
|
||||
assert abs(boost.total_boost - (ACCEL_BOOST_MAX[0] - 0.05)) < 1e-6
|
||||
|
||||
def test_param_switch(self):
|
||||
params = Params()
|
||||
boost = AccelBoostSP()
|
||||
params.put_bool("AccelBoostSP", True, block=True)
|
||||
assert boost.update_params() and boost.enabled
|
||||
for _ in range(100):
|
||||
boost.update(make_sm(), 0.0, 9.0, 9.0)
|
||||
params.put_bool("AccelBoostSP", False, block=True)
|
||||
for _ in range(int(PARAMS_UPDATE_PERIOD / DT_MDL) - 1):
|
||||
assert not boost.update_params()
|
||||
assert boost.update_params() and not boost.enabled
|
||||
assert boost.boost == 0.0 and boost.total_boost == 0.0
|
||||
@@ -8,7 +8,7 @@ from openpilot.common.parameterized import parameterized
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper, LaneChangeState, LaneChangeDirection
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeController, AutoLaneChangeMode, \
|
||||
AUTO_LANE_CHANGE_TIMER, ONE_SECOND_DELAY
|
||||
AUTO_LANE_CHANGE_TIMER, ONE_SECOND_DELAY, EDGE_DELAY
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
|
||||
AUTO_LANE_CHANGE_TIMER_COMBOS = [
|
||||
@@ -61,7 +61,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
# Update controller
|
||||
num_updates = int(5.0 / DT_MDL)
|
||||
for _ in range(num_updates): # Run for 5 seconds
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
|
||||
# Mode should not allow lane change immediately
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
@@ -74,11 +74,11 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
self.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGELESS
|
||||
|
||||
# Update controller once to read params
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
|
||||
# Update multiple times to exceed the timer threshold
|
||||
for _ in range(1): # Should exceed 0.1s with multiple DT_MDL updates
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
|
||||
# Now lane change should be allowed
|
||||
assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay
|
||||
@@ -91,7 +91,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# Update controller once
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
|
||||
# The timer should still be below the threshold after one update
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
@@ -99,7 +99,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
# Update enough times to exceed the threshold (seconds / DT_MDL)
|
||||
num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
|
||||
# Now lane change should be allowed
|
||||
assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay
|
||||
@@ -116,7 +116,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
|
||||
# Update with brake pressed for 1 second
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=True)
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=True)
|
||||
|
||||
# Even though it is an auto lane change mode, lane change should be disallowed due to brake pressed prior initiating lane change
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
@@ -126,7 +126,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
|
||||
# Even releasing brake shouldn't allow auto lane change
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
@@ -139,13 +139,13 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# Update with blindspot detected - this should prevent auto lane change
|
||||
self.alc.update_lane_change(blindspot_detected=True, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=True, edge_detected=False, brake_pressed=False)
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
# Keep updating with blindspot detected - should still prevent auto lane change
|
||||
num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=True, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=True, edge_detected=False, brake_pressed=False)
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
|
||||
@@ -157,13 +157,13 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# First update with blindspot detected to set the negative timer
|
||||
self.alc.update_lane_change(blindspot_detected=True, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=True, edge_detected=False, brake_pressed=False)
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
# Now update with blindspot cleared - should start incrementing timer from negative value
|
||||
num_updates = int((timer_delay + abs(ONE_SECOND_DELAY)) / DT_MDL) + 1
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
|
||||
# After sufficient updates with no blindspot, auto lane change should be allowed
|
||||
assert self.alc.auto_lane_change_allowed
|
||||
@@ -177,7 +177,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
|
||||
# Update enough times to exceed the threshold (seconds / DT_MDL)
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
|
||||
# Now lane change should be allowed
|
||||
assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay
|
||||
@@ -193,7 +193,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
|
||||
# Update enough times to exceed the threshold (seconds / DT_MDL)
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
@@ -206,7 +206,86 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
# Simulate updates for a long period of time (e.g., 10 seconds)
|
||||
num_updates = int(10.0 / DT_MDL)
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
|
||||
# Lane change should never be allowed
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
|
||||
def test_edge_detected_delays_auto_lane_change(self, timer_state, timer_delay):
|
||||
"""Test that road edge detection adds delay to auto lane change."""
|
||||
self._reset_states()
|
||||
self.alc.lane_change_bsm_delay = False
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# Update with edge detected - should prevent auto lane change
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False)
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
# Keep updating with edge detected - timer keeps resetting, should stay disallowed
|
||||
num_updates = int(timer_delay / DT_MDL) + 1
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False)
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
|
||||
def test_edge_detected_then_cleared_allows_after_delay(self, timer_state, timer_delay):
|
||||
"""Test that auto lane change is allowed after edge clears plus extra delay."""
|
||||
self._reset_states()
|
||||
self.alc.lane_change_bsm_delay = False
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# First update with edge detected to set the negative timer
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False)
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
# Now update with edge cleared - should start incrementing timer from negative value
|
||||
num_updates = int((timer_delay + abs(EDGE_DELAY)) / DT_MDL) + 1
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
|
||||
# After sufficient updates with no edge, auto lane change should be allowed
|
||||
assert self.alc.auto_lane_change_allowed
|
||||
|
||||
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
|
||||
def test_edge_and_bsm_combined_uses_worst_delay(self, timer_state, timer_delay):
|
||||
"""Test that when both BSM and edge are detected, the worst (longest) delay wins."""
|
||||
self._reset_states()
|
||||
self.alc.lane_change_bsm_delay = True
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# Update with both blindspot and edge detected
|
||||
self.alc.update_lane_change(blindspot_detected=True, edge_detected=True, brake_pressed=False)
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
# Clear both - need to wait out the worst delay
|
||||
worst_delay = max(abs(ONE_SECOND_DELAY), abs(EDGE_DELAY))
|
||||
num_updates = int((timer_delay + worst_delay) / DT_MDL) + 1
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
|
||||
assert self.alc.auto_lane_change_allowed
|
||||
|
||||
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
|
||||
def test_nudge_overrides_edge_in_lane_change_blocked(self, timer_state, timer_delay):
|
||||
"""Test that lane_change_blocked is False when nudge applied with edge-only."""
|
||||
self._reset_states()
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# Edge detected, no nudge - should be blocked
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False, torque_applied=False)
|
||||
assert self.alc.lane_change_blocked
|
||||
|
||||
# Edge detected, nudge applied - should NOT be blocked
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False, torque_applied=True)
|
||||
assert not self.alc.lane_change_blocked
|
||||
|
||||
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
|
||||
def test_nudge_does_not_override_bsm_in_lane_change_blocked(self, timer_state, timer_delay):
|
||||
"""Test that lane_change_blocked stays True when BSM detected even with nudge."""
|
||||
self._reset_states()
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# BSM detected with nudge - should still be blocked
|
||||
self.alc.update_lane_change(blindspot_detected=True, edge_detected=False, brake_pressed=False, torque_applied=True)
|
||||
assert self.alc.lane_change_blocked
|
||||
|
||||
@@ -113,12 +113,56 @@ class TestDesireHelperIntegration(OpenpilotTestCase):
|
||||
left_edge_detected=False, right_edge_detected=False)
|
||||
assert dh.desire == expected_desire
|
||||
|
||||
def test_edge_blocks_lane_change(self, set_lane_turn_params):
|
||||
def test_nudge_overrides_edge_detection(self, set_lane_turn_params):
|
||||
"""Manual steering nudge should override road edge detection and allow lane change."""
|
||||
dh = DesireHelper()
|
||||
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE
|
||||
carstate = DummyCarState(vEgo=15, leftBlinker=True, steeringPressed=True, steeringTorque=1)
|
||||
for _ in range(10):
|
||||
dh.update(carstate, True, 1.0, left_edge_detected=True, right_edge_detected=False)
|
||||
assert dh.lane_change_state == LaneChangeState.preLaneChange
|
||||
assert dh.lane_change_state == LaneChangeState.laneChangeStarting
|
||||
assert dh.lane_change_direction == LaneChangeDirection.left
|
||||
assert dh.desire == log.Desire.laneChangeLeft
|
||||
|
||||
def test_nudge_does_not_override_bsm(self, set_lane_turn_params):
|
||||
"""Manual steering nudge must NOT override BSM blindspot detection."""
|
||||
dh = DesireHelper()
|
||||
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE
|
||||
carstate = DummyCarState(vEgo=15, leftBlinker=True, leftBlindspot=True, steeringPressed=True, steeringTorque=1)
|
||||
for _ in range(10):
|
||||
dh.update(carstate, True, 1.0, left_edge_detected=False, right_edge_detected=False)
|
||||
assert dh.lane_change_state == LaneChangeState.preLaneChange
|
||||
assert dh.desire == log.Desire.none
|
||||
|
||||
def test_edge_blocks_auto_lane_change(self, set_lane_turn_params):
|
||||
"""Road edge detection should still block auto (nudgeless) lane changes."""
|
||||
dh = DesireHelper()
|
||||
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGELESS
|
||||
dh.alc.param_read_counter = 1
|
||||
carstate = DummyCarState(vEgo=15, leftBlinker=True)
|
||||
for _ in range(10):
|
||||
dh.update(carstate, True, 1.0, left_edge_detected=True, right_edge_detected=False)
|
||||
assert dh.lane_change_state == LaneChangeState.preLaneChange
|
||||
assert dh.desire == log.Desire.none
|
||||
|
||||
def test_auto_lane_change_allowed_without_edge(self, set_lane_turn_params):
|
||||
"""Auto lane change should proceed when no edge and no BSM detected."""
|
||||
dh = DesireHelper()
|
||||
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGELESS
|
||||
dh.alc.param_read_counter = 1
|
||||
carstate = DummyCarState(vEgo=15, leftBlinker=True)
|
||||
for _ in range(10):
|
||||
dh.update(carstate, True, 1.0, left_edge_detected=False, right_edge_detected=False)
|
||||
assert dh.lane_change_state == LaneChangeState.laneChangeStarting
|
||||
assert dh.desire == log.Desire.laneChangeLeft
|
||||
|
||||
def test_right_edge_does_not_block_left_lane_change(self, set_lane_turn_params):
|
||||
"""Edge on opposite side should not affect lane change direction."""
|
||||
dh = DesireHelper()
|
||||
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGELESS
|
||||
dh.alc.param_read_counter = 1
|
||||
carstate = DummyCarState(vEgo=15, leftBlinker=True)
|
||||
for _ in range(10):
|
||||
dh.update(carstate, True, 1.0, left_edge_detected=False, right_edge_detected=True)
|
||||
assert dh.lane_change_state == LaneChangeState.laneChangeStarting
|
||||
assert dh.desire == log.Desire.laneChangeLeft
|
||||
|
||||
@@ -32,7 +32,7 @@ from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL, DEFAULT_BIG_MO
|
||||
from openpilot.sunnypilot.selfdrive.car.sync_sunnylink_params import update_car_list_param
|
||||
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
|
||||
from openpilot.sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, get_param_as_byte, save_param_from_base64_encoded_string
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import generate_capabilities, CAPABILITY_LABELS
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import generate_capabilities, CAPABILITY_LABELS, CAR_CAPABILITY_FIELDS
|
||||
from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import generate_schema
|
||||
|
||||
SUNNYLINK_ATHENA_HOST = os.getenv('SUNNYLINK_ATHENA_HOST', 'wss://athena.sunnylink.ai')
|
||||
@@ -181,6 +181,7 @@ def getParamsMetadata() -> str:
|
||||
schema = generate_schema()
|
||||
schema["capabilities"] = generate_capabilities()
|
||||
schema["capability_labels"] = CAPABILITY_LABELS
|
||||
schema["car_capability_fields"] = CAR_CAPABILITY_FIELDS
|
||||
schema["default_model"] = DEFAULT_MODEL
|
||||
schema["default_big_model"] = DEFAULT_BIG_MODEL
|
||||
schema["chestnut_active"] = params.get_bool("ChestnutActive")
|
||||
|
||||
@@ -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.car.toyota.values import CAR as TOYOTA_CAR, ToyotaFlags
|
||||
from opendbc.sunnypilot.car.interfaces import has_bsm
|
||||
from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP
|
||||
from openpilot.common.params import Params
|
||||
@@ -43,6 +44,8 @@ CAPABILITY_FIELDS = (
|
||||
"device_type",
|
||||
"subaru_has_sng",
|
||||
"hyundai_alpha_long_available",
|
||||
"toyota_tss2",
|
||||
"custom_model_active",
|
||||
)
|
||||
|
||||
CAPABILITY_LABELS: dict[str, str] = {
|
||||
@@ -65,8 +68,29 @@ CAPABILITY_LABELS: dict[str, str] = {
|
||||
"device_type": "Device type",
|
||||
"subaru_has_sng": "Subaru Stop-and-Go available",
|
||||
"hyundai_alpha_long_available": "Hyundai Alpha Longitudinal available",
|
||||
"toyota_tss2": "Toyota TSS2 platform",
|
||||
"custom_model_active": "Non-Default model selected",
|
||||
}
|
||||
|
||||
# Fields read from the car (CarParams*, CarPlatformBundle). Until a car is seen these stay at
|
||||
# their defaults, so a rule failing on one of them means "start the vehicle", not a real reason.
|
||||
CAR_CAPABILITY_FIELDS = (
|
||||
"has_longitudinal_control",
|
||||
"has_icbm",
|
||||
"icbm_available",
|
||||
"torque_allowed",
|
||||
"brand",
|
||||
"pcm_cruise",
|
||||
"alpha_long_available",
|
||||
"steer_control_type",
|
||||
"has_bsm",
|
||||
"tesla_has_vehicle_bus",
|
||||
"has_stop_and_go",
|
||||
"subaru_has_sng",
|
||||
"hyundai_alpha_long_available",
|
||||
"toyota_tss2",
|
||||
)
|
||||
|
||||
# Explicit defaults for non-boolean capability fields
|
||||
CAPABILITY_DEFAULTS: dict[str, bool | str | int] = {
|
||||
"brand": "",
|
||||
@@ -111,6 +135,15 @@ def _resolve_brand_capabilities(caps: dict, bundle_platform: str, CP) -> None:
|
||||
caps["subaru_has_sng"] = not bool(CP.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
|
||||
caps["has_stop_and_go"] = caps["subaru_has_sng"]
|
||||
|
||||
elif brand == "toyota":
|
||||
if bundle_platform:
|
||||
try:
|
||||
caps["toyota_tss2"] = bool(TOYOTA_CAR[bundle_platform].config.flags & ToyotaFlags.TSS2)
|
||||
except KeyError:
|
||||
cloudlog.exception(f"capabilities: unknown toyota platform {bundle_platform!r}")
|
||||
elif CP is not None:
|
||||
caps["toyota_tss2"] = bool(CP.flags & ToyotaFlags.TSS2)
|
||||
|
||||
|
||||
def generate_capabilities(params: Params | None = None) -> dict:
|
||||
"""Generate a SettingsCapabilities dict from CarParams + boolean params.
|
||||
@@ -133,6 +166,9 @@ def generate_capabilities(params: Params | None = None) -> dict:
|
||||
caps["is_development"] = params.get_bool("IsDevelopmentBranch")
|
||||
caps["stock_longitudinal"] = params.get_bool("ToyotaEnforceStockLongitudinal")
|
||||
|
||||
# runner the manager launches (process_config.is_tinygrad_model); with a chestnut the big slot decides
|
||||
caps["custom_model_active"] = params.get("ModelRunnerTypeCache") == custom.ModelManagerSP.Runner.tinygrad
|
||||
|
||||
bundle = params.get("CarPlatformBundle")
|
||||
bundle_brand = _bundle_field(bundle, "brand")
|
||||
bundle_platform = _bundle_field(bundle, "platform")
|
||||
|
||||
@@ -620,6 +620,19 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "AccelBoostSP",
|
||||
"widget": "toggle",
|
||||
"title": "Acceleration Boost",
|
||||
"description": "Adds a small push on top of sunnypilot's own acceleration, stronger in Aggressive and gentler in Relaxed. Eases off in curves and when sunnypilot slows or stops. Never goes past your set speed, and lead-car following still comes first.",
|
||||
"enablement": [
|
||||
{
|
||||
"type": "capability",
|
||||
"field": "has_longitudinal_control",
|
||||
"equals": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "IntelligentCruiseButtonManagement",
|
||||
"widget": "toggle",
|
||||
@@ -1349,7 +1362,17 @@
|
||||
"key": "TorqueBar",
|
||||
"widget": "toggle",
|
||||
"title": "Steering Arc",
|
||||
"description": "Display steering arc on the driving screen when lateral control is enabled."
|
||||
"description": "Display steering arc on the driving screen when lateral control is enabled.",
|
||||
"visibility": [
|
||||
{
|
||||
"type": "not",
|
||||
"condition": {
|
||||
"type": "capability",
|
||||
"field": "device_type",
|
||||
"equals": "mici"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ShowTurnSignals",
|
||||
@@ -2021,8 +2044,21 @@
|
||||
{
|
||||
"id": "model_behavior",
|
||||
"title": "Model Behavior",
|
||||
"description": "Lane desire and lead-vehicle awareness tuning",
|
||||
"description": "Lane desire, Blendful lane centering, and lead-vehicle awareness tuning",
|
||||
"items": [
|
||||
{
|
||||
"key": "Blendful",
|
||||
"widget": "toggle",
|
||||
"title": "Blendful (Alpha)",
|
||||
"description": "Adds a gentle nudge toward lane center on top of the model's own steering, stronger when lane lines are clear. Yields around obstacles. Not available with the Default model.",
|
||||
"enablement": [
|
||||
{
|
||||
"type": "capability",
|
||||
"field": "custom_model_active",
|
||||
"equals": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "LaneTurnDesire",
|
||||
"widget": "toggle",
|
||||
@@ -2324,6 +2360,33 @@
|
||||
"equals": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ToyotaTSS2Smooth",
|
||||
"widget": "toggle",
|
||||
"needs_onroad_cycle": true,
|
||||
"title": "TSS2 Smooth Longitudinal (Alpha)",
|
||||
"description": "Enable smoother longitudinal control for TSS2 vehicles. Uses a custom speed-dependent integral gain curve for smoother traffic driving.",
|
||||
"enablement": [
|
||||
{
|
||||
"type": "not_engaged"
|
||||
},
|
||||
{
|
||||
"type": "capability",
|
||||
"field": "has_longitudinal_control",
|
||||
"equals": true
|
||||
},
|
||||
{
|
||||
"type": "param",
|
||||
"key": "ToyotaEnforceStockLongitudinal",
|
||||
"equals": false
|
||||
},
|
||||
{
|
||||
"type": "capability",
|
||||
"field": "toyota_tss2",
|
||||
"equals": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -63,3 +63,7 @@ macros:
|
||||
release_branches_hide:
|
||||
- type: not
|
||||
condition: {type: capability, field: is_sp_release, equals: true}
|
||||
|
||||
# Gate for custom model features based on active source.
|
||||
custom_model:
|
||||
- { type: capability, field: custom_model_active, equals: true }
|
||||
|
||||
@@ -30,6 +30,12 @@ sections:
|
||||
label: Relaxed
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: AccelBoostSP
|
||||
widget: toggle
|
||||
title: Acceleration Boost
|
||||
description: "Adds a small push on top of sunnypilot's own acceleration, stronger in Aggressive and gentler in Relaxed. Eases off in curves and when sunnypilot slows or stops. Never goes past your set speed, and lead-car following still comes first."
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: IntelligentCruiseButtonManagement
|
||||
widget: toggle
|
||||
title: Intelligent Cruise Button Management (ICBM) (Alpha)
|
||||
|
||||
@@ -9,8 +9,14 @@ description: Driving model behavior and camera calibration
|
||||
sections:
|
||||
- id: model_behavior
|
||||
title: Model Behavior
|
||||
description: Lane desire and lead-vehicle awareness tuning
|
||||
description: Lane desire, Blendful lane centering, and lead-vehicle awareness tuning
|
||||
items:
|
||||
- key: Blendful
|
||||
widget: toggle
|
||||
title: Blendful (Alpha)
|
||||
description: Adds a gentle nudge toward lane center on top of the model's own steering, stronger when lane lines are clear. Yields around obstacles. Not available with the Default model.
|
||||
enablement:
|
||||
- $ref: '#/macros/custom_model'
|
||||
- key: LaneTurnDesire
|
||||
widget: toggle
|
||||
title: Use Lane Turn Desires
|
||||
|
||||
@@ -101,3 +101,18 @@ sections:
|
||||
- type: param
|
||||
key: ToyotaEnforceStockLongitudinal
|
||||
equals: false
|
||||
- key: ToyotaTSS2Smooth
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: TSS2 Smooth Longitudinal (Alpha)
|
||||
description: Enable smoother longitudinal control for TSS2 vehicles. Uses a custom speed-dependent integral gain curve
|
||||
for smoother traffic driving.
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- type: param
|
||||
key: ToyotaEnforceStockLongitudinal
|
||||
equals: false
|
||||
- type: capability
|
||||
field: toyota_tss2
|
||||
equals: true
|
||||
|
||||
@@ -20,6 +20,8 @@ sections:
|
||||
widget: toggle
|
||||
title: Steering Arc
|
||||
description: Display steering arc on the driving screen when lateral control is enabled.
|
||||
visibility:
|
||||
- $ref: '#/macros/hide_on_mici'
|
||||
- key: ShowTurnSignals
|
||||
widget: toggle
|
||||
title: Display Turn Signals
|
||||
|
||||
@@ -17,6 +17,7 @@ from openpilot.sunnypilot.sunnylink.capabilities import (
|
||||
CAPABILITY_DEFAULTS,
|
||||
CAPABILITY_FIELDS,
|
||||
CAPABILITY_LABELS,
|
||||
CAR_CAPABILITY_FIELDS,
|
||||
PROTOCOL_VERSION,
|
||||
generate_capabilities,
|
||||
)
|
||||
@@ -75,6 +76,12 @@ class TestOpaquePerBrandFlags(OpenpilotTestCase):
|
||||
def test_hyundai_alpha_long_available_default_false(self, caps):
|
||||
assert caps["hyundai_alpha_long_available"] is False
|
||||
|
||||
def test_toyota_tss2_field_present(self):
|
||||
assert "toyota_tss2" in CAPABILITY_FIELDS
|
||||
|
||||
def test_toyota_tss2_default_false(self, caps):
|
||||
assert caps["toyota_tss2"] is False
|
||||
|
||||
|
||||
class TestCapabilitiesShape(OpenpilotTestCase):
|
||||
def test_all_fields_present(self, caps):
|
||||
@@ -85,6 +92,10 @@ class TestCapabilitiesShape(OpenpilotTestCase):
|
||||
for field in CAPABILITY_FIELDS:
|
||||
assert field in CAPABILITY_LABELS, f"CAPABILITY_LABELS missing {field}"
|
||||
|
||||
def test_car_fields_are_capabilities(self):
|
||||
assert set(CAR_CAPABILITY_FIELDS) <= set(CAPABILITY_FIELDS)
|
||||
assert "custom_model_active" not in CAR_CAPABILITY_FIELDS
|
||||
|
||||
def test_string_defaults_are_strings(self, caps):
|
||||
assert isinstance(caps["brand"], str)
|
||||
assert isinstance(caps["steer_control_type"], str)
|
||||
|
||||
@@ -207,6 +207,7 @@ class TestNotEngagedReplacement(OpenpilotTestCase):
|
||||
"AlphaLongitudinalEnabled",
|
||||
"ToyotaEnforceStockLongitudinal",
|
||||
"ToyotaStopAndGoHack",
|
||||
"ToyotaTSS2Smooth",
|
||||
], names=["key"])
|
||||
def test_offroad_only_replaced_with_not_engaged(self, schema, key):
|
||||
"""These items should use not_engaged, not offroad_only."""
|
||||
|
||||
@@ -286,6 +286,7 @@ class TestKnownVehicleSettings(OpenpilotTestCase):
|
||||
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("toyota"))}
|
||||
assert "ToyotaEnforceStockLongitudinal" in keys
|
||||
assert "ToyotaStopAndGoHack" in keys
|
||||
assert "ToyotaTSS2Smooth" in keys
|
||||
|
||||
def test_tesla_has_coop_steering(self, schema):
|
||||
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("tesla"))}
|
||||
|
||||
@@ -186,6 +186,9 @@ procs += [
|
||||
|
||||
# locationd
|
||||
NativeProcess("locationd_llk", "openpilot/sunnypilot/selfdrive/locationd", ["./locationd"], only_onroad),
|
||||
|
||||
# achievements: read-only stats and unlocks, saved on the device
|
||||
PythonProcess("achievementsd", "openpilot.sunnypilot.achievements.achievementsd", only_onroad),
|
||||
]
|
||||
|
||||
if os.path.exists("../../sunnypilot/sunnylink/uploader.py"):
|
||||
|
||||
@@ -57,7 +57,7 @@ class DownloadStatusAction(ItemAction):
|
||||
self.icon: str | None = None
|
||||
self.icon_color: rl.Color | None = None
|
||||
self._font = gui_app.font(FontWeight.NORMAL)
|
||||
# raw progress arrives in steps, one per 128KB chunk the manager publishes
|
||||
# raw progress arrives in steps, one per progress report from the manager
|
||||
self._progress = FirstOrderFilter(0.0, 0.5, 1 / gui_app.target_fps)
|
||||
# integrated per frame; (t * speed) % span jumps whenever the fill width changes
|
||||
self._sweep = 0.0
|
||||
|
||||
@@ -13,26 +13,41 @@ from pathlib import Path
|
||||
from datetime import datetime, UTC
|
||||
|
||||
|
||||
_VERSION_REGEX = re.compile(r'^(?:\d+[a-zA-Z]+|\d+[vVbB]\d+|[vVbB]\d+|\d{4})$')
|
||||
_DECIMAL_VERSION_REGEX = re.compile(r'^([vVbB]?)(\d+(?:\.\d+)+)$')
|
||||
|
||||
|
||||
def _version(word: str) -> str | None:
|
||||
match = _DECIMAL_VERSION_REGEX.match(word.strip('()[]{}.,;:'))
|
||||
if match:
|
||||
return match.group(1).upper() + match.group(2)
|
||||
stripped = re.sub(r'[^a-zA-Z0-9]', '', word)
|
||||
return stripped.upper() if _VERSION_REGEX.match(stripped) else None
|
||||
|
||||
|
||||
def _truncate(short_name: str) -> str:
|
||||
return short_name[:8].rstrip('.')
|
||||
|
||||
|
||||
def create_short_name(full_name: str) -> str:
|
||||
# Remove parentheses and extract alphanumeric words
|
||||
# Remove parentheses and extract alphanumeric words, keeping decimals for versioning
|
||||
clean_name = re.sub(r'\([^)]*\)', '', full_name)
|
||||
words = [re.sub(r'[^a-zA-Z0-9]', '', word) for word in clean_name.split() if re.sub(r'[^a-zA-Z0-9]', '', word)]
|
||||
words = [re.sub(r'[^a-zA-Z0-9.]', '', word) for word in clean_name.split()]
|
||||
words = [word for word in words if word]
|
||||
|
||||
if len(words) == 1:
|
||||
return words[0][:8].upper()
|
||||
return _truncate((_version(words[0]) or words[0]).upper())
|
||||
|
||||
# Handle special case: Name + Version (e.g., "Word A1" -> "WordA1")
|
||||
if len(words) == 2 and re.match(r'^[A-Za-z]\d+$', words[1]):
|
||||
return (words[0] + words[1])[:8].upper()
|
||||
# Handle special case: Name + Version (e.g., "Word A1" -> "WordA1", "Model v1.1" -> "ModelV1.1")
|
||||
if len(words) == 2 and re.match(r'^[A-Za-z]?\d+$', words[1]):
|
||||
return _truncate((words[0] + (_version(words[1]) or words[1])).upper())
|
||||
|
||||
result = ""
|
||||
for word in words:
|
||||
# Version or number patterns
|
||||
if (re.match(r'^\d+[a-zA-Z]+$', word) or
|
||||
re.match(r'^\d+[vVbB]\d+$', word) or
|
||||
re.match(r'^[vVbB]\d+$', word) or
|
||||
re.match(r'^\d{4}$', word)):
|
||||
result += word.upper()
|
||||
version = _version(word)
|
||||
if version:
|
||||
result += version
|
||||
# All uppercase abbreviations (2-3 letters)
|
||||
elif re.match(r'^[A-Z]{2,3}$', word):
|
||||
result += word
|
||||
@@ -45,7 +60,7 @@ def create_short_name(full_name: str) -> str:
|
||||
result += word
|
||||
else:
|
||||
result += word[0].upper()
|
||||
return result[:8]
|
||||
return _truncate(result)
|
||||
|
||||
|
||||
def create_pkl_name(full_name: str) -> str:
|
||||
|
||||
+1
-1
Submodule tinygrad_repo updated: 69fd4d34d4...30ddfc2eac
Reference in New Issue
Block a user