Compare commits

..

2 Commits

Author SHA1 Message Date
royjr 72deb91942 Merge branch 'master' into developer-panel-external-storage 2026-08-30 15:10:38 -04:00
royjr d5b5652165 ui: external storage 2026-07-22 10:00:19 -04:00
378 changed files with 12269 additions and 18637 deletions
+1 -3
View File
@@ -1,10 +1,8 @@
* text=auto eol=lf
* text=auto
# to move existing files into LFS:
# git add --renormalize .
*.onnx filter=lfs diff=lfs merge=lfs -text
openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text
openpilot/sunnypilot/modeld_v2/models/*.pkl filter=lfs diff=lfs merge=lfs -text
*.svg filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text
+11
View File
@@ -0,0 +1,11 @@
* @sunnypilot/dev-internal
/.github/ @devtekve @sunnyhaibin
/release/ci/ @devtekve @sunnyhaibin
/tinygrad_repo @devtekve @Discountchubbs
/tinygrad/ @devtekve @Discountchubbs
/selfdrive/controls/lib/longitudinal_planner.py @devtekve @Discountchubbs
/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py @devtekve @Discountchubbs
/selfdrive/modeld/ @devtekve @Discountchubbs
/sunnypilot/model* @devtekve @Discountchubbs
/sunnypilot/sunnylink/ @devtekve
/system/athena/ @devtekve
+43
View File
@@ -0,0 +1,43 @@
exclude-labels:
- 'no-changelog'
categories:
- title: '🚀 Features'
labels:
- 'feature'
- 'enhancement'
- title: '🐛 Bug Fixes'
collapse-after: 5
labels:
- 'fix'
- 'bugfix'
- 'bug'
- title: '🧰 Maintenance'
collapse-after: 5
label: 'chore'
change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
change-title-escapes: '\<*_&'
replacers:
- search: '/[Ss][Uu][Nn][Nn][Yy][Pp][Ii][Ll][Oo][Tt]/g'
replace: 'sunnypilot'
- search: '/\b[Ss][Pp]\b/g'
replace: 'SP'
version-resolver:
major:
labels:
- 'major'
minor:
labels:
- 'minor'
patch:
labels:
- 'patch'
default: patch
name-template: 'v$RESOLVED_VERSION 🚀'
tag-template: 'v$RESOLVED_VERSION'
version-template: "0.$MAJOR.$MINOR.$PATCH" # The day OP becomes v1, we need to bump this
tag-prefix: "v0." # The day OP becomes v1, we need to bump this
prerelease-identifier: "staging"
template: |
## Changes
$CHANGES
@@ -20,11 +20,6 @@ on:
required: false
type: string
default: 'sunnypilot/sunnypilot_models_v1'
docs_repo:
description: 'GitHub repo holding the driving_models JSON on its gh-pages branch'
required: false
type: string
default: 'sunnypilot/sunnypilot-models'
jobs:
setup:
@@ -39,6 +34,7 @@ jobs:
- name: Checkout sunnypilot repo
uses: actions/checkout@v4
with:
repository: sunnypilot/sunnypilot
path: sunnypilot
submodules: recursive
@@ -51,10 +47,10 @@ jobs:
echo "tinygrad_ref=$ref" >> $GITHUB_OUTPUT
echo "tinygrad_ref is $ref"
- name: Checkout docs repo (gh-pages)
- name: Checkout docs repo (sunnypilot-models, gh-pages)
uses: actions/checkout@v4
with:
repository: ${{ inputs.docs_repo }}
repository: sunnypilot/sunnypilot-models
ref: gh-pages
path: docs
ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }}
@@ -82,7 +78,6 @@ jobs:
- name: Get next recompiled dir number
id: create-recompiled-dir
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_REPO: ${{ github.event.inputs.hf_repo }}
run: |
pip install huggingface_hub
@@ -122,7 +117,6 @@ jobs:
json_version: ${{ needs.setup.outputs.json_version }}
target_hardware: ${{ github.event.inputs.target_hardware }}
hf_repo: ${{ github.event.inputs.hf_repo }}
docs_repo: ${{ inputs.docs_repo }}
set_min_version: ${{ github.event.inputs.set_min_version }}
tinygrad_ref: ${{ needs.setup.outputs.tinygrad_ref }}
secrets: inherit
@@ -167,7 +161,6 @@ jobs:
target_hardware: ${{ github.event.inputs.target_hardware }}
artifact_suffix: -retry
hf_repo: ${{ github.event.inputs.hf_repo }}
docs_repo: ${{ inputs.docs_repo }}
set_min_version: ${{ github.event.inputs.set_min_version }}
tinygrad_ref: ${{ needs.setup.outputs.tinygrad_ref }}
secrets: inherit
+55 -76
View File
@@ -30,7 +30,6 @@ jobs:
runs-on: ubuntu-24.04
outputs:
model_name: ${{ steps.resolve.outputs.model_name }}
safe_model_name: ${{ steps.resolve.outputs.safe_model_name }}
onnx_ref: ${{ steps.resolve.outputs.onnx_ref }}
onnx_path: ${{ steps.resolve.outputs.onnx_path }}
hf_defaults_path: ${{ steps.resolve.outputs.hf_defaults_path }}
@@ -46,24 +45,28 @@ jobs:
if [ "${{ inputs.target }}" = "big" ]; then
NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)")
ONNX_PATH="openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"
HF_DEFAULTS_PATH="models/defaults/big"
elif [ "${{ inputs.target }}" = "dm" ]; then
ONNX_PATH="openpilot/selfdrive/modeld/models/dmonitoring_model.onnx"
HF_DEFAULTS_PATH="models/defaults/dm"
NAME="dmonitoring_model ($(git log -1 --format=%cd --date=format:'%B %d, %Y' -- "$ONNX_PATH"))"
else
NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL; print(DEFAULT_MODEL)")
ONNX_PATH="openpilot/selfdrive/modeld/models/driving_supercombo.onnx"
HF_DEFAULTS_PATH="models/defaults/small"
fi
ONNX_REF=$(git log -1 --format='%H' -- "$ONNX_PATH")
TINYGRAD_REF=$(python3 openpilot/sunnypilot/models/tinygrad_ref.py)
if [ -z "$TINYGRAD_REF" ]; then
echo "::error::Failed to resolve tinygrad ref"
exit 1
fi
SAFE_NAME="${NAME// /-}"
echo "model_name=${NAME}" >> $GITHUB_OUTPUT
echo "safe_model_name=${SAFE_NAME}" >> $GITHUB_OUTPUT
echo "onnx_ref=${ONNX_REF}" >> $GITHUB_OUTPUT
echo "onnx_path=${ONNX_PATH}" >> $GITHUB_OUTPUT
echo "hf_defaults_path=${HF_DEFAULTS_PATH}" >> $GITHUB_OUTPUT
echo "tinygrad_ref=${TINYGRAD_REF}" >> $GITHUB_OUTPUT
@@ -72,8 +75,6 @@ jobs:
if: ${{ inputs.target == 'small' }}
runs-on: [self-hosted, tici]
env:
MODELS_DIR: openpilot/selfdrive/modeld/models
COMPILER: tinygrad_repo/examples/openpilot
SMALL_ONNX: openpilot/selfdrive/modeld/models/driving_supercombo.onnx
SMALL_PKL: openpilot/selfdrive/modeld/models/driving_tinygrad.pkl
steps:
@@ -81,10 +82,9 @@ jobs:
with:
submodules: recursive
- name: Pull small ONNX via LFS
- name: Pull ONNX via LFS
run: git lfs pull -I "${{ env.SMALL_ONNX }}"
- name: Set environment variables
run: |
source /etc/profile
@@ -100,35 +100,24 @@ jobs:
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable
- name: Compile small model from ONNX
- name: Compile small model with stock compiler
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
env ${TG_FLAGS} python3 ${{ github.workspace }}/${{ env.COMPILER }}/compile_onnx.py \
${{ github.workspace }}/${{ env.SMALL_ONNX }} \
${{ github.workspace }}/${{ env.SMALL_PKL }} \
--device-input "*" --out-of-band --benchmark-runs 1
- name: Compile driving warps (stock)
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
MODEL_W=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(s[0])")
MODEL_H=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(s[1])")
MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')")
CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')")
FRAME_SKIP=$(python3 -c "from openpilot.selfdrive.modeld.constants import ModelConstants as MC; print(MC.MODEL_RUN_FREQ // MC.MODEL_CONTEXT_FREQ)")
TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
for CAM in "1928,1208" "1344,760"; do
CAM_W=$(echo $CAM | cut -d, -f1)
CAM_H=$(echo $CAM | cut -d, -f2)
STRIDE_Y_UV=$(python3 -c 'import sys; from openpilot.system.camerad.cameras.nv12_info import get_nv12_info; s,y,u,_=get_nv12_info(int(sys.argv[1]),int(sys.argv[2])); print(f"{s},{y},{u},{s*(y+u)}")' $CAM_W $CAM_H)
OUTPUT="${{ github.workspace }}/${{ env.MODELS_DIR }}/driving_warp_${CAM_W}x${CAM_H}_tinygrad.pkl"
env ${TG_FLAGS} python3 ${{ github.workspace }}/${{ env.COMPILER }}/compile_warp.py \
--frame ${CAM_W},${CAM_H},${STRIDE_Y_UV} \
--warp-to ${MODEL_W}x${MODEL_H} \
--layout yuv420 \
--frames 2 \
--output "${OUTPUT}"
done
env ${TG_FLAGS} python3 \
${{ github.workspace }}/openpilot/selfdrive/modeld/compile_modeld.py \
--onnx ${{ github.workspace }}/${{ env.SMALL_ONNX }} \
--model-size $MODEL_SIZE \
--camera-resolutions $CAMERA_RES \
--frame-skip $FRAME_SKIP \
--output ${{ github.workspace }}/${{ env.SMALL_PKL }}
- name: Chunk small pkl
run: |
@@ -146,7 +135,7 @@ jobs:
- name: Prepare output
env:
MODEL_NAME: ${{ needs.resolve.outputs.safe_model_name }}
MODEL_NAME: ${{ needs.resolve.outputs.model_name }}
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
@@ -169,13 +158,13 @@ jobs:
- name: Upload small model artifact
uses: actions/upload-artifact@v4
with:
name: model-${{ needs.resolve.outputs.safe_model_name }}-${{ github.run_number }}
name: model-${{ needs.resolve.outputs.model_name }}-${{ github.run_number }}
path: ${{ github.workspace }}/small_output/
- name: Upload artifact name file
uses: actions/upload-artifact@v4
with:
name: artifact-name-${{ needs.resolve.outputs.safe_model_name }}
name: artifact-name-${{ needs.resolve.outputs.model_name }}
path: ${{ github.workspace }}/small_output/artifact_name.txt
- name: Re-enable powersave
@@ -189,14 +178,16 @@ jobs:
if: ${{ inputs.target == 'big' }}
runs-on: [self-hosted, chestnut]
env:
MODELS_DIR: openpilot/selfdrive/modeld/models
COMPILER: tinygrad_repo/examples/openpilot
BIG_ONNX: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx
BIG_PKL: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Pull big ONNX via LFS
run: git lfs pull -I "${{ env.BIG_ONNX }}"
- name: Set environment variables
run: |
source /etc/profile
@@ -228,25 +219,24 @@ jobs:
raise RuntimeError('Chestnut PCIe link not ready after 10 attempts')
"
- name: Compile driving warps (chestnut)
- name: Compile big model with stock compiler
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
MODEL_W=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(s[0])")
MODEL_H=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(s[1])")
TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32"
for CAM in "1928,1208" "1344,760"; do
CAM_W=$(echo $CAM | cut -d, -f1)
CAM_H=$(echo $CAM | cut -d, -f2)
STRIDE_Y_UV=$(python3 -c 'import sys; from openpilot.system.camerad.cameras.nv12_info import get_nv12_info; s,y,u,_=get_nv12_info(int(sys.argv[1]),int(sys.argv[2])); print(f"{s},{y},{u},{s*(y+u)}")' $CAM_W $CAM_H)
OUTPUT="${{ github.workspace }}/${{ env.MODELS_DIR }}/big_driving_warp_${CAM_W}x${CAM_H}_tinygrad.pkl"
env ${TG_FLAGS} python3 ${{ github.workspace }}/${{ env.COMPILER }}/compile_warp.py \
--frame ${CAM_W},${CAM_H},${STRIDE_Y_UV} \
--warp-to ${MODEL_W}x${MODEL_H} \
--layout yuv420 \
--frames 2 \
--output "${OUTPUT}"
done
MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')")
CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')")
FRAME_SKIP=$(python3 -c "from openpilot.selfdrive.modeld.constants import ModelConstants as MC; print(MC.MODEL_RUN_FREQ // MC.MODEL_CONTEXT_FREQ)")
TG_FLAGS="DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV=QCOM FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2"
env ${TG_FLAGS} python3 \
${{ github.workspace }}/openpilot/selfdrive/modeld/compile_modeld.py \
--onnx ${{ github.workspace }}/${{ env.BIG_ONNX }} \
--model-size $MODEL_SIZE \
--camera-resolutions $CAMERA_RES \
--frame-skip $FRAME_SKIP \
--output ${{ github.workspace }}/${{ env.BIG_PKL }}
- name: Chunk big pkl
run: |
@@ -264,7 +254,7 @@ jobs:
- name: Prepare output
env:
MODEL_NAME: ${{ needs.resolve.outputs.safe_model_name }}
MODEL_NAME: ${{ needs.resolve.outputs.model_name }}
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
@@ -287,13 +277,13 @@ jobs:
- name: Upload big model artifact
uses: actions/upload-artifact@v4
with:
name: model-${{ needs.resolve.outputs.safe_model_name }}-${{ github.run_number }}
name: model-${{ needs.resolve.outputs.model_name }}-${{ github.run_number }}
path: ${{ github.workspace }}/big_output/
- name: Upload artifact name file
uses: actions/upload-artifact@v4
with:
name: artifact-name-${{ needs.resolve.outputs.safe_model_name }}
name: artifact-name-${{ needs.resolve.outputs.model_name }}
path: ${{ github.workspace }}/big_output/artifact_name.txt
- name: Re-enable powersave
@@ -318,6 +308,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Pull ONNX via LFS
run: git lfs pull -I "${{ needs.resolve.outputs.onnx_path }}"
- name: Install huggingface_hub
@@ -327,7 +318,7 @@ jobs:
if: ${{ inputs.target == 'small' || inputs.target == 'big' }}
uses: actions/download-artifact@v4
with:
name: artifact-name-${{ needs.resolve.outputs.safe_model_name }}
name: artifact-name-${{ needs.resolve.outputs.model_name }}
path: artifact_name
- name: Read artifact name
@@ -347,7 +338,7 @@ jobs:
- name: Upload model to HF
if: ${{ inputs.target == 'small' || inputs.target == 'big' }}
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_OIDC_RESOURCE: datasets/${{ env.HF_REPO }}
ARTIFACT_NAME: ${{ steps.artifact.outputs.artifact_name }}
run: |
rm -f output/artifact_name.txt
@@ -357,6 +348,8 @@ jobs:
--hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \
--artifact-name "$ARTIFACT_NAME" \
--model-dir output \
--onnx-path "${{ needs.resolve.outputs.onnx_path }}" \
--onnx-ref "${{ needs.resolve.outputs.onnx_ref }}" \
--model-name "${{ needs.resolve.outputs.model_name }}" \
--tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \
--run-number "${{ github.run_number }}"
@@ -371,7 +364,7 @@ jobs:
- name: Generate DM metadata and upload to HF
if: ${{ inputs.target == 'dm' }}
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_OIDC_RESOURCE: datasets/${{ env.HF_REPO }}
run: |
export PYTHONPATH=$(pwd)
python3 -c "
@@ -426,6 +419,8 @@ jobs:
--hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \
--artifact-name "dm-model-${{ github.run_number }}" \
--model-dir dm_output \
--onnx-path "${{ needs.resolve.outputs.onnx_path }}" \
--onnx-ref "${{ needs.resolve.outputs.onnx_ref }}" \
--model-name "${{ needs.resolve.outputs.model_name }}" \
--tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \
--run-number "${{ github.run_number }}"
@@ -435,12 +430,14 @@ jobs:
if: ${{ inputs.target == 'dm' }}
runs-on: [self-hosted, tici]
env:
DM_ONNX: openpilot/selfdrive/modeld/models/dmonitoring_model.onnx
DM_PKL: openpilot/selfdrive/modeld/models/dmonitoring_model_tinygrad.pkl
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Pull DM ONNX via LFS
run: git lfs pull -I "${{ env.DM_ONNX }}"
- name: Set environment variables
@@ -484,29 +481,11 @@ jobs:
print(f'Chunked {pkl} into {len(targets)} chunks')
"
- name: Compile DM warp
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
MODEL_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld"
DM_SIZE=$(python3 -c "from openpilot.common.transformations.model import DM_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')")
for res in $(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')"); do
WARP_PKL="${MODEL_DIR}/models/dm_warp_${res}_tinygrad.pkl"
taskset -c 7 env ${TG_FLAGS} python3 ${MODEL_DIR}/compile_dm_warp.py \
--camera-resolution ${res} \
--warp-to ${DM_SIZE} \
--output ${WARP_PKL}
done
- name: Prepare DM output
run: |
mkdir -p dm_output
cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunk* dm_output/
cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunkmanifest dm_output/
cp ${{ github.workspace }}/openpilot/selfdrive/modeld/models/dm_warp_* dm_output/
- name: Upload DM artifact
uses: actions/upload-artifact@v4
@@ -39,11 +39,6 @@ on:
required: false
type: string
default: 'sunnypilot/sunnypilot_models_v1'
docs_repo:
description: 'GitHub repo holding the driving_models JSON on its gh-pages branch'
required: false
type: string
default: 'sunnypilot/sunnypilot-models'
set_min_version:
description: 'Minimum selector version'
required: false
@@ -112,11 +107,6 @@ on:
required: false
type: string
default: 'sunnypilot/sunnypilot_models_v1'
docs_repo:
description: 'GitHub repo holding the driving_models JSON on its gh-pages branch'
required: false
type: string
default: 'sunnypilot/sunnypilot-models'
env:
RECOMPILED_DIR: recompiled${{ inputs.recompiled_dir }}
JSON_FILE: docs/docs/driving_models_${{ inputs.target_hardware == 'chestnut' && 'chestnut_v' || 'v' }}${{ inputs.json_version }}.json
@@ -146,7 +136,7 @@ jobs:
- name: Checkout docs repo
uses: actions/checkout@v4
with:
repository: ${{ inputs.docs_repo }}
repository: sunnypilot/sunnypilot-models
ref: gh-pages
path: docs
ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }}
@@ -156,7 +146,7 @@ jobs:
- name: Validate hf_repo and JSON version
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_OIDC_RESOURCE: datasets/${{ inputs.hf_repo }}
run: |
if [ ! -f "$JSON_FILE" ]; then
echo "JSON file $JSON_FILE does not exist!"
@@ -165,8 +155,13 @@ jobs:
python3 -c "
import sys
from huggingface_hub import HfApi
HfApi().repo_info(repo_id=sys.argv[1], repo_type='dataset')
print(f'Success: Repo {sys.argv[1]} exists.')
try:
api = HfApi()
api.repo_info(repo_id=sys.argv[1], repo_type='dataset')
print(f'Success: Repo {sys.argv[1]} exists.')
except Exception as e:
print('HF validation failed:', e)
sys.exit(1)
" "${{ inputs.hf_repo }}"
- name: Download artifact name file
@@ -197,7 +192,7 @@ jobs:
- name: Upload to Hugging Face
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_OIDC_RESOURCE: datasets/${{ inputs.hf_repo }}
ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }}
run: |
hf upload ${{ inputs.hf_repo }} \
@@ -1,73 +0,0 @@
name: Compile warp for sunnypilot modeld
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * 0'
jobs:
compile_warps:
runs-on: [self-hosted, chestnut]
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set environment variables
run: |
source /etc/profile
export UV_PROJECT_ENVIRONMENT=${HOME}/venv
export UV_PYTHON_PREFERENCE=managed
export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python
export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
uv sync --frozen
printenv >> $GITHUB_ENV
- name: Disable powersave
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable
- name: Compile Warp Kernels
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
mkdir -p openpilot/sunnypilot/modeld_v2/models/
MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')")
CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')")
echo "Model size: $MODEL_SIZE"
echo "Camera resolutions: $CAMERA_RES"
for res in $CAMERA_RES; do
NV12_INFO=$(python3 -c "from openpilot.system.camerad.cameras.nv12_info import get_nv12_info; w, h = map(int, '${res}'.split('x')); print(','.join(map(str, get_nv12_info(w, h))))")
WARP_PKL="openpilot/sunnypilot/modeld_v2/models/driving_warp_${res}_tinygrad.pkl"
echo "Compiling $WARP_PKL on QCOM"
taskset -c 7 env DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1 python3 tinygrad_repo/examples/openpilot/compile_warp.py \
--frame ${res/x/,},${NV12_INFO} --warp-to ${MODEL_SIZE} --layout yuv420 --frames 2 --output "${WARP_PKL}"
BIG_WARP_PKL="openpilot/sunnypilot/modeld_v2/models/big_driving_warp_${res}_tinygrad.pkl"
echo "Compiling $BIG_WARP_PKL on AMD"
taskset -c 7 env DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32 python3 tinygrad_repo/examples/openpilot/compile_warp.py \
--frame ${res/x/,},${NV12_INFO} --warp-to ${MODEL_SIZE} --layout yuv420 --frames 2 --output "${BIG_WARP_PKL}"
done
- name: Re-enable powersave
if: always()
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
- name: Create Pull Request
uses: peter-evans/create-pull-request@9153d834b60caba6d51c9b9510b087acf9f33f83
with:
author: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "[bot] Update warp pkl for modeld_v2"
title: "[bot] Update modeld_v2 Warp"
branch: "auto/compile-warp-kernels"
base: "master"
delete-branch: true
labels: bot
add-paths: |
openpilot/sunnypilot/modeld_v2/models/*.pkl
+8 -9
View File
@@ -18,25 +18,24 @@ concurrency:
env:
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: lfs.fetchexclude
GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl
GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx
jobs:
docs:
name: build docs
runs-on: ${{
(github.repository == 'commaai/openpilot') &&
((github.event_name != 'pull_request') ||
(github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))
&& fromJSON('["namespace-profile-amd64-8x16"]')
|| fromJSON('["ubuntu-24.04"]') }}
runs-on: ubuntu-24.04
steps:
- uses: commaai/timeout@v1
- uses: actions/checkout@v7
- run: ./tools/op.sh setup
with:
submodules: true
# Build
- name: Build docs
run: ./tools/op.sh docs --build
run: |
git lfs pull
python docs/serve.py --build
# Push to docs.comma.ai
- uses: actions/checkout@v7
@@ -46,13 +46,6 @@ runs:
printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${CANONICAL}.chunk${CHUNK_IDX}" >> "$DOWNLOAD_LIST"
done < <(echo "$ARTIFACT" | jq -r '.chunks[].file_name')
echo "$NUM_CHUNKS" > "${DEST_DIR}/${CANONICAL}.chunkmanifest"
if [ "$CANONICAL" = "dmonitoring_model_tinygrad.pkl" ]; then
for warp in dm_warp_1928x1208_tinygrad.pkl dm_warp_1344x760_tinygrad.pkl; do
ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${warp}', safe=':/'))")
printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${warp}" >> "$DOWNLOAD_LIST"
done
fi
}
echo "$MODELS_JSON" | jq -c '.[]' | while IFS= read -r model; do
+28
View File
@@ -0,0 +1,28 @@
name: Release Drafter
on:
push:
branches:
- master
tags:
- 'v*'
pull_request_target:
types: [opened, reopened, synchronize]
workflow_dispatch:
permissions:
contents: read
jobs:
update_release_draft:
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6
with:
config-name: release-drafter.yml
prerelease: ${{ !startsWith(github.ref, 'refs/tags/v') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -5
View File
@@ -7,7 +7,7 @@ on:
env:
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: lfs.fetchexclude
GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl
GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx
jobs:
build___nightly:
@@ -34,7 +34,3 @@ jobs:
- run: ./tools/op.sh setup
- name: Push __nightly
run: BRANCH=__nightly tools/release/build_stripped.sh
- name: Push chestnut nightly
run: |
git lfs pull --exclude=''
INCLUDE_BIG_MODEL=1 BRANCH=__nightly-chestnut tools/release/build_stripped.sh
+1 -1
View File
@@ -11,7 +11,7 @@ env:
PYTHONPATH: ${{ github.workspace }}
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: lfs.fetchexclude
GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl
GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx
jobs:
package_updates:
+64 -77
View File
@@ -103,27 +103,25 @@ jobs:
- run: |
cd ${{ github.workspace }}/openpilot/openpilot
if [ "${{ inputs.target_hardware }}" != "chestnut" ]; then
git lfs pull -X "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/dmonitoring_*.onnx,**/selfdrive/modeld/models/big_*.pkl,**/selfdrive/modeld/models/dmonitoring_*.pkl"
rm -f selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/dmonitoring_*.onnx selfdrive/modeld/models/big_*.pkl selfdrive/modeld/models/dmonitoring_*.pkl
git lfs pull -X "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/dmonitoring_*.onnx"
rm -f selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/dmonitoring_*.onnx
else
git lfs pull -I "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/big_*.pkl" -X ""
find selfdrive/modeld/models -type f \( -name "*.onnx" -o -name "*.pkl" \) ! -name "big_*.onnx" ! -name "big_*.pkl" -delete
git lfs pull -I "**/selfdrive/modeld/models/big_*.onnx" -X ""
find selfdrive/modeld/models -name "*.onnx" ! -name "big_*.onnx" -delete
fi
if grep -lIF "version https://git-lfs.github.com/spec/v1" selfdrive/modeld/models/*.onnx selfdrive/modeld/models/*.pkl 2>/dev/null; then
echo "::error::the ONNX or PKL files above are still LFS pointers, not real models"
if grep -lIF "version https://git-lfs.github.com/spec/v1" selfdrive/modeld/models/*.onnx; then
echo "::error::the ONNX files above are still LFS pointers, not real models"
exit 1
fi
- name: 'Upload Artifact'
uses: actions/upload-artifact@v4
with:
name: models-${{ env.REF }}${{ inputs.artifact_suffix }}
path: |
${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.onnx
${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.pkl
path: ${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.onnx
if-no-files-found: error
build_model:
runs-on: [self-hosted, "${{ inputs.target_hardware == 'chestnut' && 'chestnut' || 'tici' }}"]
runs-on: [self-hosted, chestnut]
needs: get_model
env:
MODEL_NAME: ${{ inputs.custom_name || inputs.upstream_branch }} (${{ needs.get_model.outputs.model_date }})
@@ -190,7 +188,7 @@ jobs:
if [ "${{ inputs.target_hardware }}" == "chestnut" ]; then
echo "CHESTNUT build"
export CHESTNUT=1
TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_OCCUPANCY_OPT=1"
TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM WARP_DEV=QCOM FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2"
OUTPUT_PKL="${{ env.MODELS_DIR }}/big_driving_tinygrad.pkl"
else
echo "QCOM build"
@@ -198,75 +196,64 @@ jobs:
OUTPUT_PKL="${{ env.MODELS_DIR }}/driving_tinygrad.pkl"
fi
NATIVE_PKL=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "*.pkl" -print -quit)
# Generate metadata for all ONNX files
find "${{ env.MODELS_DIR }}" -maxdepth 1 -name '*.onnx' | while IFS= read -r onnx_file; do
echo "Generating metadata: $onnx_file"
env ${TG_FLAGS_QCOM} python3 "${{ env.MODELS_DIR }}/../get_model_metadata.py" "$onnx_file" || true
done
if [ -n "$NATIVE_PKL" ]; then
echo "Found native precompiled pkl: $NATIVE_PKL"
if [ "$NATIVE_PKL" != "$OUTPUT_PKL" ]; then
mv "$NATIVE_PKL" "$OUTPUT_PKL"
# Detect model type and build compile args
VISION_ONNX=""
for f in "${{ env.MODELS_DIR }}/driving_vision.onnx" "${{ env.MODELS_DIR }}/big_driving_vision.onnx"; do
[ -f "$f" ] && VISION_ONNX="$f" && break
done
POLICY_ONNX=""
for f in "${{ env.MODELS_DIR }}/driving_policy.onnx" "${{ env.MODELS_DIR }}/big_driving_policy.onnx"; do
[ -f "$f" ] && POLICY_ONNX="$f" && break
done
OFF_POLICY_ONNX=""
for f in "${{ env.MODELS_DIR }}/driving_off_policy.onnx" "${{ env.MODELS_DIR }}/big_driving_off_policy.onnx"; do
[ -f "$f" ] && OFF_POLICY_ONNX="$f" && break
done
ON_POLICY_ONNX=""
for f in "${{ env.MODELS_DIR }}/driving_on_policy.onnx" "${{ env.MODELS_DIR }}/big_driving_on_policy.onnx"; do
[ -f "$f" ] && ON_POLICY_ONNX="$f" && break
done
SUPERCOMBO_ONNX=""
for f in "${{ env.MODELS_DIR }}/supercombo.onnx" "${{ env.MODELS_DIR }}/driving_supercombo.onnx" "${{ env.MODELS_DIR }}/big_supercombo.onnx" "${{ env.MODELS_DIR }}/big_driving_supercombo.onnx"; do
[ -f "$f" ] && SUPERCOMBO_ONNX="$f" && break
done
MODEL_TYPE="" ONNX_ARGS="" OUTPUT_NAME=""
if [ -f "$VISION_ONNX" ]; then
ONNX_ARGS="--vision-onnx $VISION_ONNX"
if [ -f "$ON_POLICY_ONNX" ] && [ -f "$OFF_POLICY_ONNX" ]; then
MODEL_TYPE=vision_multi_policy
ONNX_ARGS="$ONNX_ARGS --off-policy-onnx $OFF_POLICY_ONNX --on-policy-onnx $ON_POLICY_ONNX"
elif [ -f "$OFF_POLICY_ONNX" ] && [ -f "$POLICY_ONNX" ]; then
MODEL_TYPE=vision_multi_policy
ONNX_ARGS="$ONNX_ARGS --policy-onnx $POLICY_ONNX --off-policy-onnx $OFF_POLICY_ONNX"
elif [ -f "$POLICY_ONNX" ]; then
MODEL_TYPE=vision_policy
ONNX_ARGS="$ONNX_ARGS --policy-onnx $POLICY_ONNX"
fi
echo "Chunking pkl"
python3 -c "from openpilot.common.file_chunker import chunk_file, get_chunk_targets; import os; p='$OUTPUT_PKL'; chunk_file(p, get_chunk_targets(p, os.path.getsize(p)))"
else
# Generate metadata for all ONNX files
find "${{ env.MODELS_DIR }}" -maxdepth 1 -name '*.onnx' | while IFS= read -r onnx_file; do
echo "Generating metadata: $onnx_file"
env ${TG_FLAGS_QCOM} python3 "${{ env.MODELS_DIR }}/../get_model_metadata.py" "$onnx_file" || true
done
elif [ -f "$SUPERCOMBO_ONNX" ]; then
MODEL_TYPE=supercombo
ONNX_ARGS="--supercombo-onnx $SUPERCOMBO_ONNX"
fi
# Detect model type and build compile args
VISION_ONNX=""
for f in "${{ env.MODELS_DIR }}/driving_vision.onnx" "${{ env.MODELS_DIR }}/big_driving_vision.onnx"; do
[ -f "$f" ] && VISION_ONNX="$f" && break
done
POLICY_ONNX=""
for f in "${{ env.MODELS_DIR }}/driving_policy.onnx" "${{ env.MODELS_DIR }}/big_driving_policy.onnx"; do
[ -f "$f" ] && POLICY_ONNX="$f" && break
done
OFF_POLICY_ONNX=""
for f in "${{ env.MODELS_DIR }}/driving_off_policy.onnx" "${{ env.MODELS_DIR }}/big_driving_off_policy.onnx"; do
[ -f "$f" ] && OFF_POLICY_ONNX="$f" && break
done
ON_POLICY_ONNX=""
for f in "${{ env.MODELS_DIR }}/driving_on_policy.onnx" "${{ env.MODELS_DIR }}/big_driving_on_policy.onnx"; do
[ -f "$f" ] && ON_POLICY_ONNX="$f" && break
done
SUPERCOMBO_ONNX=""
for f in "${{ env.MODELS_DIR }}/supercombo.onnx" "${{ env.MODELS_DIR }}/driving_supercombo.onnx" "${{ env.MODELS_DIR }}/big_supercombo.onnx" "${{ env.MODELS_DIR }}/big_driving_supercombo.onnx"; do
[ -f "$f" ] && SUPERCOMBO_ONNX="$f" && break
done
MODEL_TYPE="" ONNX_ARGS="" OUTPUT_NAME=""
if [ -f "$VISION_ONNX" ]; then
ONNX_ARGS="--vision-onnx $VISION_ONNX"
if [ -f "$ON_POLICY_ONNX" ] && [ -f "$OFF_POLICY_ONNX" ]; then
MODEL_TYPE=vision_multi_policy
ONNX_ARGS="$ONNX_ARGS --off-policy-onnx $OFF_POLICY_ONNX --on-policy-onnx $ON_POLICY_ONNX"
elif [ -f "$OFF_POLICY_ONNX" ] && [ -f "$POLICY_ONNX" ]; then
MODEL_TYPE=vision_multi_policy
ONNX_ARGS="$ONNX_ARGS --policy-onnx $POLICY_ONNX --off-policy-onnx $OFF_POLICY_ONNX"
elif [ -f "$POLICY_ONNX" ]; then
MODEL_TYPE=vision_policy
ONNX_ARGS="$ONNX_ARGS --policy-onnx $POLICY_ONNX"
fi
elif [ -f "$SUPERCOMBO_ONNX" ]; then
MODEL_TYPE=supercombo
ONNX_ARGS="--supercombo-onnx $SUPERCOMBO_ONNX"
fi
if [ -n "$MODEL_TYPE" ]; then
echo "Detected: $MODEL_TYPE -> $OUTPUT_PKL"
env ${TG_FLAGS} python3 "$COMPILE_MODELD" \
--model-type $MODEL_TYPE \
--model-size $MODEL_SIZE \
--camera-resolutions $CAMERA_RES \
$ONNX_ARGS \
--output "$OUTPUT_PKL"
fi
if [ -n "$MODEL_TYPE" ]; then
echo "Detected: $MODEL_TYPE -> $OUTPUT_PKL"
env ${TG_FLAGS} python3 "$COMPILE_MODELD" \
--model-type $MODEL_TYPE \
--model-size $MODEL_SIZE \
--camera-resolutions $CAMERA_RES \
$ONNX_ARGS \
--output "$OUTPUT_PKL"
fi
- name: Prepare Output
@@ -216,9 +216,6 @@ jobs:
needs: [ prepare_strategy ]
runs-on: ubuntu-24.04
if: ${{ needs.prepare_strategy.outputs.include_big_model == 'true' }}
concurrency:
group: prepare-chestnut
cancel-in-progress: false
outputs:
onnx_sha256: ${{ steps.resolve.outputs.onnx_sha256 }}
env:
@@ -226,13 +223,14 @@ jobs:
HF_REPO: sunnypilot/sunnypilot_models_v1
HF_DEFAULTS_PATH: models/defaults/big
steps:
- name: Resolve tinygrad ref via API
- name: Resolve ONNX hash and tinygrad ref via API
id: resolve
run: |
REF="${{ github.head_ref || github.ref_name }}"
BLOB_SHA=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl?ref=${REF}" --jq '.sha')
[ -n "$BLOB_SHA" ] || { echo "::error::Failed to resolve big_driving_tinygrad.pkl blob SHA"; exit 1; }
ONNX_HASH=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx?ref=${REF}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2)
echo "ONNX hash: $ONNX_HASH"
echo "onnx_sha256=$ONNX_HASH" >> $GITHUB_OUTPUT
TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha')
echo "tinygrad ref: $TINYGRAD_REF"
@@ -240,7 +238,7 @@ jobs:
JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json"
check_defaults() {
DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 2>/dev/null) || return 1
DEFAULTS=$(curl -fsSL "$JSON_URL" 2>/dev/null) || return 1
TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null)
[ "$TINYGRAD_MATCH" = "true" ] || return 1
BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null)
@@ -248,41 +246,24 @@ jobs:
}
if check_defaults; then
echo "HF defaults match repo tinygrad ref"
echo "HF defaults match repo ONNX hash and tinygrad ref"
exit 0
fi
echo "No matching model on HF — dispatching build"
gh workflow run build-default-models.yaml --ref "$REF" -f target=big
sleep 10
BUILD_RUN_ID=$(gh run list --workflow build-default-models.yaml --branch "$REF" --limit 1 --json databaseId --jq '.[0].databaseId')
echo "Dispatched build run: $BUILD_RUN_ID"
echo "Waiting for build run to complete..."
echo "Polling HF for big model availability..."
for i in $(seq 1 90); do
sleep 30
STATUS=$(gh api "repos/${GH_REPO}/actions/runs/${BUILD_RUN_ID}" --jq '.status')
CONCLUSION=$(gh api "repos/${GH_REPO}/actions/runs/${BUILD_RUN_ID}" --jq '.conclusion')
echo "Poll $i/90: status=$STATUS conclusion=$CONCLUSION"
if [ "$STATUS" = "completed" ]; then
if [ "$CONCLUSION" = "success" ]; then
echo "Build run succeeded, verifying HF..."
sleep 10
if check_defaults; then
echo "Big model verified on HF"
exit 0
fi
echo "::error::Build succeeded but model not found on HF"
exit 1
else
echo "::error::Build run failed with conclusion=$CONCLUSION"
exit 1
fi
if check_defaults; then
echo "Big model available on HF after $((i * 30))s"
exit 0
fi
echo "Poll $i/90: not yet available"
done
echo "::error::Build run did not complete within 45 minutes"
echo "::error::Big model not available on HF after 45 minutes"
exit 1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -296,9 +277,6 @@ jobs:
prepare_small_model:
needs: [ prepare_strategy ]
runs-on: ubuntu-24.04
concurrency:
group: prepare-small-model
cancel-in-progress: false
outputs:
driving_onnx_sha256: ${{ steps.resolve.outputs.driving_onnx_sha256 }}
env:
@@ -306,12 +284,14 @@ jobs:
HF_REPO: sunnypilot/sunnypilot_models_v1
HF_DEFAULTS_PATH: models/defaults/small
steps:
- name: Resolve tinygrad ref via API
- name: Resolve ONNX hash and tinygrad ref via API
id: resolve
run: |
REF="${{ github.head_ref || github.ref_name }}"
BLOB_SHA=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/driving_supercombo.onnx?ref=${REF}" --jq '.sha')
DRIVING_HASH=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/driving_supercombo.onnx?ref=${REF}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2)
echo "Driving ONNX hash: $DRIVING_HASH"
echo "driving_onnx_sha256=$DRIVING_HASH" >> $GITHUB_OUTPUT
TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha')
echo "tinygrad ref: $TINYGRAD_REF"
@@ -319,7 +299,7 @@ jobs:
JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json"
check_defaults() {
DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 2>/dev/null) || return 1
DEFAULTS=$(curl -fsSL "$JSON_URL" 2>/dev/null) || return 1
TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null)
[ "$TINYGRAD_MATCH" = "true" ] || return 1
DRIVING=$(echo "$DEFAULTS" | jq --arg hash "$DRIVING_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null)
@@ -327,41 +307,24 @@ jobs:
}
if check_defaults; then
echo "HF defaults match repo tinygrad ref"
echo "HF defaults match repo ONNX hash and tinygrad ref"
exit 0
fi
echo "No matching model on HF — dispatching build"
gh workflow run build-default-models.yaml --ref "$REF" -f target=small
sleep 10
BUILD_RUN_ID=$(gh run list --workflow build-default-models.yaml --branch "$REF" --limit 1 --json databaseId --jq '.[0].databaseId')
echo "Dispatched build run: $BUILD_RUN_ID"
echo "Waiting for build run to complete..."
echo "Polling HF for model availability..."
for i in $(seq 1 60); do
sleep 30
STATUS=$(gh api "repos/${GH_REPO}/actions/runs/${BUILD_RUN_ID}" --jq '.status')
CONCLUSION=$(gh api "repos/${GH_REPO}/actions/runs/${BUILD_RUN_ID}" --jq '.conclusion')
echo "Poll $i/60: status=$STATUS conclusion=$CONCLUSION"
if [ "$STATUS" = "completed" ]; then
if [ "$CONCLUSION" = "success" ]; then
echo "Build run succeeded, verifying HF..."
sleep 10
if check_defaults; then
echo "Small model verified on HF"
exit 0
fi
echo "::error::Build succeeded but model not found on HF"
exit 1
else
echo "::error::Build run failed with conclusion=$CONCLUSION"
exit 1
fi
if check_defaults; then
echo "Model available on HF after $((i * 30))s"
exit 0
fi
echo "Poll $i/60: not yet available"
done
echo "::error::Small model build did not complete within 30 minutes"
echo "::error::Small driving model not available on HF after 30 minutes"
exit 1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -375,9 +338,6 @@ jobs:
prepare_dm_model:
needs: [ prepare_strategy ]
runs-on: ubuntu-24.04
concurrency:
group: prepare-dm-model
cancel-in-progress: false
outputs:
dm_onnx_sha256: ${{ steps.resolve.outputs.dm_onnx_sha256 }}
env:
@@ -385,12 +345,14 @@ jobs:
HF_REPO: sunnypilot/sunnypilot_models_v1
HF_DEFAULTS_PATH: models/defaults/dm
steps:
- name: Resolve tinygrad ref via API
- name: Resolve ONNX hash and tinygrad ref via API
id: resolve
run: |
REF="${{ github.head_ref || github.ref_name }}"
BLOB_SHA=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx?ref=${REF}" --jq '.sha')
DM_HASH=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx?ref=${REF}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2)
echo "DM ONNX hash: $DM_HASH"
echo "dm_onnx_sha256=$DM_HASH" >> $GITHUB_OUTPUT
TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha')
echo "tinygrad ref: $TINYGRAD_REF"
@@ -398,7 +360,7 @@ jobs:
JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json"
check_defaults() {
DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 2>/dev/null) || return 1
DEFAULTS=$(curl -fsSL "$JSON_URL" 2>/dev/null) || return 1
TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null)
[ "$TINYGRAD_MATCH" = "true" ] || return 1
DM=$(echo "$DEFAULTS" | jq --arg hash "$DM_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null)
@@ -406,41 +368,24 @@ jobs:
}
if check_defaults; then
echo "HF defaults match DM tinygrad ref"
echo "HF defaults match DM ONNX hash and tinygrad ref"
exit 0
fi
echo "No matching DM model on HF — dispatching build"
gh workflow run build-default-models.yaml --ref "$REF" -f target=dm
sleep 10
BUILD_RUN_ID=$(gh run list --workflow build-default-models.yaml --branch "$REF" --limit 1 --json databaseId --jq '.[0].databaseId')
echo "Dispatched build run: $BUILD_RUN_ID"
echo "Waiting for build run to complete..."
echo "Polling HF for DM model availability..."
for i in $(seq 1 60); do
sleep 30
STATUS=$(gh api "repos/${GH_REPO}/actions/runs/${BUILD_RUN_ID}" --jq '.status')
CONCLUSION=$(gh api "repos/${GH_REPO}/actions/runs/${BUILD_RUN_ID}" --jq '.conclusion')
echo "Poll $i/60: status=$STATUS conclusion=$CONCLUSION"
if [ "$STATUS" = "completed" ]; then
if [ "$CONCLUSION" = "success" ]; then
echo "Build run succeeded, verifying HF..."
sleep 10
if check_defaults; then
echo "DM model verified on HF"
exit 0
fi
echo "::error::Build succeeded but DM model not found on HF"
exit 1
else
echo "::error::Build run failed with conclusion=$CONCLUSION"
exit 1
fi
if check_defaults; then
echo "DM model available on HF after $((i * 30))s"
exit 0
fi
echo "Poll $i/60: not yet available"
done
echo "::error::DM model build did not complete within 30 minutes"
echo "::error::DM model not available on HF after 30 minutes"
exit 1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+78
View File
@@ -0,0 +1,78 @@
name: Debug Discourse Posting
on:
push:
jobs:
test-discourse-post:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Post test message to Discourse
uses: ./.github/workflows/post-to-discourse
with:
discourse-url: ${{ vars.DISCOURSE_URL }}
api-key: ${{ secrets.DISCOURSE_API_KEY }}
api-username: ${{ secrets.DISCOURSE_API_USERNAME }}
topic-id: ${{ vars.DISCOURSE_UPDATES_TOPIC_ID }}
message: |
## 🧪 Test Post from GitHub Actions
**This is a test post to verify Discourse integration**
- **Workflow**: ${{ github.workflow }}
- **Run Number**: #${{ github.run_number }}
- **Branch**: `${{ github.ref_name }}`
- **Commit**: ${{ github.sha }}
- **Actor**: @${{ github.actor }}
- **Timestamp**: ${{ github.event.head_commit.timestamp }}
---
### Fake Build Info (for testing)
- **Version**: 0.9.8-test
- **Build**: #42
- **Branch**: release-test
[View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
*This is an automated test message. Drive safe! 🚗💨*
- name: Create topic on Discourse
uses: ./.github/workflows/post-to-discourse
with:
discourse-url: ${{ vars.DISCOURSE_URL }}
api-key: ${{ secrets.DISCOURSE_API_KEY }}
api-username: ${{ secrets.DISCOURSE_API_USERNAME }}
#topic-id: ${{ vars.DISCOURSE_UPDATES_TOPIC_ID }}
category-id: 4
title: "This is a test of a new topic instead of a reply"
message: |
## 🧪 Test Post from GitHub Actions
**This is a test post to verify Discourse integration**
- **Workflow**: ${{ github.workflow }}
- **Run Number**: #${{ github.run_number }}
- **Branch**: `${{ github.ref_name }}`
- **Commit**: ${{ github.sha }}
- **Actor**: @${{ github.actor }}
- **Timestamp**: ${{ github.event.head_commit.timestamp }}
---
### Fake Build Info (for testing)
- **Version**: 0.9.8-test
- **Build**: #42
- **Branch**: release-test
[View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
*This is an automated test message. Drive safe! 🚗💨*
- name: Display results
if: always()
run: |
echo "::notice::Discourse post test completed"
echo "Check your Discourse topic to verify the post appeared correctly"
-84
View File
@@ -1,84 +0,0 @@
name: Test Models Compatibility With Tinygrad Changes
on:
pull_request:
paths:
- 'tinygrad_repo'
workflow_dispatch:
jobs:
generate-matrix:
runs-on: ubuntu-latest
outputs:
models: ${{ steps.set-matrix.outputs.models }}
steps:
- uses: actions/checkout@v4
- name: Fetch and Parse json
id: set-matrix
run: |
python3 -c '
import json, urllib.request, os, re
with open("openpilot/sunnypilot/models/fetcher.py", "r") as f:
urls = re.findall(r"MODEL_URL(?:_CHESTNUT)?\s*=\s*[\"'"'"']([^\"'"'"']+)[\"'"'"']", f.read())
artifacts = []
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"])
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write(f"models={json.dumps(artifacts)}\n")
'
test-model:
name: Test ${{ matrix.artifact.file_name }}
needs: generate-matrix
runs-on: ubuntu-latest
container: ghcr.io/commaai/openpilot-base:latest
strategy:
fail-fast: false
matrix:
artifact: ${{ fromJson(needs.generate-matrix.outputs.models) }}
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Download Model Chunks in Parallel
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
mkdir -p /tmp/model_chunks
echo '${{ toJson(matrix.artifact.chunks) }}' > chunks.json
BASE_URL="${{ matrix.artifact.download_uri.url }}"
export BASE_DIR=$(dirname "$BASE_URL")
python3 -c '
import json, os
with open("chunks.json") as f:
chunks = json.load(f)
manifest_path = f"/tmp/model_chunks/${{ matrix.artifact.file_name }}.chunkmanifest"
with open(manifest_path, "w") as f:
f.write(str(len(chunks)))
base_dir = os.environ["BASE_DIR"]
hf_token = os.environ.get("HF_TOKEN", "")
with open("/tmp/curl_config.txt", "w") as f:
for c in chunks:
fn = c["file_name"]
f.write(f"url = \"{base_dir}/{fn}\"\noutput = \"/tmp/model_chunks/{fn}\"\n")
if hf_token:
f.write(f"header = \"Authorization: Bearer {hf_token}\"\n")
'
curl -Z --parallel-immediate --parallel-max 12 --retry 3 --retry-all-errors -s -S -f -L -K /tmp/curl_config.txt
- name: Run Model Compatibility Test
env:
MODEL_BASE_NAME: ${{ matrix.artifact.file_name }}
MODEL_CHUNK_DIR: "/tmp/model_chunks"
PYTHONPATH: ".:./tinygrad_repo"
run: |
python3 -m pytest openpilot/sunnypilot/modeld_v2/tests/test_models.py
+1 -1
View File
@@ -22,7 +22,7 @@ env:
PYTHONPATH: ${{ github.workspace }}
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: lfs.fetchexclude
GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl
GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx
jobs:
build_release:
-2
View File
@@ -50,8 +50,6 @@ st[0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z]
*.stats
*.pkl
*.pkl*
!openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl
!openpilot/sunnypilot/modeld_v2/models/*.pkl
config.json
compile_commands.json
compare_runtime*.html
-1
View File
@@ -19,4 +19,3 @@
[submodule "sunnypilot/neural_network_data"]
path = openpilot/sunnypilot/neural_network_data
url = https://github.com/sunnypilot/neural-network-data.git
-1
View File
@@ -1,5 +1,4 @@
[lfs]
url = https://gitlab.com/sunnypilot/public/sunnypilot-new-lfs.git/info/lfs
pushurl = ssh://git@gitlab.com/sunnypilot/public/sunnypilot-new-lfs.git
locksverify = false
Vendored
+4 -20
View File
@@ -12,17 +12,16 @@ def retryWithDelay(int maxRetries, int delay, Closure body) {
def device(String ip, String step_label, String cmd) {
withCredentials([file(credentialsId: 'id_rsa', variable: 'key_file')]) {
def ssh_cmd = """
ssh -o ControlMaster=no -o ControlPath=none -o ConnectTimeout=5 -o ServerAliveInterval=5 -o ServerAliveCountMax=12 -o BatchMode=yes -o StrictHostKeyChecking=no -i ${key_file} 'comma@${ip}' exec setpriv --pdeathsig HUP /usr/bin/bash <<'END'
ssh -o ControlMaster=auto -o ControlPath=/tmp/ssh_control_%C -o ControlPersist=yes -o ConnectTimeout=5 -o ServerAliveInterval=5 -o ServerAliveCountMax=2 -o BatchMode=yes -o StrictHostKeyChecking=no -i ${key_file} 'comma@${ip}' exec /usr/bin/bash <<'END'
set -e
export TERM=xterm-256color
trap 'kill 0' HUP # stop this process group on SSH disconnect
shopt -s huponexit # kill all child processes when the shell exits
export CI=1
export PYTHONWARNINGS=error
export PYTHONFAULTHANDLER=1
export COMMA_CACHE=/data/tmp/comma_download_cache
#export LOGPRINT=debug # this has gotten too spammy...
export TEST_DIR=${env.TEST_DIR}
@@ -70,8 +69,7 @@ export LD_LIBRARY_PATH="\$(python -c 'import ffmpeg; print(ffmpeg.LIB_DIR)'):/us
ln -snf ${env.TEST_DIR} /data/pythonpath
cd ${env.TEST_DIR} || true
time ( ${cmd} ) &
wait \$!
time ${cmd}
END"""
sh script: ssh_cmd, label: step_label
@@ -171,7 +169,7 @@ node {
env.GIT_BRANCH = checkout(scm).GIT_BRANCH
env.GIT_COMMIT = checkout(scm).GIT_COMMIT
def excludeBranches = ['__nightly', '__nightly-chestnut', 'devel', 'devel-staging',
def excludeBranches = ['__nightly', 'devel', 'devel-staging',
'release-tizi', 'release-tizi-staging', 'release-mici', 'release-mici-staging', 'testing-closet*', 'hotfix-*']
def excludeRegex = excludeBranches.join('|').replaceAll('\\*', '.*')
@@ -203,12 +201,6 @@ node {
)
}
if (env.BRANCH_NAME == '__nightly-chestnut') {
deviceStage("build nightly-chestnut", "mici-chestnut-ci", [], [
step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 PANDA_DEBUG_BUILD=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"),
])
}
if (!env.BRANCH_NAME.matches(excludeRegex)) {
parallel (
'onroad tests': {
@@ -260,14 +252,6 @@ node {
step("test amp", "./openpilot/common/hardware/comma/tests/test_amplifier.py"),
])
},
'chestnut': {
deviceStage("chestnut", "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [
step("build", "./openpilot/selfdrive/test/chestnut.sh"),
step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"),
step("onroad tests", "./openpilot/selfdrive/test/test_onroad.py TestChestnutOnroad", [timeout: 120]),
step("test power draw", "./openpilot/selfdrive/test/test_power_draw.py"),
])
},
)
}
+1 -2
View File
@@ -87,6 +87,7 @@ acados_include_dirs = [
# vendored in commaai/dependencies.
allowed_system_libs = {
"EGL", "GLESv2", "GL",
"Qt5Charts", "Qt5Core", "Qt5Gui", "Qt5Widgets",
"dl", "drm", "gbm", "m", "pthread",
}
@@ -345,8 +346,6 @@ AddPostAction(BUILD_TARGETS or [Dir('.')], prune_cache_dir)
def check_build_product_size(target, source, env):
limit = 50 * 1024 * 1024 # GitHub max size
for t in target:
if str(t).endswith('.pkl'): # chunked during release packaging
continue
if hasattr(t, 'isfile') and t.isfile() and (size := os.path.getsize(t.abspath)) > limit:
raise SCons.Errors.UserError(f"{t} is {size / (1024 * 1024):.1f} MiB, exceeding the {limit / (1024 * 1024):.1f} MiB limit")
if not GetOption('extras'):
+7 -7
View File
@@ -34,7 +34,7 @@ A supported vehicle is one that just works when you install a comma device. All
|Chrysler|Pacifica Hybrid 2019-25|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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>12</sup>](#footnotes)|Born 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[<sup>16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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 Born 2021-23">Buy Here</a></sub></details>|||
|Dodge|Durango 2020-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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>|||
@@ -99,7 +99,7 @@ A supported vehicle is one that just works when you install a comma device. All
|Honda|Fit 2018-20|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<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|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<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>|||
|Honda|HR-V 2019-22|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<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 HR-V 2019-22">Buy Here</a></sub></details>|||
|Honda|HR-V 2023-27|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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 HR-V 2023-27">Buy Here</a></sub></details>|||
|Honda|HR-V 2023-25|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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 HR-V 2023-25">Buy Here</a></sub></details>|||
|Honda|Insight 2019-22|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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 Insight 2019-22">Buy Here</a></sub></details>|||
|Honda|Inspire 2018|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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 Inspire 2018">Buy Here</a></sub></details>|||
|Honda|N-Box 2018|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|11 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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 N-Box 2018">Buy Here</a></sub></details>|||
@@ -268,8 +268,8 @@ A supported vehicle is one that just works when you install a comma device. All
|Š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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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>|||
|Tesla[<sup>10</sup>](#footnotes)|Model Y (with HW3) 2020-23[<sup>9</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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-23">Buy Here</a></sub></details>|||
|Tesla[<sup>10</sup>](#footnotes)|Model Y (with HW4) 2024-25[<sup>9</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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) 2024-25">Buy Here</a></sub></details>|||
|Toyota|Alphard 2019-20|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<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>|||
@@ -335,8 +335,8 @@ A supported vehicle is one that just works when you install a comma device. All
|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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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)|ID.4 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[<sup>16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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 ID.4 2021-23">Buy Here</a></sub></details>|||
|Volkswagen[<sup>12</sup>](#footnotes)|ID.4 2024-25|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[<sup>16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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 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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<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>|||
@@ -363,7 +363,7 @@ A supported vehicle is one that just works when you install a comma device. All
<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>9</sup>Some 2023 model years have HW4. 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 />
+2 -2
View File
@@ -5,10 +5,10 @@ The site is updated on pushes to master by this [workflow](../.github/workflows/
**1. Build the site**
``` bash
op docs --build
python docs/serve.py --build
```
**2. Run the site locally** (rebuilds on change)
``` bash
op docs
python docs/serve.py
```
+10 -10
View File
@@ -18,21 +18,21 @@ function agnos_init {
sudo chmod 660 /dev/adsprpc-smd /dev/ion /dev/kgsl-3d0
# Check if AGNOS update is required
if [ "$(< /VERSION)" != "$AGNOS_VERSION" ]; then
if [ $(< /VERSION) != "$AGNOS_VERSION" ]; then
AGNOS_PY="$DIR/openpilot/common/hardware/comma/agnos.py"
MANIFEST="$DIR/openpilot/system/hardware/comma/agnos.json"
if "$AGNOS_PY" --verify "$MANIFEST"; then
if $AGNOS_PY --verify $MANIFEST; then
sudo reboot
fi
while true; do
"$DIR/openpilot/common/hardware/comma/updater" "$AGNOS_PY" "$MANIFEST"
$DIR/openpilot/common/hardware/comma/updater $AGNOS_PY $MANIFEST
done
fi
}
function launch {
# Remove orphaned git lock if it exists on boot
[ -f "$DIR/.git/index.lock" ] && rm -f "$DIR/.git/index.lock"
[ -f "$DIR/.git/index.lock" ] && rm -f $DIR/.git/index.lock
# Check to see if there's a valid overlay-based update available. Conditions
# are as follows:
@@ -44,7 +44,7 @@ function launch {
# that completed successfully and synced to disk.
if [ -f "${DIR}/.overlay_init" ]; then
find "${DIR}/.git" -newer "${DIR}/.overlay_init" | grep -q '.' 2> /dev/null
find ${DIR}/.git -newer ${DIR}/.overlay_init | grep -q '.' 2> /dev/null
if [ $? -eq 0 ]; then
echo "${DIR} has been modified, skipping overlay update installation"
else
@@ -53,9 +53,9 @@ function launch {
echo "Valid overlay update found, installing"
LAUNCHER_LOCATION="${BASH_SOURCE[0]}"
mv "$DIR" /data/safe_staging/old_openpilot
mv "${STAGING_ROOT}/finalized" "$DIR"
cd "$DIR"
mv $DIR /data/safe_staging/old_openpilot
mv "${STAGING_ROOT}/finalized" $DIR
cd $DIR
echo "Restarting launch script ${LAUNCHER_LOCATION}"
unset AGNOS_VERSION
@@ -69,7 +69,7 @@ function launch {
fi
# handle pythonpath
ln -sfn "$(pwd)" /data/pythonpath
ln -sfn $(pwd) /data/pythonpath
export PYTHONPATH="$PWD"
# submodule package symlinks for PYTHONPATH imports on device.
@@ -90,7 +90,7 @@ function launch {
# start manager
cd openpilot/system/manager
if [ ! -f "$DIR/prebuilt" ]; then
if [ ! -f $DIR/prebuilt ]; then
./build.py
fi
./manager.py
+1 -1
View File
@@ -16,7 +16,7 @@ export VECLIB_MAXIMUM_THREADS=1
export QCOM_PRIORITY=12
if [ -z "$AGNOS_VERSION" ]; then
export AGNOS_VERSION="19.7"
export AGNOS_VERSION="19.6"
fi
export STAGING_ROOT="/data/safe_staging"
-1
View File
@@ -353,7 +353,6 @@ struct OnroadEventSP @0xda96579883444c35 {
speedLimitPending @22;
e2eChime @23;
laneChangeRoadEdge @24;
bigModelReady @25;
}
}
-3
View File
@@ -725,7 +725,6 @@ struct ChestnutState {
pcieLtssm @7 :UInt8;
supplyVoltage @8 :UInt16; # mV
supplyCurrent @9 :Int16; # mA
supplyFault @10 :Bool;
}
struct RadarState @0x9a185389d6fdd05f {
@@ -1005,7 +1004,6 @@ struct DrivingModelData {
frameIdExtra @1 :UInt32;
frameDropPerc @6 :Float32;
modelExecutionTime @7 :Float32;
big @8 :Bool;
action @2 :ModelDataV2.Action;
@@ -2594,7 +2592,6 @@ struct Event {
clocks @35 :Clocks;
deviceState @6 :DeviceState;
chestnutState @152 :ChestnutState;
chestnutGpuState @153 :ChestnutState;
logMessage @18 :Text;
errorLogMessage @85 :Text;
+1 -2
View File
@@ -25,8 +25,7 @@ _services: dict[str, tuple] = {
"accelerometer": (True, 104., 104),
"temperatureSensor": (True, 2., 200),
"deviceState": (True, 2., 1),
"chestnutState": (True, 10., 1),
"chestnutGpuState": (False, 10.),
"chestnutState": (True, 10., 10),
"touch": (True, 20., 1),
"can": (True, 100., 2053, QueueSize.BIG), # decimation gives ~3 msgs in a full segment
"controlsState": (True, 100., 10, QueueSize.MEDIUM),
-7
View File
@@ -21,13 +21,6 @@ class Profile:
def is_comma(self) -> bool:
return self.provider == 'Webbing' and self.iccid.startswith('8985235')
@property
def display_name(self) -> str:
if self.is_comma:
return "comma prime"
name = self.nickname or self.provider or "<unnamed>"
return f"{name} (...{self.iccid[-4:]})"
class LPABase(ABC):
@abstractmethod
+1 -1
View File
@@ -613,7 +613,7 @@ def parse_lpa_activation_code(activation_code: str) -> tuple[str, str]:
if not activation_code.startswith("LPA:"):
raise ValueError("Invalid activation code format")
parts = activation_code[4:].split("$")
if len(parts) != 3 or not all(parts):
if len(parts) != 3:
raise ValueError("Invalid activation code format")
return parts[1], parts[2]
+11 -4
View File
@@ -24,7 +24,6 @@ def chunk_file(path, targets):
manifest_path, *chunk_paths = targets
actual_num_chunks = max(1, math.ceil(os.path.getsize(path) / CHUNK_SIZE))
assert len(chunk_paths) >= actual_num_chunks, f"Allowed {len(chunk_paths)} chunks but needs at least {actual_num_chunks}, for path {path}"
Path(manifest_path).unlink(missing_ok=True)
with open(path, 'rb') as f:
for chunk_path in chunk_paths:
with open(chunk_path, 'wb') as out:
@@ -32,6 +31,14 @@ def chunk_file(path, targets):
Path(manifest_path).write_text(str(len(chunk_paths)))
os.remove(path)
def get_existing_chunks(path):
if os.path.isfile(path):
return [path]
if os.path.isfile(manifest := get_manifest_path(path)):
num_chunks = int(Path(manifest).read_text().strip())
return _chunk_paths(path, num_chunks)
raise FileNotFoundError(path)
class ChunkStream(io.RawIOBase):
def __init__(self, paths):
self._paths = iter(paths)
@@ -59,11 +66,11 @@ class ChunkStream(io.RawIOBase):
def open_file_chunked(path):
manifest_path = get_manifest_path(path)
if os.path.isfile(path):
paths = [path]
elif os.path.isfile(manifest_path):
if os.path.isfile(manifest_path):
num_chunks = int(Path(manifest_path).read_text().strip())
paths = [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)]
elif os.path.isfile(path):
paths = [path]
else:
raise FileNotFoundError(path)
return io.BufferedReader(ChunkStream(paths))
-3
View File
@@ -145,9 +145,6 @@ class HardwareBase(ABC):
def get_modem_temperatures(self):
return []
def get_modem_state(self) -> dict:
return {}
def initialize_hardware(self):
pass
+11 -11
View File
@@ -56,29 +56,29 @@
},
{
"name": "boot",
"url": "https://commadist.azureedge.net/agnosupdate/boot-6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d.img.xz",
"hash": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d",
"hash_raw": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d",
"url": "https://commadist.azureedge.net/agnosupdate/boot-b30f5eef65ec3878f3aa3dcaf2cc95c09e2c1e661cd3a38e94da37dee76f68bd.img.xz",
"hash": "b30f5eef65ec3878f3aa3dcaf2cc95c09e2c1e661cd3a38e94da37dee76f68bd",
"hash_raw": "b30f5eef65ec3878f3aa3dcaf2cc95c09e2c1e661cd3a38e94da37dee76f68bd",
"size": 46897152,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "d12e1e5b9455b62a1464558716493b33e470d7a7e88da1c4105a3b21d0961808"
"ondevice_hash": "6650e4c46df99ae6dfd6ee895a34b8a2a3cc490a8ce18e16cc3c451c3f822b6e"
},
{
"name": "system",
"url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img.xz",
"hash": "74ffc9c551e1f29cda897ace8a69080fe644f8039977c6885f2b48362e39b744",
"hash_raw": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f",
"url": "https://commadist.azureedge.net/agnosupdate/system-5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3.img.xz",
"hash": "b134fd04e9da27fa1d359ea0f2742c216fa21a08b5c47e9be22ab3b0563d9b9b",
"hash_raw": "5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3",
"size": 4718592000,
"sparse": true,
"full_check": false,
"has_ab": true,
"ondevice_hash": "6a992680183685eea9db99d915219a37935f45989330d9b619e880450257f448",
"ondevice_hash": "91242772af771ae96fe2eebc105f2b80a7e1dbaaf6003c2574b62d51b806f468",
"alt": {
"hash": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f",
"url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img",
"hash": "5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3",
"url": "https://commadist.azureedge.net/agnosupdate/system-5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3.img",
"size": 4718592000
}
}
]
]
+1 -2
View File
@@ -5,7 +5,6 @@ import logging
import os
import select
import signal
import string
import struct
import subprocess
import tempfile
@@ -355,7 +354,7 @@ class Modem:
imei = ""
iccid = (self._atv("AT+QCCID", "+QCCID:") or "").rstrip("F")
if not all(c in string.hexdigits for c in iccid):
if not iccid.isdigit():
iccid = ""
imsi = first_line("AT+CIMI")
@@ -9,25 +9,22 @@ from openpilot.common.realtime import Ratekeeper
from openpilot.common.filter_simple import FirstOrderFilter
def read_power(panda=None):
if panda is not None and panda.get_type() == panda.HW_TYPE_CUATRO:
health = panda.health()
return health['voltage'] * health['current'] / 1e6
def read_power():
with open("/sys/bus/i2c/devices/0-0040/hwmon/hwmon1/power1_input") as f:
return int(f.read()) / 1e6
def sample_power(seconds=5, panda=None) -> list[float]:
def sample_power(seconds=5) -> list[float]:
rate = 123
rk = Ratekeeper(rate, print_delay_threshold=None)
pwrs = []
for _ in range(rate*seconds):
pwrs.append(read_power(panda))
pwrs.append(read_power())
rk.keep_time()
return pwrs
def get_power(seconds=5, panda=None):
pwrs = sample_power(seconds, panda)
def get_power(seconds=5):
pwrs = sample_power(seconds)
return np.mean(pwrs)
def wait_for_power(min_pwr, max_pwr, min_secs_in_range, timeout):
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6a7adb302d378dda7b1788a841b89e4f905c872550a70a76650dcde977b4ece0
size 24709209
oid sha256:3a94ab8395f20d20a9d5a2a2bacca0694f072df8421cf13adca6250d28065bdc
size 24709205
+1 -7
View File
@@ -4,17 +4,11 @@ from pathlib import Path
CHESTNUT_FW_VERSION = "ed4e39b7"
CHESTNUT_USB_IDS = ((0xADD1, 0x0001), (0x3801, 0x0001))
CHESTNUT_ROM_USB_IDS = ((0x174C, 0x2464), (0x174C, 0x2463))
CHESTNUT_USB_PRODUCT = f"custom {CHESTNUT_FW_VERSION}-CLEAN"
USB_DEVICES_PATH = Path("/sys/bus/usb/devices")
TYPEC_CC_ORIENTATION_PATH = Path("/sys/class/power_supply/usb/typec_cc_orientation")
PRIMARY_USB_CONTROLLER = "a600000.ssusb"
def is_chestnut_usb_id(vendor_id: int, product_id: int, include_bootloader: bool = False) -> bool:
ids = CHESTNUT_USB_IDS + CHESTNUT_ROM_USB_IDS if include_bootloader else CHESTNUT_USB_IDS
return (vendor_id, product_id) in ids
def get_usb_topology() -> set[str]:
try:
return set(os.listdir(USB_DEVICES_PATH))
@@ -87,7 +81,7 @@ def set_usb_state(device_state, devices: list[dict]) -> None:
entry.linkErrorCount = device["linkErrorCount"]
entry.usb3Lane = device.get("usb3Lane", "unknown")
if is_chestnut_usb_id(entry.vendorId, entry.productId):
if (entry.vendorId, entry.productId) in CHESTNUT_USB_IDS:
chestnut_present = True
device_state.chestnutPresent = chestnut_present
-7
View File
@@ -28,7 +28,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"ControlsReady", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}},
{"CurrentBootlog", {PERSISTENT, STRING}},
{"CurrentRoute", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, STRING}},
{"DisableDriverCameraIR", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}},
{"DisableLogging", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}},
{"DisablePowerDown", {PERSISTENT | BACKUP, BOOL}},
{"DisableUpdates", {PERSISTENT | BACKUP, BOOL, "0"}},
@@ -93,12 +92,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}},
{"Offroad_CarUnrecognized", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
{"Offroad_ChestnutBranch", {CLEAR_ON_MANAGER_START, JSON}},
{"Offroad_ChestnutNotDetected", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
{"Offroad_ChestnutOverheated", {CLEAR_ON_MANAGER_START, JSON}},
{"Offroad_ChestnutPcieUnavailable", {CLEAR_ON_MANAGER_START, JSON}},
{"Offroad_ChestnutUncompiled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
{"Offroad_ChestnutUpdateFailed", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
{"Offroad_ChestnutUsbSlow", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
{"Offroad_ConnectivityNeeded", {CLEAR_ON_MANAGER_START, JSON}},
{"Offroad_ConnectivityNeededPrompt", {CLEAR_ON_MANAGER_START, JSON}},
{"Offroad_ExcessiveActuation", {PERSISTENT, JSON}},
+4 -4
View File
@@ -27,14 +27,14 @@ public:
auto param_path = Params().getParamPath();
if (util::file_exists(param_path)) {
std::string real_path = util::readlink(param_path);
util::check_system(util::string_format("rm -rf %s", real_path.c_str()));
util::check_system(util::string_format("rm %s -rf", real_path.c_str()));
unlink(param_path.c_str());
}
if (getenv("COMMA_CACHE") == nullptr) {
util::check_system(util::string_format("rm -rf %s", Path::download_cache_root().c_str()));
util::check_system(util::string_format("rm %s -rf", Path::download_cache_root().c_str()));
}
util::check_system(util::string_format("rm -rf %s", Path::comma_home().c_str()));
util::check_system(util::string_format("rm -rf %s", msgq_path.c_str()));
util::check_system(util::string_format("rm %s -rf", Path::comma_home().c_str()));
util::check_system(util::string_format("rm %s -rf", msgq_path.c_str()));
unsetenv("OPENPILOT_PREFIX");
}
+35 -556
View File
@@ -1,47 +1,16 @@
"""QR code encoding, decoding, and UI textures."""
import functools
import itertools
"""Small QR encoder for the UI's byte-mode, error-correction-level-L codes."""
import numpy as np
import pyray as rl
# (ec codewords per block, block count) for levels L, M, Q, H, versions 1-40
_EC = [
((7, 1), (10, 1), (13, 1), (17, 1)), ((10, 1), (16, 1), (22, 1), (28, 1)), ((15, 1), (26, 1), (18, 2), (22, 2)),
((20, 1), (18, 2), (26, 2), (16, 4)), ((26, 1), (24, 2), (18, 4), (22, 4)), ((18, 2), (16, 4), (24, 4), (28, 4)),
((20, 2), (18, 4), (18, 6), (26, 5)), ((24, 2), (22, 4), (22, 6), (26, 6)), ((30, 2), (22, 5), (20, 8), (24, 8)),
((18, 4), (26, 5), (24, 8), (28, 8)), ((20, 4), (30, 5), (28, 8), (24, 11)), ((24, 4), (22, 8), (26, 10), (28, 11)),
((26, 4), (22, 9), (24, 12), (22, 16)), ((30, 4), (24, 9), (20, 16), (24, 16)), ((22, 6), (24, 10), (30, 12), (24, 18)),
((24, 6), (28, 10), (24, 17), (30, 16)), ((28, 6), (28, 11), (28, 16), (28, 19)), ((30, 6), (26, 13), (28, 18), (28, 21)),
((28, 7), (26, 14), (26, 21), (26, 25)), ((28, 8), (26, 16), (30, 20), (28, 25)), ((28, 8), (26, 17), (28, 23), (30, 25)),
((28, 9), (28, 17), (30, 23), (24, 34)), ((30, 9), (28, 18), (30, 25), (30, 30)), ((30, 10), (28, 20), (30, 27), (30, 32)),
((26, 12), (28, 21), (30, 29), (30, 35)), ((28, 12), (28, 23), (28, 34), (30, 37)), ((30, 12), (28, 25), (30, 34), (30, 40)),
((30, 13), (28, 26), (30, 35), (30, 42)), ((30, 14), (28, 28), (30, 38), (30, 45)), ((30, 15), (28, 29), (30, 40), (30, 48)),
((30, 16), (28, 31), (30, 43), (30, 51)), ((30, 17), (28, 33), (30, 45), (30, 54)), ((30, 18), (28, 35), (30, 48), (30, 57)),
((30, 19), (28, 37), (30, 51), (30, 60)), ((30, 19), (28, 38), (30, 53), (30, 63)), ((30, 20), (28, 40), (30, 56), (30, 66)),
((30, 21), (28, 43), (30, 59), (30, 70)), ((30, 22), (28, 45), (30, 62), (30, 74)), ((30, 24), (28, 47), (30, 65), (30, 77)),
((30, 25), (28, 49), (30, 68), (30, 81)),
]
# Indexes are QR versions. These are the only two Reed-Solomon parameters needed
# for error-correction level L.
_ECC_LEN = (0, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28)
_NUM_BLOCKS = (0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8)
# GF(256) with the QR polynomial x^8 + x^4 + x^3 + x^2 + 1: powers of alpha and their logs
_EXP = [1]
for _ in range(254):
_EXP.append(_EXP[-1] << 1 ^ (0x11D if _EXP[-1] & 0x80 else 0))
_LOG = {v: i for i, v in enumerate(_EXP)}
def _bch_format(data: int) -> int:
v = data << 10
for shift in range(14, 9, -1):
if v >> shift & 1:
v ^= 0x537 << (shift - 10)
return (data << 10 | v) ^ 0x5412
# 15-bit format info indexed by (level bits << 3 | mask). Level bits: L=01, M=00, Q=11, H=10.
_FORMATS = [_bch_format(d) for d in range(32)]
# 15 format-info bits for level L (01) with mask 0: ((0x08 << 10) | bch_remainder) ^ 0x5412
_FORMAT_BITS = 0b111011111000100
def _raw_modules(version: int) -> int:
@@ -52,24 +21,8 @@ def _raw_modules(version: int) -> int:
return result - (36 if version >= 7 else 0)
def _block_lengths(version: int, level: int) -> list[int]:
"""Data codewords per Reed-Solomon block. The last blocks may be one longer."""
ec, nblocks = _EC[version - 1][level]
total = _raw_modules(version) // 8 - ec * nblocks
return [total // nblocks + (i >= nblocks - total % nblocks) for i in range(nblocks)]
def _interleaved(version: int, level: int) -> list[tuple[int, int]]:
"""(block, index within block) of each transmitted codeword: data column-major, then ECC column-major."""
ec, nblocks = _EC[version - 1][level]
lens = _block_lengths(version, level)
data = [(b, i) for i in range(max(lens)) for b in range(nblocks) if i < lens[b]]
ecc = [(b, lens[b] + i) for i in range(ec) for b in range(nblocks)]
return data + ecc
def _capacity(version: int) -> int:
return sum(_block_lengths(version, 0))
return _raw_modules(version) // 8 - _ECC_LEN[version] * _NUM_BLOCKS[version]
def _append_bits(bits: list[int], value: int, length: int) -> None:
@@ -96,18 +49,37 @@ def _data_codewords(data: bytes, version: int) -> bytes:
def _codewords(data: bytes, version: int) -> bytes:
"""Split data codewords into Reed-Solomon blocks and interleave data + ECC."""
data = _data_codewords(data, version)
divisor = _divisor(_EC[version - 1][0][0])
blocks = []
num_blocks = _NUM_BLOCKS[version]
ecc_len = _ECC_LEN[version]
raw_codewords = _raw_modules(version) // 8
short_len = raw_codewords // num_blocks
num_short = num_blocks - raw_codewords % num_blocks
divisor = _divisor(ecc_len)
blocks: list[tuple[bytes, bytes]] = []
offset = 0
for length in _block_lengths(version, 0):
for i in range(num_blocks):
length = short_len - ecc_len + (0 if i < num_short else 1)
block = data[offset:offset + length]
blocks.append(block + _remainder(block, divisor))
blocks.append((block, _remainder(block, divisor)))
offset += length
return bytes(blocks[b][i] for b, i in _interleaved(version, 0))
result = bytearray()
for i in range(short_len - ecc_len + 1):
for block, _ in blocks:
result.extend(block[i:i + 1])
for i in range(ecc_len):
for _, ecc in blocks:
result.append(ecc[i])
return bytes(result)
def _multiply(x: int, y: int) -> int:
return _EXP[(_LOG[x] + _LOG[y]) % 255] if x and y else 0
result = 0
for _ in range(8):
result = (result << 1) ^ (0x11D if result & 0x80 else 0)
if y & 0x80:
result ^= x
y <<= 1
return result
def _divisor(degree: int) -> bytes:
@@ -136,7 +108,7 @@ def _alignment_positions(version: int) -> list[int]:
if version == 1:
return []
count = version // 7 + 2
step = (version * 8 + count * 3 + 5) // (count * 4 - 4) * 2
step = ((version * 4 + count * 2 + 1) // (count * 2 - 2)) * 2
return [6] + [version * 4 + 10 - step * i for i in range(count - 1)][::-1]
@@ -199,7 +171,7 @@ class _Qr:
def _format(self) -> None:
for i in range(15):
bit = ((_FORMATS[1 << 3 | 0] >> i) & 1) != 0 # level L, mask 0
bit = ((_FORMAT_BITS >> i) & 1) != 0
y_pos = i if i < 6 else i + 1 if i < 8 else self.size - 15 + i
self._set_function(8, y_pos, bit)
x_pos = self.size - 1 - i if i < 8 else 15 - i if i < 9 else 14 - i
@@ -244,496 +216,3 @@ def make_texture(data: str, inverted: bool = False) -> rl.Texture:
rl_image.mipmaps = 1
rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8
return rl.load_texture_from_image(rl_image)
# ---- Symbol structure for decoding ----
class QRError(Exception):
pass
_LEVELS = (1, 0, 3, 2) # format info level bits -> column in _EC
_MASKS = [
lambda i, j: (i + j) % 2 == 0,
lambda i, j: i % 2 == 0,
lambda i, j: j % 3 == 0,
lambda i, j: (i + j) % 3 == 0,
lambda i, j: (i // 2 + j // 3) % 2 == 0,
lambda i, j: (i * j) % 2 + (i * j) % 3 == 0,
lambda i, j: ((i * j) % 2 + (i * j) % 3) % 2 == 0,
lambda i, j: ((i + j) % 2 + (i * j) % 3) % 2 == 0,
]
_ALIGNMENT = np.ones((5, 5), dtype=bool)
_ALIGNMENT[1:4, 1:4] = False
_ALIGNMENT[2, 2] = True
def _gf_inv(a: int) -> int:
return _EXP[-_LOG[a] % 255]
@functools.lru_cache
def _data_coords(version: int) -> tuple[np.ndarray, np.ndarray]:
"""(rows, cols) of the data and error correction modules in placement order: two-column zigzag from the right."""
dim = version * 4 + 17
func = np.zeros((dim, dim), dtype=bool) # finder, timing, alignment, format, and version modules
func[:9, :9] = func[:9, dim - 8:] = func[dim - 8:, :9] = True
func[6, :] = func[:, 6] = True
positions = _alignment_positions(version)
for r, c in itertools.product(positions, positions):
if (r, c) not in ((6, 6), (6, dim - 7), (dim - 7, 6)):
func[r - 2:r + 3, c - 2:c + 3] = True
if version >= 7:
func[:6, dim - 11:dim - 8] = func[dim - 11:dim - 8, :6] = True
ys = np.arange(dim)
rows, cols = [], []
# the vertical timing column is skipped, so the pairs left of it start at odd columns
for i, right in enumerate(col if col > 6 else col - 1 for col in range(dim - 1, 0, -2)):
r = np.repeat(ys[::-1] if i % 2 == 0 else ys, 2)
c = np.tile((right, right - 1), dim)
keep = ~func[r, c]
rows.append(r[keep])
cols.append(c[keep])
return np.concatenate(rows), np.concatenate(cols)
# ---- Matrix decoding ----
def _poly_eval(p: list[int], x: int) -> int:
# p is highest degree first
y = 0
for c in p:
y = _multiply(y, x) ^ c
return y
_EXP_TABLE = np.array(_EXP)
_LOG_TABLE = np.array([_LOG.get(v, 0) for v in range(256)])
def _syndromes(msg: list[int], nsym: int) -> list[int]:
"""syn[i] = msg(alpha^i), msg highest degree first."""
m = np.array(msg)
exponents = np.arange(nsym)[:, None] * (len(msg) - 1 - np.arange(len(msg)))
return np.bitwise_xor.reduce(_EXP_TABLE[(_LOG_TABLE[m] + exponents) % 255] * (m != 0), axis=1).tolist()
def _rs_correct(msg: list[int], nsym: int) -> list[int]:
"""Corrects up to nsym // 2 errors in a Reed-Solomon codeword, in place."""
n = len(msg)
syn = _syndromes(msg, nsym)
if not any(syn):
return msg
# Berlekamp-Massey, sigma is lowest degree first
sigma, prev, L, m, b = [1], [1], 0, 1, 1
for r in range(nsym):
d = syn[r]
for i in range(1, L + 1):
d ^= _multiply(sigma[i], syn[r - i])
if d == 0:
m += 1
continue
coef = _multiply(d, _gf_inv(b))
shifted = [0] * m + prev
saved = sigma[:]
sigma = sigma + [0] * max(0, len(shifted) - len(sigma))
for i, c in enumerate(shifted):
sigma[i] ^= _multiply(coef, c)
if 2 * L <= r:
L, prev, b, m = r + 1 - L, saved, d, 1
else:
m += 1
sigma = sigma[:L + 1]
if 2 * L > nsym:
raise QRError("too many errors")
# Chien search: codeword position p has locator alpha^(n-1-p)
positions = [p for p in range(n) if _poly_eval(sigma[::-1], _EXP[(p - n + 1) % 255]) == 0]
if len(positions) != L:
raise QRError("error locator mismatch")
# solve syn[i] = sum_k e_k * X_k^i for the magnitudes e_k
xlog = [(n - 1 - p) % 255 for p in positions]
A = [[_EXP[(xlog[k] * i) % 255] for k in range(L)] + [syn[i]] for i in range(L)]
for col in range(L):
piv = next((r for r in range(col, L) if A[r][col]), None)
if piv is None:
raise QRError("singular")
A[col], A[piv] = A[piv], A[col]
inv = _gf_inv(A[col][col])
A[col] = [_multiply(inv, v) for v in A[col]]
for r in range(L):
if r != col and A[r][col]:
f = A[r][col]
A[r] = [a ^ _multiply(f, c) for a, c in zip(A[r], A[col], strict=True)]
for k, p in enumerate(positions):
msg[p] ^= A[k][L]
if any(_syndromes(msg, nsym)):
raise QRError("uncorrectable")
return msg
def _read_format(m: np.ndarray) -> int:
"""Returns the closest format info (level bits << 3 | mask) from either copy."""
dim = m.shape[0]
copies = ([(8, i) for i in range(6)] + [(8, 7), (8, 8), (7, 8)] + [(5 - i, 8) for i in range(6)],
[(dim - 1 - i, 8) for i in range(7)] + [(8, dim - 8 + i) for i in range(8)]) # (row, col), msb first
candidates = []
for coords in copies:
bits = int("".join(str(int(m[r, c])) for r, c in coords), 2)
candidates += [((bits ^ f).bit_count(), i) for i, f in enumerate(_FORMATS)]
distance, fmt = min(candidates)
if distance > 3:
raise QRError("bad format info")
return fmt
_ALNUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
_ECI_ENCODINGS = {
0: "cp437", 2: "cp437", 1: "iso8859-1", 3: "iso8859-1",
**{i + 2: f"iso8859-{i}" for i in range(2, 17) if i != 12},
20: "shift_jis", 21: "cp1250", 22: "cp1251", 23: "cp1252", 24: "cp1256",
25: "utf-16-be", 26: "utf-8", 27: "ascii", 170: "ascii", 28: "big5", 29: "gb18030", 30: "euc_kr",
}
class _Bits:
def __init__(self, data: list[int]):
self._value = int.from_bytes(bytes(data), "big")
self.remaining = len(data) * 8
def read(self, n: int) -> int:
if n > self.remaining:
raise QRError("bitstream underflow")
self.remaining -= n
return self._value >> self.remaining & (1 << n) - 1
def read_below(self, n: int, limit: int) -> int:
v = self.read(n)
if v >= limit:
raise QRError("value out of range")
return v
def _parse_data(data: list[int], version: int) -> str:
bits = _Bits(data)
out: list[str] = []
encoding = None
band = 0 if version <= 9 else 1 if version <= 26 else 2
while bits.remaining >= 4:
mode = bits.read(4)
if mode == 0:
break
if mode == 7: # ECI character set assignment
first = bits.read(8)
extra = 0 if first < 0x80 else 8 if first < 0xC0 else 16 if first < 0xE0 else -1 # 1, 2, or 3 byte assignment
if extra < 0:
raise QRError("bad ECI assignment")
assignment = (first & 0x7F >> extra // 8) << extra | bits.read(extra)
encoding = _ECI_ENCODINGS.get(assignment)
if encoding is None:
raise QRError(f"unsupported ECI assignment {assignment}")
elif mode == 1:
n = bits.read((10, 12, 14)[band])
while n > 0:
k = min(n, 3) # 3 digits in 10 bits, the last 2 or 1 in 7 or 4
out.append(f"{bits.read_below((4, 7, 10)[k - 1], 10 ** k):0{k}d}")
n -= k
elif mode == 2:
n = bits.read((9, 11, 13)[band])
while n > 0:
k = min(n, 2) # 2 characters in 11 bits, a last one in 6
v = bits.read_below((6, 11)[k - 1], 45 ** k)
out.append(_ALNUM[v // 45] * (k - 1) + _ALNUM[v % 45])
n -= k
elif mode == 4:
n = bits.read((8, 16, 16)[band])
segment = bytes(bits.read(8) for _ in range(n))
try:
out.append(segment.decode(encoding or "utf-8"))
except UnicodeDecodeError as e:
if encoding is not None:
raise QRError("invalid ECI byte segment") from e
out.append(segment.decode("latin-1"))
elif mode == 8:
n = bits.read((8, 10, 12)[band])
for _ in range(n):
v = bits.read(13)
c = (v // 0xC0) << 8 | v % 0xC0
c += 0x8140 if c < 0x1F00 else 0xC140
try:
out.append(c.to_bytes(2, "big").decode("shift_jis"))
except UnicodeDecodeError as e:
raise QRError("invalid Kanji character") from e
else:
raise QRError(f"unsupported mode {mode}")
return "".join(out)
def decode_matrix(m: np.ndarray) -> str:
"""Decodes a square boolean module matrix (True = dark) without a quiet zone."""
dim = m.shape[0]
if m.shape != (dim, dim) or dim % 4 != 1 or not 21 <= dim <= 177:
raise QRError("bad matrix size")
version = (dim - 17) // 4
fmt = _read_format(m)
level = _LEVELS[fmt >> 3]
rows, cols = _data_coords(version)
bits = m[rows, cols] ^ _MASKS[fmt & 7](rows, cols)
codewords = np.packbits(bits[:len(bits) // 8 * 8]).tolist()
ec, _ = _EC[version - 1][level]
lens = _block_lengths(version, level)
blocks = [[0] * (n + ec) for n in lens]
for (b, i), codeword in zip(_interleaved(version, level), codewords, strict=True):
blocks[b][i] = codeword
data: list[int] = []
for block, n in zip(blocks, lens, strict=True):
data += _rs_correct(block, ec)[:n]
return _parse_data(data, version)
# ---- Image decoding ----
def _box_sums(a: np.ndarray, radii: tuple[int, ...]) -> list[np.ndarray]:
"""Sums over (2r + 1)^2 neighborhoods of the last two axes, edge padded, from one integral image."""
P = max(radii)
lead = [(0, 0)] * (a.ndim - 2)
cs = np.pad(np.cumsum(np.cumsum(np.pad(a, lead + [(P, P), (P, P)], mode="edge"), -2), -1), lead + [(1, 0), (1, 0)])
H, W = a.shape[-2:]
out = []
for r in radii:
lo, hi = P - r, P + r + 1
out.append(cs[..., hi:hi + H, hi:hi + W] - cs[..., lo:lo + H, hi:hi + W] - cs[..., hi:hi + H, lo:lo + W] + cs[..., lo:lo + H, lo:lo + W])
return out
def _binarize(gray: np.ndarray) -> np.ndarray:
"""Adaptive threshold: each pixel against the mean of the surrounding tiles that have contrast."""
h, w = gray.shape
if h < 21 or w < 21:
raise QRError("image too small")
B = max(8, min(h, w) // 128 * 2)
H, W = -(-h // B), -(-w // B)
padded = np.pad(gray, ((0, H * B - h), (0, W * B - w)), mode="edge")
# block statistics from a subsample are plenty
sub = np.ascontiguousarray(padded[::2, ::2].reshape(H, B // 2, W, B // 2).transpose(0, 2, 1, 3)).reshape(H, W, -1)
blocks = sub.sum(axis=2, dtype=np.uint32) / sub.shape[2]
known = sub.max(axis=2) - sub.min(axis=2) >= 32
# Flat tiles cannot estimate their own threshold: use the tiles with contrast nearby, then
# further out, then the global midrange. A flat tile is then all dark or all light.
est = np.full((H, W), (blocks.min() + blocks.max()) / 2)
filled = np.zeros((H, W), dtype=bool)
for total, count in _box_sums(np.stack((known * blocks, known.astype(float))), (2, 6)):
fill = ~filled & (count > 0)
est[fill] = total[fill] / count[fill]
filled |= fill
thr = np.where(known, np.minimum(est, 254) + 1, np.where(blocks <= est, 255, 0)).astype(np.uint8)
return (padded.reshape(H, B, W, B) < thr[:, None, :, None]).reshape(H * B, W * B)[:h, :w]
class _Runs:
"""Run-length table of a padded, flattened binary image with a per-pixel run index."""
def __init__(self, padded: np.ndarray):
self.flat = padded.ravel()
self.lines, self.stride = padded.shape
change = self.flat[1:] != self.flat[:-1]
self.starts = np.concatenate(([0], np.flatnonzero(change) + 1))
self.lengths = np.diff(np.append(self.starts, self.flat.size)).astype(np.int32)
def run_at(self, line: np.ndarray, pos: np.ndarray) -> np.ndarray:
"""Index of the run containing the pixel at `pos` along `line`."""
return np.searchsorted(self.starts, line * self.stride + pos + 1, side="right") - 1
@staticmethod
def _match(lengths: list[np.ndarray], ratios: tuple[int, ...]) -> tuple[np.ndarray, np.ndarray]:
"""Checks windows of runs against the ratios, given the length of each run. Returns (ok, module size)."""
S = sum(ratios)
total = sum(lengths[1:], start=lengths[0])
ok = total >= 2 * S # modules need to be at least 2 px
for L, r in zip(lengths, ratios, strict=True):
ok &= np.abs(2 * S * L - 2 * r * total) <= r * total # integer form of |L - r * total / S| <= r * total / (2 * S)
return ok, total / S
def scan(self, ratios: tuple[int, ...]) -> np.ndarray:
"""Returns the indices of all dark runs starting a window of runs matching the ratios."""
n = len(ratios)
N = len(self.lengths) - n + 1
if N <= 0:
return np.zeros(0, dtype=int)
ok, _ = self._match([self.lengths[k:N + k] for k in range(n)], ratios)
ok &= self.flat[self.starts[:N]]
first = np.flatnonzero(ok)
return first[self.starts[first] // self.stride == self.starts[first + n - 1] // self.stride]
def check(self, first: np.ndarray, ratios: tuple[int, ...]) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Checks the run windows starting at run index `first`. Returns (ok, center position along the line, module size)."""
n, half = len(ratios), len(ratios) // 2
ok = (first >= 0) & (first + n <= len(self.starts))
idx = np.clip(first[:, None] + np.arange(n), 0, len(self.starts) - 1)
matched, module = self._match([self.lengths[idx[:, k]] for k in range(n)], ratios)
ok &= matched & self.flat[self.starts[idx[:, 0]]]
ok &= self.starts[idx[:, 0]] // self.stride == self.starts[idx[:, -1]] // self.stride
center = self.starts[idx[:, half]] % self.stride - 1 + self.lengths[idx[:, half]] / 2
return ok, center, module
def _find_patterns(binary: np.ndarray, ratios: tuple[int, ...]) -> list[tuple[float, float, float]]:
"""Finds dark/light run patterns with the given module ratios. Returns (x, y, module size)."""
half = len(ratios) // 2
step = 2 # the center rows of a 2 px finder pattern still get scanned twice
rows_t = _Runs(np.pad(binary[::step], ((0, 0), (1, 1))))
first = rows_t.scan(ratios)
if len(first) == 0:
return []
_, cx, hmod = rows_t.check(first, ratios)
row = rows_t.starts[first] // rows_t.stride * step
xi = cx.astype(int)
xs, col = np.unique(xi, return_inverse=True)
cols_t = _Runs(np.pad(binary[:, xs].T, ((0, 0), (1, 1))))
ok, cy, vmod = cols_t.check(cols_t.run_at(col, row) - half, ratios)
ok &= (0.5 <= vmod / hmod) & (vmod / hmod <= 2)
line = np.clip(np.rint(cy / step), 0, rows_t.lines - 1).astype(int)
ok2, cx2, hmod2 = rows_t.check(rows_t.run_at(line, xi) - half, ratios)
ok &= ok2 & (0.5 <= hmod2 / vmod) & (hmod2 / vmod <= 2)
found: list[list[float]] = [] # [x, y, module, count]
for x, y, module in zip(cx2[ok], cy[ok], (hmod2[ok] + vmod[ok]) / 2, strict=True):
for f in found:
if abs(f[0] - x) <= f[2] and abs(f[1] - y) <= f[2] and 0.5 <= f[2] / module <= 2:
c = f[3]
f[0], f[1], f[2], f[3] = (f[0] * c + x) / (c + 1), (f[1] * c + y) / (c + 1), (f[2] * c + module) / (c + 1), c + 1
break
else:
found.append([x, y, module, 1])
found.sort(key=lambda f: -f[3])
return [(f[0], f[1], f[2]) for f in found if f[3] >= 2]
def _pick_finders(patterns: list[tuple[float, float, float]]) -> tuple[np.ndarray, np.ndarray, np.ndarray, float]:
"""Returns (top-left, top-right, bottom-left) centers and the module size of the most square-looking triple."""
best = None
for a, b, c in itertools.combinations(patterns[:10], 3):
mods = sorted((a[2], b[2], c[2]))
if mods[2] / mods[0] > 1.5:
continue
pts = [np.array(p[:2]) for p in (a, b, c)]
d = [np.linalg.norm(pts[(i + 1) % 3] - pts[(i + 2) % 3]) for i in range(3)]
tl = int(np.argmax(d)) # opposite the hypotenuse
p1, p2 = pts[(tl + 1) % 3], pts[(tl + 2) % 3]
v1, v2 = p1 - pts[tl], p2 - pts[tl]
n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2)
if n1 == 0 or n2 == 0:
continue
cos = abs(np.dot(v1, v2)) / (n1 * n2)
if cos > 0.35 or not 0.6 <= n1 / n2 <= 1.6:
continue
score = cos + abs(np.log(n1 / n2)) + np.log(mods[2] / mods[0])
if best is not None and score >= best[0]:
continue
if v1[0] * v2[1] - v1[1] * v2[0] < 0:
p1, p2 = p2, p1
best = (score, pts[tl], p1, p2, float(sum(mods) / 3))
if best is None:
raise QRError("no finder patterns")
return best[1:]
def _perspective(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
"""Homography mapping the four src points onto the four dst points."""
A = [row for (x, y), (u, v) in zip(src, dst, strict=True)
for row in ([x, y, 1, 0, 0, 0, -u * x, -u * y], [0, 0, 0, x, y, 1, -v * x, -v * y])]
try:
h = np.linalg.solve(np.array(A, dtype=float), np.asarray(dst, dtype=float).ravel())
except np.linalg.LinAlgError as e:
raise QRError("degenerate geometry") from e
return np.append(h, 1).reshape(3, 3)
def _transform(H: np.ndarray, pts: np.ndarray) -> np.ndarray:
p = np.column_stack((pts, np.ones(len(pts)))) @ H.T
return p[:, :2] / p[:, 2:3]
def _match_alignment(binary: np.ndarray, est: np.ndarray, offs: np.ndarray, r: int, module: float) -> np.ndarray | None:
h, w = binary.shape
dy = np.arange(max(0, int(est[1]) - r), min(h, int(est[1]) + r)) - est[1]
dx = np.arange(max(0, int(est[0]) - r), min(w, int(est[0]) + r)) - est[0]
if len(dy) == 0 or len(dx) == 0:
return None
y = np.rint(est[1] + dy[:, None, None] + offs[None, None, :, 1]).astype(int)
x = np.rint(est[0] + dx[None, :, None] + offs[None, None, :, 0]).astype(int)
valid = ((y >= 0) & (y < h) & (x >= 0) & (x < w)).all(axis=2)
samples = binary[np.clip(y, 0, h - 1), np.clip(x, 0, w - 1)]
score = np.where(valid, (samples == _ALIGNMENT.ravel()).sum(axis=2), 0)
if score.max() < 23:
return None
hits = np.argwhere(score == score.max())
centers = np.column_stack((est[0] + dx[hits[:, 1]], est[1] + dy[hits[:, 0]]))
closest = centers[np.argmin(np.linalg.norm(centers - est, axis=1))]
return centers[np.linalg.norm(centers - closest, axis=1) <= module / 2].mean(axis=0)
def _locate_alignment(binary: np.ndarray, H: np.ndarray, center: float, module: float) -> np.ndarray | None:
"""Template matches the 5x5 alignment pattern around its position estimated from H."""
grid = np.mgrid[-2:3, -2:3].reshape(2, -1).T[:, ::-1] + center # (25, 2) module coords (x, y)
pts = _transform(H, grid)
# the affine estimate can be off in both position and local scale under perspective
for radius in (2, 4, 8, 16):
for scale in (1.0, 0.8, 1.25, 0.65, 1.5):
found = _match_alignment(binary, pts[12], (pts - pts[12]) * scale, int(module * radius), module)
if found is not None:
return found
return None
def _sample(binary: np.ndarray, tl: np.ndarray, tr: np.ndarray, bl: np.ndarray, module: float, dim: int, use_alignment: bool) -> np.ndarray:
src = np.array([(3.5, 3.5), (dim - 3.5, 3.5), (3.5, dim - 3.5), (dim - 3.5, dim - 3.5)])
dst = np.array([tl, tr, bl, tr + bl - tl])
H = _perspective(src, dst)
if use_alignment and dim > 21:
align = _locate_alignment(binary, H, dim - 6.5, module)
if align is not None:
src[3], dst[3] = (dim - 6.5, dim - 6.5), align
H = _perspective(src, dst)
rows, cols = np.mgrid[0:dim, 0:dim]
pts = _transform(H, np.column_stack((cols.ravel() + 0.5, rows.ravel() + 0.5)))
xy = np.rint(pts).astype(int)
h, w = binary.shape
if (xy < 0).any() or (xy[:, 0] >= w).any() or (xy[:, 1] >= h).any():
raise QRError("code extends outside image")
return binary[xy[:, 1], xy[:, 0]].reshape(dim, dim)
def decode(gray: np.ndarray) -> str | None:
"""Decodes the QR code in a 2D uint8 grayscale image. Modules need to be at least 2 px.
Returns None if nothing could be decoded."""
try:
binary = _binarize(gray)
tl, tr, bl, module = _pick_finders(_find_patterns(binary, (1, 1, 3, 1, 1)))
except QRError:
return None
d = (np.linalg.norm(tr - tl) + np.linalg.norm(bl - tl)) / 2
dim = int(round((d / module + 7 - 17) / 4)) * 4 + 17
dims = [cand for cand in (dim, dim - 4, dim + 4) if 21 <= cand <= 177]
for cand, use_alignment, transpose in itertools.product(dims, (True, False), (False, True)):
try:
m = _sample(binary, tl, tr, bl, module, cand, use_alignment)
return decode_matrix(m.T if transpose else m)
except QRError:
pass
return None
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-178
View File
@@ -1,178 +0,0 @@
import hashlib
import math
from pathlib import Path
import numpy as np
from openpilot.common import qrcode as qr
from openpilot.common.test import OpenpilotTestCase
LPA = "LPA:1$rsp.truphone.com$QRF-BETTERROAMING-PMRDGIR2EARDEIT5"
# Matrices generated with python-qrcode 8.2, covering all versions and EC levels.
# Packed fixtures keep the decoder tests independent of our encoder.
FIXTURES = {}
for path in Path(__file__).with_name("fixtures").glob("qrcode_*.npz"):
with np.load(path) as fixtures:
FIXTURES.update({key: fixtures[key] for key in fixtures.files})
def fixture(key: str) -> np.ndarray:
bits = np.unpackbits(FIXTURES[key])
size = math.isqrt(len(bits))
return bits[:size * size].reshape(size, size).astype(bool)
def render(matrix: np.ndarray, box: int = 6, border: int = 4) -> np.ndarray:
img = np.repeat(np.repeat(np.pad(matrix, border), box, axis=0), box, axis=1)
return np.where(img, 0, 255).astype(np.uint8)
def make(data: str, version: int | None = None, level: int = 0, box: int = 6, border: int = 4):
matrix = fixture(hashlib.sha256(f"{version}:{level}:{data}".encode()).hexdigest())
return matrix, render(matrix, box, border)
def warp(img: np.ndarray, H: np.ndarray) -> np.ndarray:
"""Bilinear resampling through the output -> input homography H, white outside the image."""
h, w = img.shape
rows, cols = np.mgrid[0:h, 0:w]
pts = qr._transform(H, np.column_stack((cols.ravel() + 0.5, rows.ravel() + 0.5))) - 0.5
x0, y0 = np.floor(pts[:, 0]).astype(int), np.floor(pts[:, 1]).astype(int)
fx, fy = pts[:, 0] - x0, pts[:, 1] - y0
padded = np.pad(img.astype(float), 1, constant_values=255)
def at(y, x):
return padded[np.clip(y + 1, 0, h + 1), np.clip(x + 1, 0, w + 1)]
out = at(y0, x0) * (1 - fx) * (1 - fy) + at(y0, x0 + 1) * fx * (1 - fy) + at(y0 + 1, x0) * (1 - fx) * fy + at(y0 + 1, x0 + 1) * fx * fy
return np.clip(out, 0, 255).astype(np.uint8).reshape(h, w)
def rotate(img: np.ndarray, angle: float) -> np.ndarray:
h, w = img.shape
t = np.radians(angle)
R = np.array([[np.cos(t), -np.sin(t)], [np.sin(t), np.cos(t)]])
center = np.array([w / 2, h / 2])
corners = np.array([(0, 0), (w, 0), (w, h), (0, h)], dtype=float)
return warp(img, qr._perspective((corners - center) @ R.T + center, corners))
class TestQRCode(OpenpilotTestCase):
def test_alignment_positions(self):
assert qr._alignment_positions(7) == [6, 22, 38]
assert qr._alignment_positions(32) == [6, 34, 60, 86, 112, 138]
assert qr._alignment_positions(40) == [6, 30, 58, 86, 114, 142, 170]
def test_all_versions(self):
for version in range(1, 41):
for level in range(4):
with self.subTest(version=version, level=level):
data = "".join(chr(ord("a") + i % 26) for i in range(version))
matrix, img = make(data, version, level, box=3)
assert qr.decode_matrix(matrix) == data
assert qr.decode(img) == data
def test_modes(self):
for data in ["0123456789012345", "HELLO WORLD $1.50", LPA, "こんにちは", "ünïcødé", "mixed 123 ABC xyz"]:
with self.subTest(data=data):
matrix, img = make(data)
assert qr.decode_matrix(matrix) == data
assert qr.decode(img) == data
def test_error_correction(self):
matrix, _ = make(LPA, level=2)
rng = np.random.default_rng(0)
flipped = matrix.copy()
for r, c in rng.integers(9, matrix.shape[0] - 9, size=(40, 2)):
flipped[r, c] ^= True
assert qr.decode_matrix(flipped) == LPA
def test_large_modules(self):
for data in ["0123456789012345", "HELLO WORLD $1.50", LPA, "mixed 123 ABC xyz"]:
for box in [16, 20, 24, 32]:
for dark, light in [(0, 255), (60, 200), (140, 250)]:
with self.subTest(data=data, box=box, dark=dark):
_, img = make(data, box=box)
img = np.where(img == 0, dark, light).astype(np.uint8)
assert qr.decode(img) == data
def test_image_edges(self):
# a code touching the image edge must not lose the rows and columns left over from tiling
matrix, _ = make(LPA)
for size in (200, 203):
with self.subTest(size=size):
img = np.full((size, size), 255, dtype=np.uint8)
code = render(matrix, box=5, border=0)
img[size - code.shape[0]:, size - code.shape[1]:] = code
assert qr.decode(img) == LPA
def test_eci(self):
# qrcode_eci.npz: packed Segno 1.6.6 matrices, generated with mode='byte',
# eci=True, micro=False and the named encoding. Mixed also includes numeric,
# alphanumeric, and Kanji segments after changing the byte encoding twice.
cases = {
"iso8859-5": "Привет", "utf-16-be": "héllo", "utf-8": "こんにちは",
"shift_jis": "日本語", "cp1251": "Привет", "iso8859-1": "héllo",
"mixed": "hélloПривет日本語123ABC漢字",
}
for encoding, expected in cases.items():
with self.subTest(encoding=encoding):
matrix = fixture(encoding)
assert qr.decode_matrix(matrix) == expected
assert qr.decode(render(matrix)) == expected
def test_parse_data(self):
def parse(stream: str) -> str:
stream += '0' * (-len(stream) % 8)
return qr._parse_data([int(stream[i:i + 8], 2) for i in range(0, len(stream), 8)], 1)
def eci(assignment: str, payload: bytes = b'A') -> str:
return parse('0111' + assignment + '0100' + f'{len(payload):08b}' + ''.join(f'{b:08b}' for b in payload) + '0000')
# ASCII assignment 170 uses the two-byte ECI representation.
assert eci('1000000010101010') == 'A'
for assignment in ['00001110', '1000001111100111', '110000010000000000000000', '11100000']:
with self.subTest(assignment=assignment), self.assertRaises(qr.QRError):
eci(assignment)
with self.assertRaises(qr.QRError):
eci('00011010', b'\xff') # Invalid UTF-8 must not fall back to Latin-1.
# out-of-range numeric, alphanumeric, and Kanji values are format errors, not crashes
for stream in ['0001' + '0000000011' + '1111111111', '0001' + '0000000010' + '1111111',
'0010' + '000000010' + '11111111111', '0010' + '000000001' + '111111',
'1000' + '00000001' + '0000000111111']:
with self.subTest(stream=stream), self.assertRaises(qr.QRError):
parse(stream)
def test_rotation(self):
for angle in [0, 90, 180, 270, 25, 110]:
with self.subTest(angle=angle):
_, img = make(LPA, box=8, border=12)
assert qr.decode(rotate(img, angle)) == LPA
def test_mirrored(self):
_, img = make(LPA)
assert qr.decode(img[:, ::-1]) == LPA
def test_perspective_and_noise(self):
_, img = make(LPA, box=10, border=8)
h, w = img.shape
corners = np.array([(40, 60), (w - 20, 30), (w - 60, h - 40), (30, h - 90)])
arr = warp(img, qr._perspective(corners, np.array([(0, 0), (w, 0), (w, h), (0, h)]))).astype(float)
rng = np.random.default_rng(1)
arr = arr * 0.6 + 60 + rng.normal(0, 12, arr.shape) # low contrast + noise
# uneven lighting
arr += np.linspace(-40, 40, w)[None, :]
assert qr.decode(np.clip(arr, 0, 255).astype(np.uint8)) == LPA
def test_no_code(self):
rng = np.random.default_rng(2)
assert qr.decode(rng.integers(0, 256, size=(240, 320), dtype=np.uint8)) is None
assert qr.decode(np.full((240, 320), 200, dtype=np.uint8)) is None
def test_encoder_roundtrip(self):
for version in range(1, 21):
with self.subTest(version=version):
assert qr.decode_matrix(np.array(qr._Qr(version, b"hello").modules)) == "hello"
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bf97a6738b294ac0aed9b2d075916cee0b7d3215bcd23381760900ede6a92748
size 13256
oid sha256:845c40ff0d37612e8f2f482a36845744b5ae91ce2fcfc8117990d7d278b59820
size 13079
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:07bda2fe5d6be0b2854044053c384fe002e96406da119863a443b9344258b500
size 1544
+2 -2
View File
@@ -23,8 +23,8 @@ done
# sudo apt install inkscape
for svg in $(find "$DIR" -type f | grep svg$); do
bunx svgo "$svg" --multipass --pretty --indent 2
for svg in $(find $DIR -type f | grep svg$); do
bunx svgo $svg --multipass --pretty --indent 2
# convert to PNG
png="${svg%.svg}.png"
+3
View File
@@ -186,6 +186,9 @@ class Car:
# card is driven by can recv, expected at 100Hz
self.rk = Ratekeeper(100, print_delay_threshold=None)
# log fingerprint in sentry
sunnypilot_interfaces.log_fingerprint(self.CP)
def state_update(self) -> tuple[car.CarState, custom.CarStateSP, structs.RadarDataT | None]:
"""carState update loop, driven by can"""
+6 -6
View File
@@ -278,12 +278,12 @@ def main():
estimator = LocationEstimator(DEBUG)
filter_initialized = False
critical_services = ["accelerometer", "gyroscope", "cameraOdometry"]
critcal_services = ["accelerometer", "gyroscope", "cameraOdometry"]
observation_input_invalid = defaultdict(int)
input_invalid_limit = {s: round(INPUT_INVALID_LIMIT * (SERVICE_LIST[s].frequency / 20.)) for s in critical_services}
input_invalid_threshold = {s: input_invalid_limit[s] - 0.5 for s in critical_services}
input_invalid_decay = {s: calculate_invalid_input_decay(input_invalid_limit[s], INPUT_INVALID_RECOVERY, SERVICE_LIST[s].frequency) for s in critical_services}
input_invalid_limit = {s: round(INPUT_INVALID_LIMIT * (SERVICE_LIST[s].frequency / 20.)) for s in critcal_services}
input_invalid_threshold = {s: input_invalid_limit[s] - 0.5 for s in critcal_services}
input_invalid_decay = {s: calculate_invalid_input_decay(input_invalid_limit[s], INPUT_INVALID_RECOVERY, SERVICE_LIST[s].frequency) for s in critcal_services}
initial_pose_data = params.get("LocationFilterInitialState")
if initial_pose_data is not None:
@@ -313,7 +313,7 @@ def main():
if valid:
t = log_mono_time * 1e-9
res = estimator.handle_log(t, which, msg)
if which not in critical_services:
if which not in critcal_services:
continue
if res == HandleLogResult.TIMING_INVALID:
@@ -328,7 +328,7 @@ def main():
filter_initialized = sm.all_checks() and sensor_all_checks(acc_msgs, gyro_msgs, sensor_valid, sensor_recv_time, sensor_alive, SIMULATION)
if sm.updated["cameraOdometry"]:
critical_service_inputs_valid = all(observation_input_invalid[s] < input_invalid_threshold[s] for s in critical_services)
critical_service_inputs_valid = all(observation_input_invalid[s] < input_invalid_threshold[s] for s in critcal_services)
inputs_valid = sm.all_valid() and critical_service_inputs_valid
sensors_valid = sensor_all_checks(acc_msgs, gyro_msgs, sensor_valid, sensor_recv_time, sensor_alive, SIMULATION)
+114 -57
View File
@@ -1,90 +1,147 @@
import glob
import json
import os
import time
from SCons.Script import Action, Value
from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_existing_chunks
from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye
from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE
from openpilot.selfdrive.modeld.helpers import chestnut_present
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, chestnut_present, modeld_pkl_path
CAMERA_CONFIGS = [
(_ar_ox_fisheye.width, _ar_ox_fisheye.height), # tici: 1928x1208
(_os_fisheye.width, _os_fisheye.height), # mici: 1344x760
]
Import('env', 'arch')
chunker_file = File("#openpilot/common/file_chunker.py")
lenv = env.Clone()
lenv.PrependENVPath('PYTHONPATH', Dir('#tinygrad_repo').abspath)
tinygrad_root = env.Dir("#").abspath
tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=tinygrad_root)
if 'pycache' not in x and os.path.isfile(os.path.join(tinygrad_root, x))]
camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)]
def estimate_pickle_max_size(onnx_size):
return 1.2 * onnx_size + 10 * 1024 * 1024 # 20% + 10MB is plenty
if arch == 'comma_arm64':
tg_flags = 'DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1'
tg_backend = 'QCOM'
tg_flags = f'DEV={tg_backend} IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1'
else:
# JIT=2 disables graph batching, which produces incorrect outputs after buffers change.
tg_flags = 'DEV=METAL JIT=2' if arch == 'Darwin' else 'DEV=CPU:LLVM'
tg_backend = 'CPU'
tg_flags = f'DEV=CPU' if arch == 'Darwin' else 'DEV=CPU:LLVM'
tg_devices = { # which device to put jit inputs to at runtime
'openpilot.selfdrive.modeld.modeld': {
'default': {'WARP_DEV': tg_backend, 'QUEUE_DEV': tg_backend},
'chestnut': {'WARP_DEV': tg_backend, 'QUEUE_DEV': 'AMD'}
},
'openpilot.selfdrive.modeld.dmonitoringmodeld': {
'default': {'DEV': tg_backend}
},
}
CHESTNUT = chestnut_present()
if CHESTNUT:
chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32'
chestnut_tg_flags = f'DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV={tg_backend} FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2'
# the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it
chestnut_lock = File("models/.chestnut.lock").abspath
def write_tg_devices(target, source, env):
with open(str(target[0]), "w") as f:
json.dump(tg_devices, f)
f.write("\n")
tg_devices_node = lenv.Command(
str(TG_INPUT_DEVICES_PATH),
[Value(tg_devices)],
write_tg_devices,
)
# tinygrad calls brew which needs a $HOME in the env
mac_brew_string = f'HOME={os.path.expanduser("~")}' if arch == 'Darwin' else ''
warp_deps = [File("#openpilot/system/camerad/cameras/nv12_info.py")]
compiler = Dir('#tinygrad_repo/examples/openpilot').abspath
# CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it.
taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else ''
def chestnut_action(command):
def do_compile(target, source, env):
from openpilot.system.hardware.chestnut.flash import link_up
# chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars
for _ in range(10):
if link_up():
break
time.sleep(1)
else:
print("Chestnut not ready, skipping warp build")
return
return env.Execute(command)
return Action(do_compile, " [CHESTNUT] $TARGET")
def compile_model(onnx_path, pkl_path):
onnx_path, target_pkl_path = File(onnx_path).abspath, File(pkl_path).abspath
cmd = (f'{tg_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_onnx.py" '
f'"{onnx_path}" "{target_pkl_path}" --device-input "*" --out-of-band --benchmark-runs 1')
lenv.Command(
target_pkl_path,
tinygrad_files + [onnx_path, Value(cmd)],
Action(cmd, " [ONNX] $TARGET"),
)
compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl')
compile_model('models/driving_supercombo.onnx', 'models/driving_tinygrad.pkl')
modeld_dir = Dir("#openpilot/selfdrive/modeld").abspath
compile_modeld_script = [
File(f"{modeld_dir}/compile_modeld.py"),
File(f"{modeld_dir}/get_model_metadata.py"),
File("#openpilot/system/camerad/cameras/nv12_info.py"),
File("#openpilot/common/hardware/hw.py"),
]
model_w, model_h = MEDMODEL_INPUT_SIZE
for chestnut in [False, True] if CHESTNUT else [False]:
file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags)
for cam_w, cam_h in camera_configs:
warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath
stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h)
cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_warp.py" '
f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{stride * (y_height + uv_height)} '
f'--warp-to {model_w}x{model_h} --layout yuv420 --frames 2 '
f'--output {warp_pkl_path}')
action = chestnut_action(cmd) if chestnut else cmd
node = lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], action)
frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ
if not os.getenv('SKIP_TINYGRAD_COMPILE'):
for chestnut in [False, True] if CHESTNUT else [False]:
target_pkl_path = File(modeld_pkl_path(chestnut)).abspath
# BIG_INTO_SMALL=1 builds the default target from the big model, e.g. to test it without a chestnut
file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('big_' if os.getenv('BIG_INTO_SMALL') else '', tg_flags)
driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath)
camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in CAMERA_CONFIGS)
# CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it.
taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else ''
cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_modeld.py '
f'--model-size {model_w}x{model_h} '
f'--camera-resolutions {camera_res_args} '
f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} '
f'--output {target_pkl_path} --frame-skip {frame_skip}')
onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps)
chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum))
def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets):
from openpilot.system.hardware.chestnut.flash import link_up
# chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars
for _ in range(10):
if link_up():
break
time.sleep(1)
else:
print("Chestnut not ready, skipping big model build")
return
if ret := env.Execute(command):
return ret
chunk_file(pkl, chunks)
def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets):
chunk_file(pkl, chunks)
actions = Action(do_compile, " [CHESTNUT] $TARGET") if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")]
node = lenv.Command(
chunk_targets,
tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(chunk_targets), chunker_file],
actions,
)
if chestnut:
lenv.SideEffect(chestnut_lock, node)
# get model metadata
fn = File(f"models/dmonitoring_model").abspath
script_files = [File(Dir("#openpilot/selfdrive/modeld").File("get_model_metadata.py").abspath)]
cmd = f'{tg_flags} {mac_brew_string} python3 {Dir("#openpilot/selfdrive/modeld").abspath}/get_model_metadata.py {fn}.onnx'
lenv.Command(fn + "_metadata.pkl", [fn + ".onnx"] + tinygrad_files + script_files + [tg_devices_node], cmd)
dm_w, dm_h = DM_INPUT_SIZE
for cam_w, cam_h in camera_configs:
compile_dm_warp_script = [File(f"{modeld_dir}/compile_dm_warp.py")]
for cam_w, cam_h in CAMERA_CONFIGS:
dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath
stride, y_height, uv_height, frame_size = get_nv12_info(cam_w, cam_h)
cmd = (f'{tg_flags} {mac_brew_string} python3 "{compiler}/compile_warp.py" '
f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{frame_size} --warp-to {dm_w}x{dm_h} '
f'--layout luma --border-fill 16 --transform-device NPY --output {dm_pkl_path}')
lenv.Command(dm_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], cmd)
cmd = (f'{tg_flags} {mac_brew_string} python3 {modeld_dir}/compile_dm_warp.py '
f'--camera-resolution {cam_w}x{cam_h} --warp-to {dm_w}x{dm_h} '
f'--output {dm_pkl_path}')
lenv.Command(dm_pkl_path, tinygrad_files + compile_dm_warp_script + compile_modeld_script + [tg_devices_node], cmd)
def tg_compile(flags, model_name):
pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + '"'
fn = File(f"models/{model_name}").abspath
pkl = fn + "_tinygrad.pkl"
onnx_path = fn + ".onnx"
chunk_targets = get_chunk_targets(pkl, estimate_pickle_max_size(os.path.getsize(onnx_path)))
def do_chunk(target, source, env):
chunk_file(pkl, chunk_targets)
return lenv.Command(
chunk_targets,
[onnx_path] + tinygrad_files + [Value(chunk_targets), chunker_file, tg_devices_node],
[f'{pythonpath_string} {flags} python3 {Dir("#tinygrad_repo").abspath}/examples/openpilot/compile3.py {fn}.onnx {pkl}',
Action(do_chunk, " [CHUNK] $TARGET")],
)
if not os.getenv('SKIP_TINYGRAD_COMPILE'):
tg_compile(tg_flags, 'dmonitoring_model')
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
import argparse
import pickle
import time
from tinygrad.tensor import Tensor
from tinygrad.device import Device
from tinygrad.engine.jit import TinyJit
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
from openpilot.selfdrive.modeld.compile_modeld import NV12Frame, warp_perspective_tinygrad, _parse_size
def make_warp_dm(nv12: NV12Frame, dm_w, dm_h):
cam_w, cam_h, stride, _, _, _ = nv12
stride_pad = stride - cam_w
def warp_dm(input_frame, M_inv):
M_inv = M_inv.to(Device.DEFAULT).realize()
return warp_perspective_tinygrad(input_frame[:cam_h*stride], M_inv,
(dm_w, dm_h), (cam_h, cam_w), stride_pad, border_fill_val=16).reshape(-1, dm_h * dm_w) # Y
return warp_dm
def compile_dm_warp(nv12: NV12Frame, dm_w, dm_h, pkl_path):
print(f"Compiling DM warp for {nv12.width}x{nv12.height} -> {dm_w}x{dm_h}...")
warp_dm_jit = TinyJit(make_warp_dm(nv12, dm_w, dm_h), prune=True)
for i in range(10):
frame = Tensor.randint(nv12.size, low=0, high=256, dtype='uint8').realize()
M_inv = Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY')
Device.default.synchronize()
st = time.perf_counter()
warp_dm_jit(frame, M_inv).realize()
mt = time.perf_counter()
Device.default.synchronize()
et = time.perf_counter()
print(f" [{i+1}/10] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms")
with open(pkl_path, "wb") as f:
pickle.dump(warp_dm_jit, f)
print(f" Saved to {pkl_path}")
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument('--camera-resolution', type=_parse_size, required=True, help='camera resolution WxH')
p.add_argument('--warp-to', type=_parse_size, required=True, help='DM input WxH')
p.add_argument('--output', required=True)
args = p.parse_args()
cam_w, cam_h = args.camera_resolution
nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h))
dm_w, dm_h = args.warp_to
compile_dm_warp(nv12, dm_w, dm_h, args.output)
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
import argparse
import atexit
import math
import os
import tempfile
import time
import shutil
from functools import partial
from collections import namedtuple
import numpy as np
from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob
def _patch_tinygrad_fetch_fw():
import hashlib
import pathlib
import zstandard
from tinygrad import helpers
_orig = helpers.fetch_fw
def fetch_fw(path, name, sha256):
p = pathlib.Path(f"/lib/firmware/{path}/{name}.zst")
if p.is_file():
blob = zstandard.ZstdDecompressor().stream_reader(p.read_bytes()).read()
if hashlib.sha256(blob).hexdigest() == sha256:
return blob
return _orig(path, name, sha256)
helpers.fetch_fw = fetch_fw
_patch_tinygrad_fetch_fw()
from tinygrad.tensor import Tensor
from tinygrad.helpers import Context
from tinygrad.device import Device
from tinygrad.engine.jit import TinyJit
NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size'])
WARP_INPUTS = ['tfm', 'big_tfm']
POLICY_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs']
UV_SCALE_MATRIX = np.array([[0.5, 0, 0], [0, 0.5, 0], [0, 0, 1]], dtype=np.float32)
UV_SCALE_MATRIX_INV = np.linalg.inv(UV_SCALE_MATRIX)
WARP_DEV = os.getenv('WARP_DEV')
def make_random_images(keys, shape, device=None):
return {k: Tensor.randint(shape, low=0, high=256, dtype='uint8', device=device).realize() for k in keys}
def warp_perspective_tinygrad(src_flat, M_inv, dst_shape, src_shape, stride_pad, border_fill_val=None):
w_dst, h_dst = dst_shape
h_src, w_src = src_shape
x = Tensor.arange(w_dst).reshape(1, w_dst).expand(h_dst, w_dst).reshape(-1)
y = Tensor.arange(h_dst).reshape(h_dst, 1).expand(h_dst, w_dst).reshape(-1)
# inline 3x3 matmul as elementwise to avoid reduce op (enables fusion with gather)
src_x = M_inv[0, 0] * x + M_inv[0, 1] * y + M_inv[0, 2]
src_y = M_inv[1, 0] * x + M_inv[1, 1] * y + M_inv[1, 2]
src_w = M_inv[2, 0] * x + M_inv[2, 1] * y + M_inv[2, 2]
src_x = src_x / src_w
src_y = src_y / src_w
x_round = Tensor.round(src_x)
y_round = Tensor.round(src_y)
x_nn_clipped = x_round.clip(0, w_src - 1).cast('int')
y_nn_clipped = y_round.clip(0, h_src - 1).cast('int')
idx = y_nn_clipped * (w_src + stride_pad) + x_nn_clipped
sampled = src_flat[idx]
if border_fill_val is None:
return sampled
in_bounds = ((x_round >= 0) & (x_round <= w_src - 1) &
(y_round >= 0) & (y_round <= h_src - 1)).cast(sampled.dtype)
return sampled * in_bounds + Tensor(border_fill_val, dtype=sampled.dtype) * (1 - in_bounds)
def frames_to_tensor(frames):
H = (frames.shape[0] * 2) // 3
W = frames.shape[1]
in_img1 = Tensor.cat(frames[0:H:2, 0::2],
frames[1:H:2, 0::2],
frames[0:H:2, 1::2],
frames[1:H:2, 1::2],
frames[H:H+H//4].reshape((H//2, W//2)),
frames[H+H//4:H+H//2].reshape((H//2, W//2)), dim=0).reshape((6, H//2, W//2))
return in_img1
def make_frame_prepare(nv12: NV12Frame, model_w, model_h):
cam_w, cam_h, stride, y_height, uv_height, _ = nv12
uv_offset = stride * y_height
stride_pad = stride - cam_w
def frame_prepare_tinygrad(input_frame, M_inv):
# UV_SCALE @ M_inv @ UV_SCALE_INV simplifies to elementwise scaling
M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=WARP_DEV)
# deinterleave NV12 UV plane (UVUV... -> separate U, V)
uv = input_frame[uv_offset:uv_offset + uv_height * stride].reshape(uv_height, stride)
with Context(SPLIT_REDUCEOP=0):
y = warp_perspective_tinygrad(input_frame[:cam_h*stride],
M_inv, (model_w, model_h),
(cam_h, cam_w), stride_pad).realize()
u = warp_perspective_tinygrad(uv[:cam_h//2, :cam_w:2].flatten(),
M_inv_uv, (model_w//2, model_h//2),
(cam_h//2, cam_w//2), 0).realize()
v = warp_perspective_tinygrad(uv[:cam_h//2, 1:cam_w:2].flatten(),
M_inv_uv, (model_w//2, model_h//2),
(cam_h//2, cam_w//2), 0).realize()
yuv = y.cat(u).cat(v).reshape((model_h * 3 // 2, model_w))
tensor = frames_to_tensor(yuv)
return tensor
return frame_prepare_tinygrad
def make_warp_input_queues(vision_input_shapes, frame_skip, device):
img = vision_input_shapes['img'] # (1, 12, 128, 256)
n_frames = img[1] // 6
img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img[2], img[3])
npy = {
'tfm': np.zeros((3, 3), dtype=np.float32),
'big_tfm': np.zeros((3, 3), dtype=np.float32),
}
input_queues = {
'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(),
'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(),
**{k: Tensor(v, device='NPY').realize() for k, v in npy.items()},
}
return input_queues, npy
def get_policy_npy_shapes(input_shapes):
dp = input_shapes['desire_pulse'] # (1, 25, 8)
tc = input_shapes['traffic_convention'] # (1, 2)
at = input_shapes['action_t'] # (1, 2)
fb = input_shapes['features_buffer'] # (1, 24, 512)
# TODO prev_feat shouldn't exist and be handled inside the JIT, but corrupt on QCOM for now
shapes = {'desire': (dp[2],), 'traffic_convention': tuple(tc), 'action_t': tuple(at), 'prev_feat': (fb[0], fb[2])}
return shapes, [math.prod(s) for s in shapes.values()]
def make_input_queues(input_shapes, frame_skip, device):
input_queues, npy = make_warp_input_queues(input_shapes, frame_skip, device)
fb = input_shapes['features_buffer'] # (1, 24, 512), past features only; the model appends the current frame's feature
dp = input_shapes['desire_pulse'] # (1, 25, 8)
shapes, sizes = get_policy_npy_shapes(input_shapes)
packed_npy_inputs = np.zeros(sum(sizes), dtype=np.float32)
# views into the packed inputs, to be refilled at runtime
npy.update({k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)})
input_queues.update({
'feat_q': Tensor(np.zeros((frame_skip * fb[1], fb[0], fb[2]), dtype=np.float32), device=device).contiguous().realize(),
'desire_q': Tensor(np.zeros((frame_skip * dp[1], dp[0], dp[2]), dtype=np.float32), device=device).contiguous().realize(),
'packed_npy_inputs': Tensor(packed_npy_inputs, device='NPY').realize(),
})
return input_queues, npy
def shift_and_sample(buf, new_val, sample_fn):
buf.assign(buf[1:].cat(new_val, dim=0).contiguous())
return sample_fn(buf)
def sample_skip(buf, frame_skip):
return buf[::frame_skip].contiguous().flatten(0, 1).unsqueeze(0)
def sample_desire(buf, frame_skip):
return buf.reshape(-1, frame_skip, *buf.shape[1:]).max(1).flatten(0, 1).unsqueeze(0)
def make_warp(nv12, model_w, model_h, frame_skip):
frame_prepare = make_frame_prepare(nv12, model_w, model_h)
def warp(tfm, big_tfm, frame, big_frame):
tfm = tfm.to(WARP_DEV)
big_tfm = big_tfm.to(WARP_DEV)
Tensor.realize(tfm, big_tfm)
warped_frame = frame_prepare(frame, tfm).unsqueeze(0)
warped_big_frame = frame_prepare(big_frame, big_tfm).unsqueeze(0)
return Tensor.cat(warped_frame, warped_big_frame)
return warp
def make_run_policy(model_runner, model_metadata, frame_skip):
sample_desire_fn = partial(sample_desire, frame_skip=frame_skip)
sample_skip_fn = partial(sample_skip, frame_skip=frame_skip)
npy_shapes, npy_sizes = get_policy_npy_shapes(model_metadata['input_shapes'])
def run_policy(warped, img_q, big_img_q, feat_q, desire_q, packed_npy_inputs):
packed_npy_inputs = packed_npy_inputs.to(Device.DEFAULT)
warped = warped.to(Device.DEFAULT)
Tensor.realize(packed_npy_inputs, warped)
img = shift_and_sample(img_q, warped[0:1], sample_skip_fn)
big_img = shift_and_sample(big_img_q, warped[1:2], sample_skip_fn)
desire, traffic_convention, action_t, prev_feat = (t.reshape(s) for t, s in zip(packed_npy_inputs.split(npy_sizes), npy_shapes.values(), strict=True))
desire_buf = shift_and_sample(desire_q, desire.reshape(1, 1, -1), sample_desire_fn)
feat_buf = shift_and_sample(feat_q, prev_feat.reshape(1, 1, -1), sample_skip_fn)
inputs = {
'img': img,
'big_img': big_img,
'features_buffer': feat_buf,
'desire_pulse': desire_buf,
'traffic_convention': traffic_convention,
'action_t': action_t,
}
out = next(iter(model_runner(inputs).values())).cast('float32')
return out,
return run_policy
def compile_jit(jit, make_random_inputs, input_keys, make_queues):
SEED = 42
def random_inputs_run(fn, seed, test_val=None, test_buffers=None, expect_match=True):
input_queues, npy = make_queues(Device.DEFAULT)
rng = np.random.default_rng(seed)
Tensor.manual_seed(seed)
testing = test_val is not None or test_buffers is not None
n_runs = 1 if testing else 3
for i in range(n_runs):
for v in npy.values():
v[:] = rng.standard_normal(v.shape).astype(v.dtype)
Device.default.synchronize()
random_inputs = make_random_inputs()
st = time.perf_counter()
outs = fn(**{k: input_queues[k] for k in input_keys}, **random_inputs)
mt = time.perf_counter()
Device.default.synchronize()
et = time.perf_counter()
print(f" [{i+1}/{n_runs}] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms")
if i == 0:
val = [np.copy(v.numpy()) for v in outs]
buffers = [np.copy(v.numpy().copy()) for v in input_queues.values()]
if test_val is not None:
match = all(np.array_equal(a, b) for a, b in zip(val, test_val, strict=True))
assert match == expect_match, f"outputs {'differ from' if expect_match else 'match'} baseline (seed={seed})"
if test_buffers is not None:
match = all(np.array_equal(a, b) for a, b in zip(buffers, test_buffers, strict=True))
assert match == expect_match, f"buffers {'differ from' if expect_match else 'match'} baseline (seed={seed})"
return val, buffers
print('capture + replay')
test_val, test_buffers = random_inputs_run(jit, SEED)
print('pickle round trip')
with tempfile.TemporaryFile(dir=".") as f:
dump_oob(jit, f)
f.seek(0)
jit = load_oob(f)
random_inputs_run(jit, SEED, test_val, test_buffers, expect_match=True)
random_inputs_run(jit, SEED+1, test_val, test_buffers, expect_match=False)
return jit
def _parse_size(s):
w, h = s.lower().split('x')
return int(w), int(h)
def read_file_chunked_to_disk(path):
from openpilot.common.file_chunker import open_file_chunked
tmp_path = f'{path}.unchunked'
with open(tmp_path, 'wb') as f, open_file_chunked(path) as src:
shutil.copyfileobj(src, f)
atexit.register(lambda: os.path.exists(tmp_path) and os.remove(tmp_path))
return tmp_path
if __name__ == "__main__":
from tinygrad.nn.onnx import OnnxRunner
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
from openpilot.selfdrive.modeld.get_model_metadata import make_metadata_dict
p = argparse.ArgumentParser()
p.add_argument('--model-size', type=_parse_size, required=True, help='model input WxH')
p.add_argument('--camera-resolutions', type=_parse_size, nargs='+', required=True,
help='camera resolutions WxH (one or more)')
p.add_argument('--onnx', required=True)
p.add_argument('--output', required=True)
p.add_argument('--frame-skip', type=int, required=True)
args = p.parse_args()
model_path = read_file_chunked_to_disk(args.onnx)
model_w, model_h = args.model_size
model_runner = OnnxRunner(model_path)
out = {'metadata': make_metadata_dict(model_path)}
run_policy_jit = TinyJit(make_run_policy(model_runner, out['metadata'], args.frame_skip), prune=True)
make_policy_queues = partial(make_input_queues, out['metadata']['input_shapes'], args.frame_skip)
make_random_model_inputs = partial(make_random_images, keys=['warped'], shape=(2, 6, *out['metadata']['input_shapes']['img'][2:]), device=WARP_DEV)
out['run_policy'] = compile_jit(run_policy_jit, make_random_model_inputs, POLICY_INPUTS,
make_policy_queues)
for cam_w, cam_h in args.camera_resolutions:
nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h))
make_random_warp_inputs = partial(make_random_images, keys=['frame', 'big_frame'], shape=nv12.size, device=WARP_DEV)
warp = TinyJit(make_warp(nv12, model_w, model_h, args.frame_skip), prune=True)
make_warp_queues = partial(make_warp_input_queues, out['metadata']['input_shapes'], args.frame_skip)
out[(cam_w,cam_h)] = compile_jit(warp, make_random_warp_inputs, WARP_INPUTS, make_warp_queues)
with open(args.output, "wb") as f:
dump_oob(out, f)
print(f"Saved JITs to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB)")
+13 -15
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
import os
import base64
from openpilot.selfdrive.modeld.helpers import MODELS_DIR, load_oob
from openpilot.selfdrive.modeld.helpers import MODELS_DIR, get_tg_input_devices
from tinygrad.tensor import Tensor
import time
import pickle
@@ -19,8 +18,10 @@ from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
from openpilot.common.file_chunker import open_file_chunked
from openpilot.selfdrive.modeld.parse_model_outputs import sigmoid, safe_exp
PROCESS_NAME = "openpilot.selfdrive.modeld.dmonitoringmodeld"
SEND_RAW_PRED = os.getenv('SEND_RAW_PRED')
MODEL_PKL_PATH = MODELS_DIR / 'dmonitoring_model_tinygrad.pkl'
METADATA_PATH = MODELS_DIR / 'dmonitoring_model_metadata.pkl'
class ModelState:
@@ -28,10 +29,11 @@ class ModelState:
output: np.ndarray
def __init__(self, cam_w: int, cam_h: int):
jits = load_oob(open_file_chunked(MODEL_PKL_PATH))
self.DEV = jits['input_specs']['input_img'][2]
self.input_shapes = jits['metadata']['input_shapes']
self.output_slices = pickle.loads(base64.b64decode(jits['metadata']['metadata']['output_slices']))
self.DEV = get_tg_input_devices(PROCESS_NAME, chestnut=False)['DEV']
with open(METADATA_PATH, 'rb') as f:
model_metadata = pickle.load(f)
self.input_shapes = model_metadata['input_shapes']
self.output_slices = model_metadata['output_slices']
self.numpy_inputs = {
'calib': np.zeros(self.input_shapes['calib'], dtype=np.float32),
@@ -40,19 +42,16 @@ class ModelState:
self.warp_inputs_np = {'transform': np.zeros((3,3), dtype=np.float32)}
self.warp_inputs = {k: Tensor(v, device='NPY') for k,v in self.warp_inputs_np.items()}
self.frame_buf_params = get_nv12_info(cam_w, cam_h)
self.tensor_inputs = {k: Tensor(v, device=self.DEV).realize() for k,v in self.numpy_inputs.items()}
self.calib_host = Tensor(self.numpy_inputs['calib'], device='NPY')._buffer()
self.tensor_inputs = {k: Tensor(v, device='NPY').realize() for k,v in self.numpy_inputs.items()}
self._blob_cache : dict[int, Tensor] = {}
self.model_run = jits['run']
self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in jits['output_specs'].items()}
self.model_run = pickle.load(open_file_chunked(str(MODEL_PKL_PATH)))
with open(MODELS_DIR / f'dm_warp_{cam_w}x{cam_h}_tinygrad.pkl', "rb") as f:
self.image_warp = pickle.load(f)['run']
self.image_warp = pickle.load(f)
def run(self, buf: VisionBuf, calib: np.ndarray, transform: np.ndarray) -> tuple[np.ndarray, float]:
self.numpy_inputs['calib'][0,:] = calib
t1 = time.perf_counter()
self.tensor_inputs['calib']._buffer().copy_from(self.calib_host)
ptr = np.frombuffer(buf.data, dtype=np.uint8).ctypes.data
# There is a ringbuffer of imgs, just cache tensors pointing to all of them
@@ -60,10 +59,9 @@ class ModelState:
self._blob_cache[ptr] = Tensor.from_blob(ptr, (self.frame_buf_params[3],), dtype='uint8', device=self.DEV)
self.warp_inputs_np['transform'][:] = transform[:]
self.tensor_inputs['input_img'] = self.image_warp(input_frame=self._blob_cache[ptr], M_inv=self.warp_inputs['transform'])
self.tensor_inputs['input_img'] = self.image_warp(self._blob_cache[ptr], self.warp_inputs['transform'])
self.model_run(output_buffers=self.outputs, **self.tensor_inputs)
output = self.outputs['outputs'].numpy().astype(np.float32).reshape(-1)
output = self.model_run(**self.tensor_inputs).numpy().flatten()
t2 = time.perf_counter()
return output, t2 - t1
@@ -64,7 +64,6 @@ def fill_driving_model_data(msg: capnp._DynamicStructBuilder, modelv2_send: capn
driving_model_data.frameIdExtra = modelV2.frameIdExtra
driving_model_data.frameDropPerc = modelV2.frameDropPerc
driving_model_data.modelExecutionTime = modelV2.modelExecutionTime
driving_model_data.big = modelV2.big
driving_model_data.action = modelV2.action
driving_model_data.meta.laneChangeState = modelV2.meta.laneChangeState
driving_model_data.meta.laneChangeDirection = modelV2.meta.laneChangeDirection
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
import sys
import pathlib
import codecs
import pickle
from typing import Any
from tinygrad.nn.onnx import OnnxPBParser
class MetadataOnnxPBParser(OnnxPBParser):
def _parse_ModelProto(self) -> dict:
obj: dict[str, Any] = {"graph": {"input": [], "output": []}, "metadata_props": []}
for fid, wire_type in self._parse_message(self.reader.len):
match fid:
case 7:
obj["graph"] = self._parse_GraphProto()
case 14:
obj["metadata_props"].append(self._parse_StringStringEntryProto())
case _:
self.reader.skip_field(wire_type)
return obj
def get_name_and_shape(value_info: dict[str, Any]) -> tuple[str, tuple[int, ...]]:
shape = tuple(int(dim) if isinstance(dim, int) else 0 for dim in value_info["parsed_type"].shape)
name = value_info["name"]
return name, shape
def get_metadata_value_by_name(model: dict[str, Any], name: str) -> str | Any:
for prop in model["metadata_props"]:
if prop["key"] == name:
return prop["value"]
return None
def make_metadata_dict(model_path):
model = MetadataOnnxPBParser(model_path).parse()
output_slices = get_metadata_value_by_name(model, 'output_slices')
assert output_slices is not None, 'output_slices not found in metadata'
return {
'model_checkpoint': get_metadata_value_by_name(model, 'model_checkpoint'),
'output_slices': pickle.loads(codecs.decode(output_slices.encode(), "base64")),
'input_shapes': dict(get_name_and_shape(x) for x in model["graph"]["input"]),
'output_shapes': dict(get_name_and_shape(x) for x in model["graph"]["output"]),
}
if __name__ == "__main__":
model_path = pathlib.Path(sys.argv[1])
metadata_path = model_path.parent / (model_path.stem + '_metadata.pkl')
with open(metadata_path, 'wb') as f:
pickle.dump(make_metadata_dict(model_path), f)
print(f'saved metadata to {metadata_path}')
+27 -7
View File
@@ -1,25 +1,47 @@
import io
import json
import pickle
import shutil
import struct
import tempfile
from pathlib import Path
from openpilot.common.file_chunker import get_manifest_path
from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, is_chestnut_usb_id
from openpilot.common.hardware.usb import CHESTNUT_FW_VERSION, CHESTNUT_USB_IDS, USB_DEVICES_PATH
MODELS_DIR = Path(__file__).resolve().parent / 'models'
TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json'
def get_tg_input_devices(process_name: str, chestnut: bool):
with open(TG_INPUT_DEVICES_PATH) as f:
return json.load(f)[process_name]['default' if not chestnut else 'chestnut']
def modeld_pkl_path(chestnut: bool):
prefix = 'big_' if chestnut else ''
return MODELS_DIR / f'{prefix}driving_tinygrad.pkl'
def dump_oob(obj, f):
with tempfile.TemporaryFile(dir=".") as tmp:
def buffer_callback(pb: pickle.PickleBuffer):
m = pb.raw()
tmp.write(struct.pack('<q', m.nbytes))
tmp.write(m)
pb.release() # keep peak ram at ~1 buffer
stream = io.BytesIO()
pickle.Pickler(stream, protocol=5, buffer_callback=buffer_callback).dump(obj)
opcodes = stream.getvalue()
f.write(struct.pack('<q', len(opcodes)))
f.write(opcodes)
tmp.seek(0)
shutil.copyfileobj(tmp, f)
def load_oob(f):
opcodes = f.read(struct.unpack('<q', f.read(8))[0])
def buffers():
while (h := f.read(8)):
pb = pickle.PickleBuffer(bytearray(struct.unpack('<q', h)[0]))
if f.readinto(pb) != pb.raw().nbytes:
raise EOFError("incomplete model buffer")
f.readinto(pb)
yield pb
return pickle.load(io.BytesIO(opcodes), buffers=buffers())
@@ -28,13 +50,11 @@ def chestnut_present() -> bool:
try:
usb_id = (int((d / "idVendor").read_text(), 16), int((d / "idProduct").read_text(), 16))
product = (d / "product").read_text().strip()
if is_chestnut_usb_id(*usb_id) and product == CHESTNUT_USB_PRODUCT:
if usb_id in CHESTNUT_USB_IDS and product == f"custom {CHESTNUT_FW_VERSION}-CLEAN":
return True
except Exception:
pass
return False
def chestnut_compiled() -> bool:
path = modeld_pkl_path(chestnut=True)
return (path.is_file() or Path(get_manifest_path(path)).is_file()) and all(
(MODELS_DIR / f'big_driving_warp_{size}_tinygrad.pkl').is_file() for size in ('1344x760', '1928x1208'))
return Path(get_manifest_path(modeld_pkl_path(chestnut=True))).is_file()
+76 -69
View File
@@ -1,17 +1,12 @@
#!/usr/bin/env python3
from collections.abc import Callable
import base64
import ctypes
from functools import cached_property
import os
os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom
from tinygrad.device import Buffer, Device
from tinygrad.dtype import DType, dtypes
from tinygrad.tensor import Tensor
from tinygrad.helpers import round_up
from tinygrad.uop.ops import UOp
import math
import pickle
from tinygrad.device import Device
import struct
import threading
import time
import numpy as np
@@ -33,11 +28,17 @@ from openpilot.common.transformations.model import get_warp_matrix
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, should_stop, smooth_value, get_curvature_from_plan
from openpilot.selfdrive.modeld.parse_model_outputs import Parser
from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, WARP_INPUTS, POLICY_INPUTS
from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState
from openpilot.common.file_chunker import open_file_chunked
from openpilot.selfdrive.modeld.constants import ModelConstants, Plan
from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob
from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, modeld_pkl_path, get_tg_input_devices, load_oob
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase
from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController
PROCESS_NAME = "openpilot.selfdrive.modeld.modeld"
SEND_RAW_PRED = os.getenv('SEND_RAW_PRED')
LAT_SMOOTH_SECONDS = 0.0
@@ -74,8 +75,8 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log.
shouldStop=bool(stop))
class ChestnutGpuState:
# GPU metrics require modeld's GPU context
class ChestnutState:
# only modeld can access chestnut
def __init__(self, pm: PubMaster, big: bool):
self.pm = pm
self.big = big
@@ -89,8 +90,8 @@ class ChestnutGpuState:
return smu._send_msg(smu.smu_mod.PPSMC_MSG_GetPptLimit, 0, read_back_arg=True, timeout=100)
def send(self) -> None:
msg = messaging.new_message('chestnutGpuState')
state = msg.chestnutGpuState
msg = messaging.new_message('chestnutState')
state = msg.chestnutState
self.sends += 1
if self.big and "AMD" in Device._opened_devices and self.sends % 100 == 1:
try:
@@ -116,8 +117,19 @@ class ChestnutGpuState:
for k, v in self.metrics.items():
setattr(state, k, v)
msg.valid = not self.big or (self.valid and bool(self.metrics))
self.pm.send('chestnutGpuState', msg)
asm_valid = False
if "AMD" in Device._opened_devices:
try:
# ASM runs on USB-C power, these still read without a gpu
asm = Device["AMD"].iface.pci_dev.usb
state.pcieLtssm = asm.read(0xB450, 1)[0]
state.supplyVoltage, state.supplyCurrent = struct.unpack('<Hh', bytes(asm.usb.control_read(0xC0, 5))[:4])
asm_valid = True
except Exception:
pass
msg.valid = asm_valid and (not self.big or self.valid)
self.pm.send('chestnutState', msg)
class FrameMeta:
@@ -130,64 +142,45 @@ class FrameMeta:
self.frame_id, self.timestamp_sof, self.timestamp_eof = vipc.frame_id, vipc.timestamp_sof, vipc.timestamp_eof
def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int) -> Tensor:
view = buffer.view(math.prod(shape), dtype, offset).ensure_allocated()
return Tensor(UOp.from_buffer(view)).reshape(shape)
class ModelState:
class ModelState(ModelStateBase):
prev_desire: np.ndarray # for tracking the rising edge of the pulse
def __init__(self, cam_w: int, cam_h: int, chestnut: bool):
ModelStateBase.__init__(self)
input_devices = get_tg_input_devices(PROCESS_NAME, chestnut)
self.WARP_DEV, self.QUEUE_DEV = input_devices['WARP_DEV'], input_devices['QUEUE_DEV']
jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut)))
self.model_device = jits['input_specs']['new_img'][2]
self.input_shapes = {name: (shape, np.dtype(dtype)) for name, (shape, dtype, _) in jits['input_specs'].items()}
self.state_pairs = {name: f'next_{name}' for name in self.input_shapes if f'next_{name}' in jits['metadata']['output_shapes']}
self.vision_input_names = ('img', 'big_img')
self.output_slices = pickle.loads(base64.b64decode(jits['metadata']['metadata']['output_slices']))
metadata = jits['metadata']
self.input_shapes = metadata['input_shapes']
self.vision_input_names = [k for k in self.input_shapes if 'img' in k]
self.output_slices = metadata['output_slices']
self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32)
self.chestnut = chestnut
stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h)
self.frame_copy_size = stride * (y_height + uv_height)
self.pack_inputs()
with open(MODELS_DIR / f'{"big_" if chestnut else ""}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl', 'rb') as f:
self.run_warp = pickle.load(f)['run']
self.run_model = jits['run']
self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in jits['output_specs'].items()}
for name, next_name in self.state_pairs.items():
state = self.input_queues[name]
self.outputs[next_name] = input_view(state._buffer(), state.shape, state.dtype, 0)
self.frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ
self.input_queues, self.npy = make_input_queues(self.input_shapes, self.frame_skip, device=self.QUEUE_DEV)
self.full_frames: dict[str, Tensor] = {}
self._blob_cache: dict[tuple[str, int], Tensor] = {}
self.parser = Parser()
def pack_inputs(self) -> None:
# Pack host inputs into one upload to reduce USB transfer overhead for the eGPU.
self.input_queues = {name: Tensor(np.zeros(shape, dtype=dtype), device=self.model_device).realize()
for name, (shape, dtype) in self.input_shapes.items() if name in self.state_pairs}
shapes = {'tfm': (2, 3, 3)} | {name: shape for name, (shape, _) in self.input_shapes.items()
if name not in self.state_pairs and name != 'new_img'}
npy_size = sum(round_up(math.prod(shape) * 4, 128) for shape in shapes.values())
self.packed_input = np.zeros(npy_size + 2 * self.frame_copy_size, dtype=np.uint8)
self.input_host = Tensor(self.packed_input, device='NPY')._buffer()
self.input_device = Tensor(self.packed_input, device=self.model_device)._buffer()
self.npy = {}
offset = 0
for name, shape in shapes.items():
self.npy[name] = np.ndarray(shape, dtype=np.float32, buffer=self.packed_input, offset=offset)
self.input_queues[name] = input_view(self.input_device, shape, dtypes.float32, offset)
offset += round_up(self.npy[name].nbytes, 128)
self.frames = self.packed_input[npy_size:].reshape(2, self.frame_copy_size)
self.warp_inputs = {'input_frame': input_view(self.input_device, self.frames.shape, dtypes.uint8, npy_size), 'M_inv': self.input_queues.pop('tfm')}
self.frame_buf_params = {k: get_nv12_info(cam_w, cam_h) for k in ('img', 'big_img')}
self.run_policy = jits['run_policy']
self.warp = jits[(cam_w,cam_h)]
def slice_outputs(self, model_outputs: np.ndarray, output_slices: dict[str, slice]) -> dict[str, np.ndarray]:
return {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()}
parsed_model_outputs = {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()}
return parsed_model_outputs
def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray],
inputs: dict[str, np.ndarray], after_enqueue: Callable[[], None] | None = None) -> dict[str, np.ndarray]:
for i, key in enumerate(self.vision_input_names):
np.copyto(self.frames[i], np.frombuffer(bufs[key].data, dtype=np.uint8, count=self.frame_copy_size))
self.npy['tfm'][i] = transforms[key]
for key in bufs.keys():
ptr = np.frombuffer(bufs[key].data, dtype=np.uint8).ctypes.data
yuv_size = self.frame_buf_params[key][3]
# There is a ringbuffer of imgs, just cache tensors pointing to all of them
cache_key = (key, ptr)
if cache_key not in self._blob_cache:
self._blob_cache[cache_key] = Tensor.from_blob(ptr, (yuv_size,), dtype='uint8', device=self.WARP_DEV)
self.full_frames[key] = self._blob_cache[cache_key]
# Model decides when action is completed, so desire input is just a pulse triggered on rising edge
inputs['desire_pulse'][0] = 0
@@ -195,30 +188,35 @@ class ModelState:
self.prev_desire[:] = inputs['desire_pulse']
self.npy['traffic_convention'][:] = inputs['traffic_convention']
self.npy['action_t'][:] = inputs['action_t']
self.npy['tfm'][:,:] = transforms['img'][:,:]
self.npy['big_tfm'][:,:] = transforms['big_img'][:,:]
self.input_device.copy_from(self.input_host)
self.input_queues['new_img'] = self.run_warp(**self.warp_inputs)
self.run_model(output_buffers=self.outputs, **self.input_queues)
warped = self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=self.full_frames['img'], big_frame=self.full_frames['big_img'])
outs, = self.run_policy(
**{k: self.input_queues[k] for k in POLICY_INPUTS if k in self.input_queues}, warped=warped
)
if after_enqueue is not None:
after_enqueue()
model_output = self.outputs['outputs'].numpy()[0]
model_output = outs.numpy()[0]
if self.chestnut and not np.all(np.isfinite(model_output)):
raise RuntimeError("model output not finite")
outputs_dict = self.parser.parse_outputs(self.slice_outputs(model_output, self.output_slices))
self.npy['prev_feat'][:] = model_output[self.output_slices['hidden_state']]
if SEND_RAW_PRED:
outputs_dict['raw_pred'] = model_output.copy()
return outputs_dict
def warmup(self) -> None:
dummy_frames = {k: np.zeros(self.frame_copy_size, dtype=np.uint8) for k in self.vision_input_names}
dummy_frames = {k: np.zeros(self.frame_buf_params[k][3], dtype=np.uint8) for k in self.vision_input_names}
eye = np.eye(3, dtype=np.float32)
dims = {'desire_pulse': ModelConstants.DESIRE_LEN, 'traffic_convention': 2, 'action_t': 2}
self.run(dummy_frames, dict.fromkeys(self.vision_input_names, eye), {k: np.zeros(v, dtype=np.float32) for k, v in dims.items()})
self.packed_input[:] = 0
for key in self.state_pairs:
self.input_queues[key].assign(0).realize()
self.input_queues, self.npy = make_input_queues(self.input_shapes, self.frame_skip, device=self.QUEUE_DEV)
self.prev_desire[:] = 0
self.full_frames.clear()
self._blob_cache.clear()
def main(demo=False):
@@ -279,16 +277,17 @@ def main(demo=False):
if model is None:
model = small_model
params.put_bool("ChestnutLoading", False)
assert model is not None
cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting")
# messaging
pub_socks = ["modelV2", "drivingModelData", "cameraOdometry"] + (["chestnutGpuState"] if CHESTNUT else [])
pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutState"] if CHESTNUT else [])
pm = PubMaster(pub_socks)
sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"])
publish_state = PublishState()
params = Params()
chestnut_state = ChestnutGpuState(pm, model.chestnut) if CHESTNUT else None
chestnut_state = ChestnutState(pm, model.chestnut) if CHESTNUT else None
# setup filter to track dropped frames
frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_RUN_FREQ)
@@ -315,6 +314,7 @@ def main(demo=False):
prev_action = log.ModelDataV2.Action()
DH = DesireHelper()
RELC = RoadEdgeLaneChangeController()
while True:
# Keep receiving frames until we are at least 1 frame ahead of previous extra frame
@@ -354,6 +354,7 @@ def main(demo=False):
is_rhd = sm["driverMonitoringState"].isRHD
frame_id = sm["narrowRoadCameraState"].frameId
v_ego = max(sm["carState"].vEgo, 0.)
model.lat_delay = get_lat_delay(params, sm["lateralDelay"].lateralDelay)
lat_delay = sm["lateralDelay"].lateralDelay + LAT_SMOOTH_SECONDS
if sm.updated["extrinsicsCalibration"] and sm.seen['narrowRoadCameraState'] and sm.seen['deviceState']:
device_from_calib_euler = np.array(sm["extrinsicsCalibration"].rpyCalib, dtype=np.float32)
@@ -397,7 +398,7 @@ def main(demo=False):
mt1 = time.perf_counter()
try:
send_chestnut = (chestnut_state is not None and
run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutGpuState'].frequency) == 0)
run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0)
model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None)
except Exception:
if not params.get_bool("ChestnutActive"):
@@ -405,6 +406,7 @@ def main(demo=False):
# fallback to small model
cloudlog.exception("big model failed, fall back to small")
params.put_bool("ChestnutActive", False)
assert small_model is not None
model = small_model
if chestnut_state is not None:
chestnut_state.big = False
@@ -433,11 +435,16 @@ def main(demo=False):
modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state
modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction
mdv2sp_send = messaging.new_message('modelDataV2SP')
left_edge, right_edge = RELC.update_and_fill(modelv2_send.modelV2, mdv2sp_send.modelDataV2SP, v_ego)
mdv2sp_send.modelDataV2SP.laneTurnDirection = DH.lane_turn_direction
fill_driving_model_data(drivingdata_send, modelv2_send)
fill_pose_msg(posenet_send, model_output, meta_main.frame_id, vipc_dropped_frames, meta_main.timestamp_eof, extrinsics_calibration_seen)
pm.send('modelV2', modelv2_send)
pm.send('drivingModelData', drivingdata_send)
pm.send('cameraOdometry', posenet_send)
pm.send('modelDataV2SP', mdv2sp_send)
last_vipc_frame_id = meta_main.frame_id
if __name__ == "__main__":
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a501760a9d1d5fef0eab2b8c5d122d06124fc26dc8e0782e0aa94b82a208f0ff
size 1757355221
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:76cc0a9bc3af7a318889483dcbe126337f8d338f5abcbe664a8988c9b18b6639
size 776634338
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:65a08adc31d5c456219687d99b7bf5e44d61dae2d49ea67850e76105c7248cce
size 60918562
oid sha256:659727c4d4839adc4992a254409a54259a8756a743f2d567bf5fdc6579f8009b
size 60881999
+3 -1
View File
@@ -1 +1,3 @@
from openpilot.selfdrive.pandad.pandad_api_impl import can_list_to_can_capnp as can_list_to_can_capnp, can_capnp_to_list as can_capnp_to_list
from openpilot.selfdrive.pandad.pandad_api_impl import can_list_to_can_capnp, can_capnp_to_list
assert can_list_to_can_capnp
assert can_capnp_to_list
+12 -12
View File
@@ -123,22 +123,22 @@ void fill_panda_state(cereal::PandaState::Builder &ps, cereal::PandaState::Panda
ps.setUptime(health.uptime_pkt);
ps.setSafetyTxBlocked(health.safety_tx_blocked_pkt);
ps.setSafetyRxInvalid(health.safety_rx_invalid_pkt);
ps.setIgnitionLine((health.flags_pkt & HEALTH_FLAG_IGNITION_LINE) != 0U);
ps.setIgnitionCan((health.flags_pkt & HEALTH_FLAG_IGNITION_CAN) != 0U);
ps.setControlsAllowed((health.flags_pkt & HEALTH_FLAG_CONTROLS_ALLOWED) != 0U);
ps.setIgnitionLine(health.ignition_line_pkt);
ps.setIgnitionCan(health.ignition_can_pkt);
ps.setControlsAllowed(health.controls_allowed_pkt);
ps.setTxBufferOverflow(health.tx_buffer_overflow_pkt);
ps.setRxBufferOverflow(health.rx_buffer_overflow_pkt);
ps.setPandaType(hw_type);
ps.setSafetyModel(cereal::CarParams::SafetyModel(health.safety_mode_pkt));
ps.setSafetyParam(health.safety_param_pkt);
ps.setFaultStatus(cereal::PandaState::FaultStatus(health.fault_status_pkt));
ps.setPowerSaveEnabled((health.flags_pkt & HEALTH_FLAG_POWER_SAVE_ENABLED) != 0U);
ps.setHeartbeatLost((health.flags_pkt & HEALTH_FLAG_HEARTBEAT_LOST) != 0U);
ps.setPowerSaveEnabled((bool)(health.power_save_enabled_pkt));
ps.setHeartbeatLost((bool)(health.heartbeat_lost_pkt));
ps.setAlternativeExperience(health.alternative_experience_pkt);
ps.setHarnessStatus(cereal::PandaState::HarnessStatus(health.car_harness_status_pkt));
ps.setInterruptLoad(health.interrupt_load_pkt / 255.0f);
ps.setInterruptLoad(health.interrupt_load_pkt);
ps.setFanPower(health.fan_power);
ps.setSafetyRxChecksInvalid((health.flags_pkt & HEALTH_FLAG_SAFETY_RX_CHECKS_INVALID) != 0U);
ps.setSafetyRxChecksInvalid((bool)(health.safety_rx_checks_invalid_pkt));
ps.setSpiErrorCount(health.spi_error_count_pkt);
ps.setSbu1Voltage(health.sbu1_voltage_mV / 1000.0f);
ps.setSbu2Voltage(health.sbu2_voltage_mV / 1000.0f);
@@ -198,10 +198,10 @@ std::optional<bool> send_panda_states(PubMaster *pm, Panda *panda, bool is_onroa
}
if (spoofing_started) {
health.flags_pkt |= HEALTH_FLAG_IGNITION_LINE;
health.ignition_line_pkt = 1;
}
bool ignition_local = ((health.flags_pkt & (HEALTH_FLAG_IGNITION_LINE | HEALTH_FLAG_IGNITION_CAN)) != 0U) && !always_offroad;
bool ignition_local = ((health.ignition_line_pkt != 0) || (health.ignition_can_pkt != 0)) && !always_offroad;
// Make sure CAN buses are live: safety_setter_thread does not work if Panda CAN are silent and there is only one other CAN node
if (health.safety_mode_pkt == (uint8_t)(cereal::CarParams::SafetyModel::SILENT)) {
@@ -209,7 +209,7 @@ std::optional<bool> send_panda_states(PubMaster *pm, Panda *panda, bool is_onroa
}
bool power_save_desired = !ignition_local;
if (((health.flags_pkt & HEALTH_FLAG_POWER_SAVE_ENABLED) != 0U) != power_save_desired) {
if (health.power_save_enabled_pkt != power_save_desired) {
panda->set_power_saving(power_save_desired);
}
@@ -342,8 +342,8 @@ void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control,
}
}
// Disable IR on input timeout or when requested offroad.
if (nanos_since_boot() - last_cabin_camera_t > 1e9 || (!is_onroad && params.getBool("DisableDriverCameraIR"))) {
// Disable IR on input timeout
if (nanos_since_boot() - last_cabin_camera_t > 1e9) {
ir_pwr = 0;
}
@@ -19,30 +19,6 @@
},
"Offroad_ChestnutBranch": {
"text": "Chestnut detected! Switch to the %1 branch to use chestnut-class models.",
"severity": -1
},
"Offroad_ChestnutNotDetected": {
"text": "Chestnut not detected. Check USB and 12V connections.",
"severity": 0
},
"Offroad_ChestnutOverheated": {
"text": "Chestnut overheated. Ensure good airflow. Current GPU temperature is %1.",
"severity": 0
},
"Offroad_ChestnutPcieUnavailable": {
"text": "%1",
"severity": 0
},
"Offroad_ChestnutUncompiled": {
"text": "Chestnut model not compiled. Keep ignition on and reboot the comma.",
"severity": 0
},
"Offroad_ChestnutUpdateFailed": {
"text": "Chestnut update failed. Check the USB cable.",
"severity": 0
},
"Offroad_ChestnutUsbSlow": {
"text": "Chestnut USB link is slow. Check the USB cable. The current speed is %1.",
"severity": 0
},
"Offroad_UnregisteredHardware": {
+3 -5
View File
@@ -198,7 +198,6 @@ class SelfdriveD(CruiseHelper):
loading = self.params.get_bool("ChestnutLoading")
if self.big_model_loading and not loading:
self.big_model_ready_t = time.monotonic()
self.events_sp.add(custom.OnroadEventSP.EventName.bigModelReady)
self.big_model_loading = loading
if self.big_model_loading:
self.events.add(EventName.bigModelLoading)
@@ -453,12 +452,11 @@ class SelfdriveD(CruiseHelper):
self.logged_comm_issue = None
if not self.CP.notCar and not big_model_settling: # localization has nothing to work with during the load
# the defaults of a message that was never received are not a localizer failure
if self.sm.seen['deviceMotion'] and not self.sm['deviceMotion'].posenetOK:
if not self.sm['deviceMotion'].posenetOK:
self.events.add(EventName.posenetInvalid)
if self.sm.seen['deviceMotion'] and not self.sm['deviceMotion'].inputsOK:
if not self.sm['deviceMotion'].inputsOK:
self.events.add(EventName.locationdTemporaryError)
if (self.sm.seen['vehicleParameters'] and not self.sm['vehicleParameters'].valid and cal_status == log.ExtrinsicsCalibration.Status.calibrated and
if (not self.sm['vehicleParameters'].valid and cal_status == log.ExtrinsicsCalibration.Status.calibrated and
not TESTING_CLOSET and (not SIMULATION or REPLAY)):
self.events.add(EventName.paramsdTemporaryError)
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -e
sudo python3 openpilot/system/hardware/chestnut/flash.py
SCONSFLAGS="-j4" ./openpilot/system/manager/build.py
@@ -152,7 +152,7 @@ def migrate_drivingModelData(msgs):
add_ops = []
for _, msg in msgs:
dmd = messaging.new_message('drivingModelData', valid=msg.valid, logMonoTime=msg.logMonoTime)
for field in ["frameId", "frameIdExtra", "frameDropPerc", "modelExecutionTime", "big", "action"]:
for field in ["frameId", "frameIdExtra", "frameDropPerc", "modelExecutionTime", "action"]:
setattr(dmd.drivingModelData, field, getattr(msg.modelV2, field))
for meta_field in ["laneChangeState", "laneChangeState"]:
setattr(dmd.drivingModelData.meta, meta_field, getattr(msg.modelV2.meta, meta_field))
@@ -20,13 +20,11 @@ from openpilot.tools.lib.framereader import FrameReader
from openpilot.tools.lib.logreader import LogReader, save_log
from openpilot.tools.lib.github_utils import GithubUtils
TEST_ROUTE = "98395b7c5b27882e|0000002b--2686b5a2d0"
SEGMENT = 1
TEST_ROUTE = "8494c69d3c710e81|000001d4--2648a9a404"
SEGMENT = 4
START_FRAME = 0
END_FRAME = 60
CHESTNUT = "--chestnut" in sys.argv
SEND_EXTRA_INPUTS = bool(int(os.getenv("SEND_EXTRA_INPUTS", "0")))
DATA_TOKEN = os.getenv("CI_ARTIFACTS_TOKEN","")
@@ -35,13 +33,13 @@ MODEL_REPLAY_BUCKET="model_replay_master"
GITHUB = GithubUtils(API_TOKEN, DATA_TOKEN)
EXEC_TIMINGS = [
# model, instant max, average max, chestnut average max
("modelV2", 0.05, 0.03, 0.05),
("driverStateV2", 0.05, 0.018, 0.018),
# model, instant max, average max
("modelV2", 0.05, 0.028),
("driverStateV2", 0.05, 0.018),
]
def get_log_fn(test_route, ref="master"):
return f"{test_route}_model_{'chestnut' if CHESTNUT else 'tici'}_{ref}.zst"
return f"{test_route}_model_tici_{ref}.zst"
def plot(proposed, master, title, tmp):
proposed = list(proposed)
@@ -171,15 +169,11 @@ def model_replay(lr, frs):
dmonitoringmodeld_msgs = replay_process(dmonitoringmodeld, dmodeld_logs, frs)
msgs = modeld_msgs + dmonitoringmodeld_msgs
chestnut = any(m.modelV2.big for m in modeld_msgs if m.which() == "modelV2")
if CHESTNUT:
assert chestnut and all(m.modelV2.big for m in modeld_msgs if m.which() == "modelV2"), "Chestnut replay must run the big model without fallback"
header = ['model', 'max instant', 'max instant allowed', 'average', 'max average allowed', 'test result']
rows = []
timings_ok = True
for (s, instant_max, avg_max, chestnut_avg_max) in EXEC_TIMINGS:
avg_max = chestnut_avg_max if chestnut else avg_max
for (s, instant_max, avg_max) in EXEC_TIMINGS:
ts = [getattr(m, s).modelExecutionTime for m in msgs if m.which() == s]
# TODO some init can happen in first iteration
ts = ts[1:]
@@ -289,8 +283,7 @@ if __name__ == "__main__":
diff_short, diff_long, failed = format_diff(results, log_paths, 'master')
if "CI" in os.environ:
if not CHESTNUT:
comment_replay_report(log_msgs, cmp_log, log_msgs)
comment_replay_report(log_msgs, cmp_log, log_msgs)
failed = False
print(diff_long)
print('-------------\n'*5)
@@ -17,7 +17,7 @@ from openpilot.common.hardware.hw import Paths
import openpilot.cereal.messaging as messaging
from opendbc.car.structs import car
from openpilot.cereal.services import SERVICE_LIST
from msgq.visionipc import VisionIpcClient, VisionIpcServer, get_endpoint_name as vipc_get_endpoint_name
from msgq.visionipc import VisionIpcServer, get_endpoint_name as vipc_get_endpoint_name
from opendbc.car.can_definitions import CanData
from opendbc.car.car_helpers import get_car, interfaces
from openpilot.common.params import Params
@@ -210,7 +210,6 @@ class ProcessContainer:
stride, y_height, _, yuv_size = get_nv12_info(frame_size[0], frame_size[1])
vipc_server.create_buffers_with_sizes(meta.stream, 2, frame_size[0], frame_size[1], yuv_size, stride, stride * y_height)
vipc_server.start_listener()
VisionIpcClient.available_streams("camerad", block=True)
self.vipc_server = vipc_server
self.cfg.vision_pubs = [meta.camera_state for meta in streams_metas if meta.camera_state in self.cfg.vision_pubs]
+2 -2
View File
@@ -3,7 +3,7 @@ set -e
SCRIPT_DIR=$(dirname "$0")
BASEDIR=$(realpath "$SCRIPT_DIR/../../../")
cd "$BASEDIR"
cd $BASEDIR
# tests that our build system's dependencies are configured properly,
# needs a machine with lots of cores
@@ -11,7 +11,7 @@ cd "$BASEDIR"
# helpful commands:
# scons -Q --tree=derived
cd "$BASEDIR/opendbc_repo/"
cd $BASEDIR/opendbc_repo/
scons --clean
scons --no-cache --random
if ! scons -q; then
+17 -22
View File
@@ -29,7 +29,7 @@ if [ -d /data/safe_staging/ ]; then
fi
CONTINUE_PATH="/data/continue.sh"
tee "$CONTINUE_PATH" << EOF
tee $CONTINUE_PATH << EOF
#!/usr/bin/env bash
sudo abctl --set_success
@@ -54,18 +54,13 @@ done
sleep infinity
EOF
chmod +x "$CONTINUE_PATH"
chmod +x $CONTINUE_PATH
export GIT_LFS_SKIP_SMUDGE=1
pull_lfs() {
if [ -n "${CHESTNUT:-}" ]
then
git lfs pull --exclude=''
return
fi
# Keep the precompiled big model as a pointer on devices without Chestnut.
LFS_EXCLUDE="openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl"
# The big driving model is not used on these devices yet. Keep its pointer in
# the worktree, but don't download or copy the 1.8 GB LFS object.
LFS_EXCLUDE="openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"
git config --local lfs.fetchexclude "$LFS_EXCLUDE"
git lfs pull --exclude="$LFS_EXCLUDE"
@@ -86,16 +81,16 @@ pull_lfs() {
safe_checkout() {
# completely clean TEST_DIR
cd "$SOURCE_DIR"
cd $SOURCE_DIR
# cleanup orphaned locks
find .git -type f -name "*.lock" -exec rm {} +
git reset --hard
git fetch --no-tags --no-recurse-submodules -j4 --verbose --depth 1 origin "$GIT_COMMIT"
git fetch --no-tags --no-recurse-submodules -j4 --verbose --depth 1 origin $GIT_COMMIT
find . -maxdepth 1 -not -path './.git' -not -name '.' -not -name '..' -exec rm -rf '{}' \;
git reset --hard "$GIT_COMMIT"
git checkout "$GIT_COMMIT"
git reset --hard $GIT_COMMIT
git checkout $GIT_COMMIT
git clean -xdff
git submodule sync
git submodule foreach --recursive "git reset --hard && git clean -xdff"
@@ -105,22 +100,22 @@ safe_checkout() {
pull_lfs
echo "git checkout done, t=$SECONDS"
du -hs "$SOURCE_DIR" "$SOURCE_DIR/.git"
du -hs $SOURCE_DIR $SOURCE_DIR/.git
rsync -a --delete "$SOURCE_DIR" "$TEST_DIR"
rsync -a --delete $SOURCE_DIR $TEST_DIR
}
unsafe_checkout() {( set -e
# checkout directly in test dir, leave old build products
cd "$TEST_DIR"
cd $TEST_DIR
# cleanup orphaned locks
find .git -type f -name "*.lock" -exec rm {} +
git fetch --no-tags --no-recurse-submodules -j8 --verbose --depth 1 origin "$GIT_COMMIT"
git checkout --force --no-recurse-submodules "$GIT_COMMIT"
git reset --hard "$GIT_COMMIT"
git fetch --no-tags --no-recurse-submodules -j8 --verbose --depth 1 origin $GIT_COMMIT
git checkout --force --no-recurse-submodules $GIT_COMMIT
git reset --hard $GIT_COMMIT
git clean -dff
git submodule sync
git submodule foreach --recursive "git reset --hard && git clean -df"
@@ -134,7 +129,7 @@ export GIT_PACK_THREADS=8
# set up environment
if [ ! -d "$SOURCE_DIR" ]; then
git clone https://github.com/commaai/openpilot.git "$SOURCE_DIR"
git clone https://github.com/commaai/openpilot.git $SOURCE_DIR
fi
if [ ! -z "$UNSAFE" ]; then
@@ -151,7 +146,7 @@ else
fi
# submodule package symlinks for PYTHONPATH imports on device (same as launch_chffrplus.sh)
cd "$TEST_DIR"
cd $TEST_DIR
ln -sfn msgq_repo/msgq msgq
ln -sfn opendbc_repo/opendbc opendbc
ln -sfn rednose_repo/rednose rednose
+1 -43
View File
@@ -20,12 +20,8 @@ from openpilot.common.basedir import BASEDIR
from openpilot.common.timeout import Timeout
from openpilot.common.params import Params
from openpilot.selfdrive.selfdrived.events import EVENTS, ET
from openpilot.selfdrive.test.helpers import set_params_enabled, release_only, processes_context, log_collector
from openpilot.common.hardware import HARDWARE
from openpilot.selfdrive.test.helpers import set_params_enabled, release_only
from openpilot.common.hardware.hw import Paths
from openpilot.common.mock import mock_messages
from opendbc.car.car_helpers import get_demo_car_params
from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled
from openpilot.tools.lib.logreader import LogReader
from openpilot.tools.lib.log_time_series import msgs_to_time_series
@@ -461,43 +457,5 @@ class TestOnroad(OpenpilotTestCase):
f"Not engageable for whole segment:\n- selfdriveState.engageable: {Counter(eng)}\n- No entry events: {no_entries}"
@unittest.skipUnless(HARDWARE.get_device_type() == "mici", "requires MICI")
class TestChestnutOnroad(OpenpilotTestCase):
COMMA_HARDWARE_TEST = True
@mock_messages(['deviceMotion'])
def test_camera_models(self, subtests):
assert chestnut_present() and chestnut_compiled()
Params().put("CarParams", get_demo_car_params().to_bytes(), block=True)
services = ['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState', 'modelV2', 'driverStateV2']
sm = messaging.SubMaster(services)
pm = messaging.PubMaster(['deviceState'])
device_state = messaging.new_message('deviceState')
device_state.deviceState.deviceType = HARDWARE.get_device_type()
device_state_bytes = device_state.to_bytes()
with processes_context(['camerad', 'calibrationd', 'modeld', 'dmonitoringmodeld']):
with Timeout(60, "camera models didn't start"):
while not all(sm.seen.values()) or not sm.valid['modelV2']:
pm.send('deviceState', device_state_bytes)
sm.update(1000)
with log_collector(services) as (logs, _):
time.sleep(TEST_DURATION)
msgs = {s: [m for m in logs if m.which() == s] for s in services}
for service, messages in msgs.items():
with subtests.test(service=service):
expected = TEST_DURATION * SERVICE_LIST[service].frequency
assert np.isclose(len(messages), expected, rtol=0.05, atol=2), f"{service}: expected {expected}, got {len(messages)}"
assert all(m.valid for m in messages)
frame_ids = [getattr(m, service).frameId for m in messages]
assert np.all(np.diff(frame_ids) > 0), f"{service}: repeated or reordered frames"
camera_frames = {m.narrowRoadCameraState.frameId for m in msgs['narrowRoadCameraState']}
model_frames = {m.modelV2.frameId for m in msgs['modelV2']}
assert len(camera_frames & model_frames) >= TEST_DURATION * SERVICE_LIST['modelV2'].frequency * 0.9
assert all(m.modelV2.big for m in msgs['modelV2']), "Chestnut fell back to the small model"
assert all(np.isfinite(m.modelV2.position.x).all() for m in msgs['modelV2'])
if __name__ == "__main__":
unittest.main()
+4 -17
View File
@@ -5,8 +5,6 @@ import time
import unittest
import numpy as np
from dataclasses import dataclass
from panda import Panda
from openpilot.common.hardware import HARDWARE
from openpilot.common.test import OpenpilotTestCase
from openpilot.common.utils import tabulate
@@ -16,14 +14,11 @@ from opendbc.car.car_helpers import get_demo_car_params
from openpilot.common.mock import mock_messages
from openpilot.common.params import Params
from openpilot.common.hardware.comma.power_monitor import get_power
from openpilot.selfdrive.modeld.helpers import chestnut_present
from openpilot.system.manager.process_config import managed_processes
from openpilot.system.manager.manager import manager_cleanup
SAMPLE_TIME = 2 # seconds to sample power
MAX_WARMUP_TIME = 30 # seconds to wait for SAMPLE_TIME consecutive valid samples
MICI = HARDWARE.get_device_type() == "mici"
CHESTNUT = chestnut_present()
@dataclass
class Proc:
@@ -38,10 +33,9 @@ class Proc:
return '+'.join(self.procs)
# MICI readings exclude the separately powered Chestnut GPU.
PROCS = [
Proc(['camerad'], 0.85 if MICI else 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']),
Proc(['modeld'], 0.45 if MICI and CHESTNUT else 1.5, atol=0.2, msgs=['modelV2']),
Proc(['camerad'], 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']),
Proc(['modeld'], 1.5, atol=0.2, msgs=['modelV2']),
Proc(['dmonitoringmodeld'], 0.65, atol=0.35, msgs=['driverStateV2']),
Proc(['encoderd'], 0.23, msgs=[]),
]
@@ -52,13 +46,6 @@ class TestPowerDraw(OpenpilotTestCase):
def setup_method(self):
Params().put("CarParams", get_demo_car_params().to_bytes(), block=True)
self.panda = None
if MICI:
HARDWARE.reset_internal_panda()
self.addCleanup(HARDWARE.reset_internal_panda)
Panda.wait_for_panda(None, 30)
self.panda = Panda(cli=False)
self.addCleanup(self.panda.close)
def teardown_method(self):
manager_cleanup()
@@ -91,7 +78,7 @@ class TestPowerDraw(OpenpilotTestCase):
start_time = time.monotonic()
while (time.monotonic() - start_time) < MAX_WARMUP_TIME:
power = get_power(1, self.panda)
power = get_power(1)
iteration_msg_counts = {}
for msg,sock in socks.items():
iteration_msg_counts[msg] = len(messaging.drain_sock_raw(sock))
@@ -110,7 +97,7 @@ class TestPowerDraw(OpenpilotTestCase):
@mock_messages(['deviceMotion'])
def test_camera_procs(self, subtests):
baseline = get_power(panda=self.panda)
baseline = get_power()
prev = baseline
used = {}
@@ -1,7 +1,7 @@
import time
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.selfdrive.ui.ui_state import ui_state
@@ -26,8 +26,8 @@ class BodyLayout(Widget):
self._last_input_time = time.monotonic()
self._was_active = False
self._offroad_label = UnifiedLabel("turn on ignition to use", 95 if gui_app.big_ui() else 45, FontWeight.DISPLAY,
alignment=TextAlignment.CENTER,
alignment_vertical=TextAlignmentVertical.MIDDLE)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
def draw_dot_grid(self, rect: rl.Rectangle, dots: list[tuple[int, int]], color: rl.Color):
spacing = min(rect.height / GRID_ROWS, rect.width / GRID_COLS)
+2 -2
View File
@@ -8,7 +8,7 @@ from openpilot.selfdrive.ui.widgets.exp_mode_button import ExperimentalModeButto
from openpilot.selfdrive.ui.widgets.prime import PrimeWidget
from openpilot.selfdrive.ui.widgets.setup import SetupWidget
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignment
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.lib.multilang import tr, trn
from openpilot.system.ui.widgets.label import gui_label
from openpilot.system.ui.widgets import Widget
@@ -178,7 +178,7 @@ class HomeLayout(Widget):
version_rect = rl.Rectangle(self.header_rect.x + self.header_rect.width - version_text_width, self.header_rect.y,
version_text_width, self.header_rect.height)
gui_label(version_rect, self._version_text, 48, rl.WHITE, alignment=TextAlignment.RIGHT)
gui_label(version_rect, self._version_text, 48, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
def _render_home_content(self):
self._render_left_column()
+4 -4
View File
@@ -5,7 +5,7 @@ from enum import IntEnum
import pyray as rl
from openpilot.common.basedir import BASEDIR
from openpilot.system.ui.lib.application import FontWeight, TextAlignment, gui_app
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
@@ -115,9 +115,9 @@ class TermsPage(Widget):
self._on_accept = on_accept
self._on_decline = on_decline
self._title = Label(tr("Welcome to sunnypilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=TextAlignment.LEFT)
self._title = Label(tr("Welcome to sunnypilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._desc = Label(tr("You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing."),
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT)
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._decline_btn = Button(tr("Decline"), click_callback=on_decline)
self._accept_btn = Button(tr("Agree"), button_style=ButtonStyle.PRIMARY, click_callback=on_accept)
@@ -150,7 +150,7 @@ class DeclinePage(Widget):
def __init__(self, back_callback=None):
super().__init__()
self._text = Label(tr("You must accept the Terms of Service in order to use sunnypilot."),
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT)
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._back_btn = Button(tr("Back"), click_callback=back_callback)
self._uninstall_btn = Button(tr("Decline, uninstall sunnypilot"), button_style=ButtonStyle.DANGER,
click_callback=self._on_uninstall_clicked)
@@ -199,9 +199,6 @@ class SoftwareLayout(Widget):
selection = self._branch_dialog.selection
ui_state.params.put("UpdaterTargetBranch", selection, block=True)
self._branch_btn.action_item.set_value(selection)
self._download_btn.action_item.set_enabled(False)
self._waiting_for_updater = True
self._waiting_start_ts = time.monotonic()
subprocess.run("pkill -SIGUSR1 -f openpilot.system.updated.updated", shell=True)
self._branch_dialog = None
@@ -101,10 +101,6 @@ class PrimeState:
with self._lock:
return bool(self.prime_type > PrimeType.NONE)
def is_full_prime(self) -> bool:
with self._lock:
return self.prime_type > PrimeType.NONE and self.prime_type != PrimeType.LITE
def is_paired(self) -> bool:
with self._lock:
return self.prime_type > PrimeType.UNPAIRED
+37 -44
View File
@@ -1,7 +1,4 @@
from __future__ import annotations
import datetime
import math
import time
from openpilot.cereal import log
@@ -11,7 +8,7 @@ from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.layouts import HBoxLayout
from openpilot.system.ui.widgets.icon_widget import IconWidget
from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignment, TextAlignmentVertical
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.selfdrive.ui.ui_state import ui_state, ChestnutState
from openpilot.common.version import RELEASE_BRANCHES
@@ -41,13 +38,16 @@ class AlertsPill(Widget):
self.set_rect(rl.Rectangle(0, 0, 104, 52))
self._pill_bg_txt = gui_app.texture("icons_mici/alerts_pill.png", 104, 52)
self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", 36, 36)
self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", 36, 36)
self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 36, 36)
self._alert_count_callback: Callable[[], int] | None = None
self._alert_icon_callback: Callable[[], rl.Texture | None] | None = None
self._max_severity_callback: Callable[[], int | None] | None = None
def set_alert_count_callback(self, callback: Callable[[], int] | None,
icon_callback: Callable[[], rl.Texture | None] | None = None):
severity_callback: Callable[[], int | None] | None = None):
self._alert_count_callback = callback
self._alert_icon_callback = icon_callback
self._max_severity_callback = severity_callback
def _render(self, _):
alert_count = self._alert_count_callback() if self._alert_count_callback else 0
@@ -55,37 +55,42 @@ class AlertsPill(Widget):
pill_w, pill_h = self._pill_bg_txt.width, self._pill_bg_txt.height
rl.draw_texture_ex(self._pill_bg_txt, rl.Vector2(self.rect.x, self.rect.y), 0.0, 1.0, rl.WHITE)
warning_txt = self._alert_icon_callback() if self._alert_icon_callback else None
if warning_txt is not None:
scale = 36 / max(warning_txt.width, warning_txt.height)
warn_x = self.rect.x + self.ICON_OFFSET
warn_y = self.rect.y + (pill_h - warning_txt.height * scale) / 2
rl.draw_texture_ex(warning_txt, rl.Vector2(warn_x, warn_y), 0.0, scale, rl.WHITE)
severity = self._max_severity_callback() if self._max_severity_callback else None
if severity == -1:
warning_txt = self._icon_green
elif severity is not None and severity > 0:
warning_txt = self._icon_red
else:
warning_txt = self._icon_orange
warn_x = self.rect.x + self.ICON_OFFSET
warn_y = self.rect.y + (pill_h - warning_txt.height) / 2
rl.draw_texture_ex(warning_txt, rl.Vector2(warn_x, warn_y), 0.0, 1.0, rl.WHITE)
count_rect = rl.Rectangle(self.rect.x + self.COUNT_OFFSET, self.rect.y, pill_w - self.COUNT_OFFSET, pill_h)
gui_label(count_rect, str(alert_count), font_size=36,
alignment=TextAlignment.CENTER,
alignment_vertical=TextAlignmentVertical.MIDDLE)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
class NetworkIcon(Widget):
def __init__(self):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 60, 47)) # max size of all icons
self.set_rect(rl.Rectangle(0, 0, 54, 44)) # max size of all icons
self._net_type = NetworkType.none
self._net_strength = 0
self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 54, 47)
self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 54, 40)
self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 54, 40)
self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 54, 40)
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 54, 40)
self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 50, 44)
self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 50, 37)
self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 50, 37)
self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 50, 37)
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 50, 37)
self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 60, 40)
self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 60, 40)
self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 60, 40)
self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 60, 40)
self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 60, 40)
self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 54, 36)
self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 54, 36)
self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 54, 36)
self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 54, 36)
self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 54, 36)
def _update_state(self):
device_state = ui_state.sm['deviceState']
@@ -134,9 +139,7 @@ class MiciHomeLayout(Widget):
self._version_text = self._get_version_text()
self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48))
self._usb_icon = IconWidget("icons_mici/usb.png", (62, 40))
self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (54, 40))
self._chestnut_loading_icon = IconWidget("icons_mici/chestnut.png", (68, 40))
self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (68, 40))
self._chestnut_failed_icon = IconWidget("icons_mici/chestnut_orange.png", (68, 40))
self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46))
self._body_icon = IconWidget("icons_mici/body.png", (54, 37))
@@ -147,15 +150,13 @@ class MiciHomeLayout(Widget):
IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9),
NetworkIcon(),
self._experimental_icon,
self._usb_icon,
self._chestnut_icon,
self._chestnut_loading_icon,
self._chestnut_failed_icon,
self._body_icon,
self._mic_icon,
], spacing=18)
self._openpilot_label = UnifiedLabel("openpilot", font_size=96, font_weight=FontWeight.DISPLAY, max_width=480, wrap_text=False)
self._openpilot_label = UnifiedLabel("sunnypilot", font_size=96, font_weight=FontWeight.DISPLAY, max_width=480, wrap_text=False)
self._version_label = UnifiedLabel("", font_size=36, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False)
self._large_version_label = UnifiedLabel("", font_size=64, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False)
self._date_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False)
@@ -181,11 +182,11 @@ class MiciHomeLayout(Widget):
def set_callbacks(self, on_settings: Callable | None = None, on_alerts: Callable | None = None,
alert_count_callback: Callable[[], int] | None = None,
alert_icon_callback: Callable[[], rl.Texture | None] | None = None):
max_severity_callback: Callable[[], int | None] | None = None):
self._on_settings_click = on_settings
self._on_alerts_click = on_alerts
self._alert_count_callback = alert_count_callback
self._alerts_pill.set_alert_count_callback(alert_count_callback, alert_icon_callback)
self._alerts_pill.set_alert_count_callback(alert_count_callback, max_severity_callback)
def _handle_mouse_release(self, mouse_pos: MousePos):
if not self._did_long_press:
@@ -246,20 +247,12 @@ class MiciHomeLayout(Widget):
self._version_commit_label.render()
# ***** Center-aligned bottom section icons *****
usb_connected = ui_state.usb_connected
usb_unknown = ui_state.usb_unknown
chestnut_state = ui_state.chestnut_state
self._experimental_icon.set_visible(ui_state.experimental_mode)
if gui_app.sunnypilot_ui():
self._set_chestnut_visibility()
else:
self._usb_icon.set_visible(usb_connected and usb_unknown)
self._chestnut_icon.set_visible(not usb_unknown and chestnut_state not in
(ChestnutState.LOADING, ChestnutState.UNCOMPILED, ChestnutState.FAILED) and
(usb_connected or chestnut_state in (ChestnutState.READY, ChestnutState.ACTIVE)))
self._chestnut_loading_icon.set_visible(not usb_unknown and chestnut_state == ChestnutState.LOADING)
self._chestnut_loading_icon.set_opacity(0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0)))
self._chestnut_failed_icon.set_visible(not usb_unknown and chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED))
self._chestnut_icon.set_visible(ui_state.chestnut_state in (ChestnutState.READY, ChestnutState.LOADING, ChestnutState.ACTIVE))
self._chestnut_failed_icon.set_visible(ui_state.chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED))
self._mic_icon.set_visible(ui_state.recording_audio)
self._body_icon.set_visible(bool(ui_state.is_body))
+1 -1
View File
@@ -77,7 +77,7 @@ class MiciMainLayout(Scroller):
on_settings=lambda: gui_app.push_widget(self._settings_layout),
on_alerts=lambda: self._scroll_to(self._alerts_layout),
alert_count_callback=self._alerts_layout.active_alerts,
alert_icon_callback=self._alerts_layout.highest_severity_icon,
max_severity_callback=self._alerts_layout.max_severity,
)
for layout in (self._car_onroad_layout, self._body_onroad_layout):
layout.set_click_callback(lambda: self._scroll_to(self._home_layout))
@@ -1,5 +1,3 @@
from __future__ import annotations
import pyray as rl
import re
import threading
@@ -13,7 +11,7 @@ from openpilot.common.hardware import HARDWARE
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
REFRESH_INTERVAL = 5.0 # seconds
@@ -31,7 +29,6 @@ class AlertData:
text: str
severity: int
visible: bool = False
icon: str | None = None
class AlertItem(Widget):
@@ -59,28 +56,18 @@ class AlertItem(Widget):
self._bg_big = gui_app.texture("icons_mici/offroad_alerts/big_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG)
self._bg_big_pressed = gui_app.texture("icons_mici/offroad_alerts/big_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG)
# Load alert icons
# Load warning icons
self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", self.ICON_SIZE, self.ICON_SIZE)
self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", self.ICON_SIZE, self.ICON_SIZE)
self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE)
self._custom_icon = gui_app.texture(alert_data.icon, self.ICON_SIZE, self.ICON_SIZE) if alert_data.icon else None
if self._custom_icon is not None:
self._icon = self._custom_icon
elif alert_data.severity == -1:
self._icon = self._icon_green
elif alert_data.severity > 0:
self._icon = self._icon_red
else:
self._icon = self._icon_orange
self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR,
alignment=TextAlignment.LEFT,
alignment_vertical=TextAlignmentVertical.TOP, line_height=0.95)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, line_height=0.95)
self._body_label = UnifiedLabel(text="", font_size=28, font_weight=FontWeight.ROMAN, text_color=self.TEXT_COLOR,
alignment=TextAlignment.LEFT,
alignment_vertical=TextAlignmentVertical.BOTTOM, line_height=0.95)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, line_height=0.95)
self._title_text = ""
self._body_text = ""
@@ -88,10 +75,6 @@ class AlertItem(Widget):
self._update_content()
@property
def icon(self) -> rl.Texture:
return self._icon
def _split_text(self, text: str) -> tuple[str, str]:
"""Split text into title (first sentence) and body (remaining text)."""
# Find the end of the first sentence (period, exclamation, or question mark followed by space or end)
@@ -193,9 +176,16 @@ class AlertItem(Widget):
self._body_label.render(body_rect)
# Draw warning icon on the right side
# Use green icon for update alerts (severity = -1), red for high severity, orange for low severity
if self.alert_data.severity == -1:
icon_texture = self._icon_green
elif self.alert_data.severity > 0:
icon_texture = self._icon_red
else:
icon_texture = self._icon_orange
icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE
icon_y = self._rect.y + self.ALERT_PADDING
rl.draw_texture_ex(self._icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE)
rl.draw_texture_ex(icon_texture, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE)
class MiciOffroadAlerts(Scroller):
@@ -210,8 +200,8 @@ class MiciOffroadAlerts(Scroller):
# Create empty state label
self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE,
alignment=TextAlignment.CENTER,
alignment_vertical=TextAlignmentVertical.MIDDLE)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
# Build initial alert list
self._build_alerts()
@@ -224,10 +214,8 @@ class MiciOffroadAlerts(Scroller):
def active_alerts(self) -> int:
return sum(alert.visible for alert in self.sorted_alerts)
def highest_severity_icon(self) -> rl.Texture | None:
item = max((item for item in self.alert_items if item.alert_data.visible),
key=lambda item: (item.alert_data.severity, bool(item.alert_data.icon)), default=None)
return item.icon if item is not None else None
def max_severity(self) -> int | None:
return max((alert.severity for alert in self.sorted_alerts if alert.visible), default=None)
def scrolling(self):
return self._scroller.scroll_panel.is_touch_valid()
@@ -247,7 +235,7 @@ class MiciOffroadAlerts(Scroller):
# Add regular alerts sorted by severity
for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True):
severity = config.get("severity", 0)
alert_data = AlertData(key=key, text="", severity=severity, icon=config.get("icon"))
alert_data = AlertData(key=key, text="", severity=severity)
self.sorted_alerts.append(alert_data)
# Create alert item widget
@@ -4,7 +4,7 @@ import pyray as rl
from collections.abc import Callable
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.qrcode import make_texture
from openpilot.system.ui.lib.application import FontWeight, gui_app, TextAlignment
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import SmallCircleIconButton
from openpilot.system.ui.widgets.scroller import NavScroller, Scroller
@@ -35,7 +35,7 @@ class DriverCameraSetupDialog(BaseCabinCameraDialog):
if not self._camera_view.frame:
gui_label(rect, tr("camera starting"), font_size=64, font_weight=FontWeight.BOLD,
alignment=TextAlignment.CENTER)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
rl.end_scissor_mode()
return
@@ -23,6 +23,8 @@ class AlphaLongConfirmPage(NavScroller):
GreyBigButton("", "WARNING: alpha longitudinal control may disable Automatic Emergency Braking (AEB)"),
GreyBigButton("", "On this car, openpilot defaults to the stock system's built-in ACC."),
GreyBigButton("", "Enabling this will switch to openpilot longitudinal control."),
GreyBigButton("", "Using Experimental mode is recommended with openpilot longitudinal control alpha."),
GreyBigButton("", "Changing this setting will restart openpilot if the car is powered on."),
accept,
])
@@ -61,31 +63,28 @@ class DeveloperLayoutMici(NavScroller):
txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 56, 64)
github_username = ui_state.params.get("GithubUsername") or ""
self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh,
description="Grant SSH access to all public keys in your GitHub settings. Only enter your own username.")
self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh)
self._ssh_keys_btn.set_click_callback(ssh_keys_callback)
# adb, ssh, ssh keys, debug mode, joystick debug mode, longitudinal maneuver mode, ip address
# ******** Main Scroller ********
self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12),
description="Use Android Debug Bridge (ADB) over USB or the network.", title="enable ADB")
self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12),
description="Access the device remotely using your SSH keys.", title="enable SSH")
self._joystick_toggle = BigToggle("joystick debug\nmode", initial_state=ui_state.params.get_bool("JoystickDebugMode"),
toggle_callback=self._on_joystick_debug_mode, description="Control the car with a joystick for debugging.")
self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"),
toggle_callback=self._on_long_maneuver_mode,
description="Run longitudinal maneuvers for testing gas and brake control.")
self._lat_maneuver_toggle = BigToggle("lateral maneuver mode", initial_state=ui_state.params.get_bool("LateralManeuverMode"),
toggle_callback=self._on_lat_maneuver_mode,
description="Run lateral maneuvers for testing steering control.")
self._alpha_long_toggle = BigToggle("alpha longitudinal", initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"),
toggle_callback=self._on_alpha_long_enabled,
description="Use alpha openpilot longitudinal control instead of stock ACC. This may disable Automatic Emergency " +
"Braking (AEB).")
self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12))
self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12))
self._joystick_toggle = BigToggle("joystick debug mode",
initial_state=ui_state.params.get_bool("JoystickDebugMode"),
toggle_callback=self._on_joystick_debug_mode)
self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode",
initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"),
toggle_callback=self._on_long_maneuver_mode)
self._lat_maneuver_toggle = BigToggle("lateral maneuver mode",
initial_state=ui_state.params.get_bool("LateralManeuverMode"),
toggle_callback=self._on_lat_maneuver_mode)
self._alpha_long_toggle = BigToggle("alpha longitudinal",
initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"),
toggle_callback=self._on_alpha_long_enabled)
self._debug_mode_toggle = BigParamControl("ui debug mode", "ShowDebugInfo",
toggle_callback=lambda checked: (gui_app.set_show_touches(checked), gui_app.set_show_fps(checked)),
description="Show touch locations and the UI frame rate.")
toggle_callback=lambda checked: (gui_app.set_show_touches(checked),
gui_app.set_show_fps(checked)))
self._scroller.add_widgets([
self._adb_toggle,
@@ -1,7 +1,6 @@
import os
import pyray as rl
from collections.abc import Callable
from typing import Union
from openpilot.common.basedir import BASEDIR
from openpilot.common.params import Params
@@ -78,16 +77,15 @@ def _engaged_confirmation_click(callback: Callable, action_text: str, icon: rl.T
class EngagedConfirmationCircleButton(BigCircleButton):
def __init__(self, title: str, icon: rl.Texture, callback: Callable[[], None], exit_on_confirm: bool = True,
red: bool = False, icon_offset: tuple[int, int] = (0, 0), *, description: str = ""):
super().__init__(icon, red, icon_offset, description=description, title=title)
red: bool = False, icon_offset: tuple[int, int] = (0, 0)):
super().__init__(icon, red, icon_offset)
self.set_click_callback(lambda: _engaged_confirmation_click(callback, title, icon, exit_on_confirm=exit_on_confirm, red=red))
class EngagedConfirmationButton(BigButton):
def __init__(self, text: str, action_text: str, icon: rl.Texture, callback: Callable[[], None],
exit_on_confirm: bool = True, red: bool = False, *, description: str = "",
description_icon: Union[rl.Texture, None] = None):
super().__init__(text, "", icon, description=description, description_icon=description_icon)
exit_on_confirm: bool = True, red: bool = False):
super().__init__(text, "", icon)
self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red))
@@ -179,9 +177,7 @@ class DeviceLayoutMici(NavScroller):
params.put_bool("OnroadCycleRequested", True, block=True)
reset_calibration_btn = EngagedConfirmationButton("reset calibration", "reset", gui_app.texture("icons_mici/settings/device/lkas.png", 122, 64),
reset_calibration_callback,
description="Mount the device within 4° left or right and 5° up or 9° down. openpilot calibrates " +
"continuously; resetting is rarely needed. Resetting clears learned calibration.")
reset_calibration_callback)
reboot_btn = EngagedConfirmationCircleButton("reboot", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70),
reboot_callback, exit_on_confirm=False)
@@ -193,8 +189,7 @@ class DeviceLayoutMici(NavScroller):
regulatory_btn = BigButton("regulatory info", "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64))
regulatory_btn.set_click_callback(self._on_regulatory)
cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64),
description="Preview the cabin camera to check driver monitoring visibility. The vehicle must be off.")
cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64))
cabin_cam_btn.set_click_callback(lambda: gui_app.push_widget(CabinCameraDialog()))
cabin_cam_btn.set_enabled(lambda: ui_state.is_offroad())
@@ -1,60 +1,13 @@
import pyray as rl
from openpilot.cereal import log
from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
from openpilot.common.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.cellular_manager import CellularManager
from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid
NetworkStrength = log.DeviceState.NetworkStrength
NetworkType = log.DeviceState.NetworkType
class EsimNetworkButton(BigButton):
def __init__(self, cellular_manager: CellularManager, *, description: str = ""):
self._cellular_manager = cellular_manager
self._cell_icons = {
NetworkStrength.unknown: gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 64, 47),
NetworkStrength.poor: gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 64, 47),
NetworkStrength.moderate: gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 64, 47),
NetworkStrength.good: gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 64, 47),
NetworkStrength.great: gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 64, 47),
}
super().__init__("esim", "loading...", self._cell_icons[NetworkStrength.unknown], scroll=True, description=description)
def _update_state(self):
super()._update_state()
self.set_enabled(self._cellular_manager.is_euicc is not False)
text, value, icon = self._compute_state()
self.set_text(text)
self.set_value(value)
self.set_icon(icon)
def _compute_state(self):
cm = self._cellular_manager
none_icon = self._cell_icons[NetworkStrength.unknown]
ip = cm.modem_state.get("ip_address") or "connecting..."
if cm.is_euicc is False:
iccid = cm.modem_state.get("iccid") or ""
if not iccid:
return "sim", "no sim", none_icon
return f"sim (...{iccid[-4:]})", ip, self._cell_icon()
active = cm.active_profile
if active is None:
return "esim", "loading...", none_icon
return active.display_name, ip, self._cell_icon()
def _cell_icon(self):
# read directly from HARDWARE so it reflects modem state even when wifi is the active connection
strength = HARDWARE.get_network_strength(NetworkType.cell4G)
return self._cell_icons.get(strength, self._cell_icons[NetworkStrength.unknown])
class WifiNetworkButton(BigButton):
def __init__(self, wifi_manager: WifiManager, *, description: str = ""):
def __init__(self, wifi_manager: WifiManager):
self._wifi_manager = wifi_manager
self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 28, 36)
self._draw_lock = False
@@ -64,7 +17,7 @@ class WifiNetworkButton(BigButton):
self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 64, 47)
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 64, 47)
super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True, description=description)
super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True)
def _update_state(self):
super()._update_state()
@@ -1,412 +0,0 @@
import threading
import numpy as np
import pyray as rl
from collections.abc import Callable
from openpilot.cereal import log
from openpilot.cereal.visionipc import VisionStreamType
from openpilot.common import qrcode
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraView
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets.nav_widget import NavWidget
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigConfirmationDialog
from openpilot.common.esim.base import Profile
from openpilot.common.esim.lpa import parse_lpa_activation_code
from openpilot.system.ui.lib.application import DEFAULT_TEXT_COLOR, FontWeight, MousePos, TextAlignment, gui_app
from openpilot.system.ui.lib.cellular_manager import CellularManager
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label
from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller
class ProfileActionButton(Widget):
SIZE = 68
MARGIN = 10
HORIZONTAL_MARGIN = 4
def __init__(self, callback: Callable, delete: bool = False):
super().__init__()
self.set_click_callback(callback)
self._delete = delete
self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 25, 30) if delete else None
self._bg_txt = gui_app.texture("icons_mici/buttons/button_circle.png", self.SIZE, self.SIZE)
self._bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_pressed.png", self.SIZE, self.SIZE)
self.set_rect(rl.Rectangle(0, 0, self.SIZE + self.HORIZONTAL_MARGIN * 2, self.SIZE + self.MARGIN * 2))
def _render(self, _):
bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt
rl.draw_texture_ex(bg_txt, (self._rect.x + (self._rect.width - self._bg_txt.width) / 2,
self._rect.y + (self._rect.height - self._bg_txt.height) / 2), 0, 1.0, rl.WHITE)
color = rl.Color(255, 105, 115, 255) if self._delete else DEFAULT_TEXT_COLOR
if not self.enabled:
color = rl.Color(color.r, color.g, color.b, 90)
if self._trash_txt:
rl.draw_texture_ex(self._trash_txt, (self._rect.x + (self._rect.width - self._trash_txt.width) / 2,
self._rect.y + (self._rect.height - self._trash_txt.height) / 2), 0, 1.0, color)
else:
gui_label(self._rect, "Aa", 30, color=color, alignment=TextAlignment.CENTER)
class QRScannerDialog(NavWidget):
SCAN_INTERVAL_S = 0.25
INVALID_CODE_DURATION_S = 1.0
def __init__(self, on_qr_detected: Callable[[str], None]):
super().__init__()
self._on_qr_detected = on_qr_detected
self._camera_view = CabinCameraView("camerad", VisionStreamType.VISION_STREAM_CABIN)
self._detected = False
self._last_scan_time = 0.0
self._invalid_code_until = 0.0
self._scan_thread: threading.Thread | None = None
self._scan_result: str | None = None
self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
def show_event(self):
super().show_event()
ui_state.params.put_bool("DisableDriverCameraIR", True)
ui_state.params.put_bool("IsDriverViewEnabled", True)
def hide_event(self):
super().hide_event()
ui_state.params.put_bool("IsDriverViewEnabled", False)
ui_state.params.put_bool("DisableDriverCameraIR", False)
def __del__(self):
self._camera_view.close()
def _update_state(self):
super()._update_state()
now = rl.get_time()
if self._detected or not self._camera_view.frame or now < self._invalid_code_until:
return
if self._scan_thread is not None:
if self._scan_thread.is_alive():
return
self._scan_thread = None
data = self._scan_result
if data is not None:
try:
parse_lpa_activation_code(data)
except ValueError:
self._invalid_code_until = now + self.INVALID_CODE_DURATION_S
self._last_scan_time = self._invalid_code_until
else:
self._detected = True
self.dismiss(lambda: self._on_qr_detected(data))
return
if now - self._last_scan_time < self.SCAN_INTERVAL_S:
return
self._last_scan_time = now
frame = self._camera_view.frame
y = np.frombuffer(frame.data, dtype=np.uint8, count=frame.height * frame.stride).reshape(frame.height, frame.stride)
gray = y[:, :frame.width].copy() # the vision buffer is recycled under the scan thread
self._scan_thread = threading.Thread(target=self._scan, args=(gray,), daemon=True)
self._scan_thread.start()
def _scan(self, gray: np.ndarray):
self._scan_result = qrcode.decode(gray)
def _render(self, rect):
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
self._camera_view._render(rect)
if not self._camera_view.frame:
gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD,
alignment=TextAlignment.CENTER)
else:
label_y = rect.y + rect.height * 3 / 4
label_rect = rl.Rectangle(rect.x, label_y + (rect.height - label_y) / 2 - 20, rect.width, 40)
text = "not an LPA code" if rl.get_time() < self._invalid_code_until else "hold QR code to camera"
gui_label(label_rect, text, font_size=32, font_weight=FontWeight.MEDIUM,
alignment=TextAlignment.CENTER,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
rl.end_scissor_mode()
class InstallingProfileDialog(BigDialog):
DOT_STEP = 0.6
def __init__(self):
super().__init__("installing profile", "please wait...")
self._show_time = 0.0
def show_event(self):
super().show_event()
self._nav_bar._alpha = 0.0
self._show_time = rl.get_time()
def _back_enabled(self) -> bool:
return False
def _render(self, _):
t = (rl.get_time() - self._show_time) % (self.DOT_STEP * 2)
dots = "." * min(int(t / (self.DOT_STEP / 4)), 3)
self._card.set_value(f"please wait{dots}")
super()._render(_)
class EsimProfileButton(BigButton):
SUB_LABEL_DISABLED = rl.Color(255, 255, 255, int(255 * 0.585))
CHECK_ICON_COLOR = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
LABEL_PADDING = 98
LABEL_WIDTH = 402 - 98 - 28
SUB_LABEL_WIDTH = 402 - BigButton.LABEL_HORIZONTAL_PADDING * 2
def __init__(self, profile: Profile, cellular_manager: CellularManager, profiles_enabled: Callable[[], bool]):
self._cellular_manager = cellular_manager
self._profiles_enabled = profiles_enabled
super().__init__(profile.display_name, scroll=True)
self._profile = profile
self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 48, 36)
self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 48, 36)
self._check_txt = gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 32, 32)
self._comma_txt = gui_app.texture("icons_mici/settings/comma_icon.png", 36, 36) if profile.is_comma else None
self._delete_btn = ProfileActionButton(self._on_delete, delete=True)
self._rename_btn = ProfileActionButton(self._on_rename) if not profile.is_comma else None
self._delete_btn.set_enabled(lambda: not self._locked and not self._cellular_manager.busy and self._show_delete_btn)
if self._rename_btn:
self._rename_btn.set_enabled(lambda: not self._locked and not self._cellular_manager.busy)
self.set_enabled(lambda: not self._profile.enabled and self._profiles_enabled() and not self._cellular_manager.busy)
self.update_profile(profile)
@property
def profile(self) -> Profile:
return self._profile
def update_profile(self, profile: Profile):
self._profile = profile
active = profile.enabled
self.set_text(profile.display_name)
self.set_value("active" if active else "switch")
def _update_state(self):
super()._update_state()
self._sub_label.set_color(DEFAULT_TEXT_COLOR if self.enabled else self.SUB_LABEL_DISABLED)
self._sub_label.set_font_weight(FontWeight.SEMI_BOLD if self.enabled else FontWeight.ROMAN)
@property
def _locked(self) -> bool:
return not self._profile.is_comma and not self._profiles_enabled()
@property
def _show_delete_btn(self) -> bool:
return not self._profile.enabled and not self._profile.is_comma
def _on_rename(self):
current = self._profile.nickname or ""
dlg = BigInputDialog("nickname", default_text=current, confirm_callback=self._on_nickname_entered,
text_validator=lambda text: bool(text.strip()))
gui_app.push_widget(dlg)
def _on_delete(self):
icon = gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64)
gui_app.push_widget(BigConfirmationDialog("slide to delete", icon, self._delete_profile, red=True))
def _delete_profile(self):
if not self._locked and not self._cellular_manager.busy and self._show_delete_btn:
if ui_state.sm["deviceState"].networkType == log.DeviceState.NetworkType.none:
gui_app.push_widget(BigDialog("", tr("Ensure you're connected to the internet and try again.")))
return
self._cellular_manager.delete_profile(self._profile.iccid)
def _on_nickname_entered(self, nickname: str):
if not self._locked and not self._cellular_manager.busy:
self._cellular_manager.nickname_profile(self._profile.iccid, nickname.strip())
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._show_delete_btn and rl.check_collision_point_rec(mouse_pos, self._delete_btn.rect):
return
if self._rename_btn is not None and rl.check_collision_point_rec(mouse_pos, self._rename_btn.rect):
return
super()._handle_mouse_release(mouse_pos)
def _get_label_font_size(self):
return 48
def _draw_content(self, btn_y: float):
self._label.set_color(self.SUB_LABEL_DISABLED if self._locked else LABEL_COLOR)
label_rect = rl.Rectangle(self._rect.x + self.LABEL_PADDING, btn_y + self.LABEL_VERTICAL_PADDING,
self.LABEL_WIDTH, self._rect.height - self.LABEL_VERTICAL_PADDING * 2)
self._label.render(label_rect)
active = self._profile.enabled
if self.value:
sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING
label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING
action_w = self._rename_btn.rect.width if self._rename_btn is not None else 0
action_w += self._delete_btn.rect.width if self._show_delete_btn else 0
sub_label_w = self.SUB_LABEL_WIDTH - action_w
sub_label_height = self._sub_label.get_content_height(sub_label_w)
if active:
check_y = int(label_y - sub_label_height + (sub_label_height - self._check_txt.height) / 2)
rl.draw_texture_ex(self._check_txt, rl.Vector2(sub_label_x, check_y), 0.0, 1.0, self.CHECK_ICON_COLOR)
sub_label_x += self._check_txt.width + 14
sub_label_rect = rl.Rectangle(sub_label_x, label_y - sub_label_height, sub_label_w, sub_label_height)
self._sub_label.render(sub_label_rect)
if self._comma_txt:
rl.draw_texture_ex(self._comma_txt, (self._rect.x + 36, btn_y + 38), 0.0, 1.0, rl.WHITE)
else:
cell_icon = self._cell_full_txt if active else self._cell_none_txt
rl.draw_texture_ex(cell_icon, (self._rect.x + 30, btn_y + 38), 0.0, 1.0, rl.WHITE)
btn_x = self._rect.x + self._rect.width - (ProfileActionButton.MARGIN - ProfileActionButton.HORIZONTAL_MARGIN)
btn_bottom = btn_y + self._rect.height
if self._show_delete_btn:
btn_x -= self._delete_btn.rect.width
self._delete_btn.render(rl.Rectangle(
btn_x, btn_bottom - self._delete_btn.rect.height,
self._delete_btn.rect.width, self._delete_btn.rect.height,
))
if self._rename_btn is not None:
btn_x -= self._rename_btn.rect.width
self._rename_btn.render(rl.Rectangle(
btn_x, btn_bottom - self._rename_btn.rect.height,
self._rename_btn.rect.width, self._rename_btn.rect.height,
))
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
def action_pressed() -> bool:
return self._delete_btn.is_pressed or (self._rename_btn is not None and self._rename_btn.is_pressed)
super().set_touch_valid_callback(lambda: touch_callback() and not action_pressed())
self._delete_btn.set_touch_valid_callback(touch_callback)
if self._rename_btn:
self._rename_btn.set_touch_valid_callback(touch_callback)
class EsimErrorDialog(NavRawScrollPanel):
def __init__(self, error: str):
super().__init__()
self._title = UnifiedLabel("esim error", font_size=64, font_weight=FontWeight.BOLD)
self._error = UnifiedLabel(error, font_size=36, elide=False)
def _render(self, rect: rl.Rectangle):
width = int(rect.width - 80)
title_height = self._title.get_content_height(width)
error_height = self._error.get_content_height(width)
offset = self._scroll_panel.update(rect, title_height + error_height + 100)
y = rect.y + 40 + offset
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
self._title.render(rl.Rectangle(rect.x + 40, y, width, title_height))
self._error.render(rl.Rectangle(rect.x + 40, y + title_height + 20, width, error_height))
rl.end_scissor_mode()
class EsimUI(NavScroller):
def __init__(self, cellular_manager: CellularManager, profiles_enabled: Callable[[], bool]):
super().__init__()
self._cellular_manager = cellular_manager
self._profiles_enabled = profiles_enabled
self._add_profile_btn = BigButton("add profile", "scan QR code")
self._add_profile_btn.set_click_callback(self._on_add_profile)
self._scroller.add_widget(self._add_profile_btn)
self._installing_dialog: InstallingProfileDialog | None = None
self._cellular_manager.on_profiles_updated = self._on_profiles_updated
self._cellular_manager.on_operation_error = self._on_error
def show_event(self):
super().show_event()
self._update_buttons(re_sort=True)
self._cellular_manager.refresh_profiles()
def _on_profiles_updated(self):
if self._installing_dialog:
existing = {btn.profile.iccid for btn in self._scroller.items if isinstance(btn, EsimProfileButton)}
added = [profile for profile in self._cellular_manager.profiles if profile.iccid not in existing]
# Start the normal tap-to-activate flow once the profile list is visible again.
self._installing_dialog.dismiss(lambda: self._on_profile_clicked(added[0]) if len(added) == 1 else None)
self._installing_dialog = None
self._update_buttons()
def _update_buttons(self, re_sort: bool = False):
existing = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, EsimProfileButton)}
buttons = []
for profile in self._cellular_manager.profiles:
btn = existing.get(profile.iccid)
if btn is None:
btn = EsimProfileButton(profile, self._cellular_manager, self._profiles_enabled)
btn.set_click_callback(lambda btn=btn: self._on_profile_clicked(btn.profile))
self._scroller.add_widget(btn)
else:
btn.update_profile(profile)
buttons.append(btn)
if re_sort:
self._scroller.items[:] = sorted(buttons, key=lambda b: not b.profile.enabled)
else:
self._scroller.items[:] = [btn for btn in self._scroller.items if btn in buttons]
self._scroller.items.append(self._add_profile_btn)
def _move_profile_to_front(self, iccid: str | None, scroll: bool = False):
front_btn_idx = next((i for i, btn in enumerate(self._scroller.items)
if isinstance(btn, EsimProfileButton) and btn.profile.iccid == iccid), None) if iccid else None
if front_btn_idx is not None and front_btn_idx > 0:
self._scroller.move_item(front_btn_idx, 0)
if scroll:
self._scroller.scroll_to(self._scroller.scroll_panel.get_offset(), smooth=True)
def _update_state(self):
super()._update_state()
self._add_profile_btn.set_enabled(not self._cellular_manager.busy and self._profiles_enabled())
active = self._cellular_manager.active_profile
self._move_profile_to_front(active.iccid if active else None)
def _on_add_profile(self):
if self._cellular_manager.busy or not self._profiles_enabled():
return
if ui_state.sm["deviceState"].networkType == log.DeviceState.NetworkType.none:
gui_app.push_widget(BigDialog("", tr("Ensure you're connected to the internet and try again.")))
return
gui_app.push_widget(QRScannerDialog(on_qr_detected=self._on_qr_scanned))
def _on_qr_scanned(self, lpa_code: str):
dlg = BigInputDialog("enter a nickname...", text_validator=lambda text: bool(text.strip()),
confirm_callback=lambda nickname: self._download_profile(lpa_code, nickname))
gui_app.push_widget(dlg)
def _download_profile(self, lpa_code: str, nickname: str):
self._installing_dialog = InstallingProfileDialog()
gui_app.push_widget(self._installing_dialog)
self._cellular_manager.download_profile(lpa_code, nickname.strip())
def _on_error(self, error: str):
cloudlog.error("eSIM error: %s", error)
dlg = EsimErrorDialog(error)
if self._installing_dialog:
self._installing_dialog.dismiss(lambda: gui_app.push_widget(dlg))
self._installing_dialog = None
else:
gui_app.push_widget(dlg)
def _on_profile_clicked(self, profile: Profile):
if self._cellular_manager.busy or not self._profiles_enabled():
return
self._cellular_manager.switch_profile(profile.iccid)
self._move_profile_to_front(profile.iccid, scroll=True)
@@ -1,13 +1,12 @@
from openpilot.selfdrive.ui.mici.layouts.settings.network import EsimNetworkButton, WifiNetworkButton
from openpilot.selfdrive.ui.mici.layouts.settings.network.esim_ui import EsimUI
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiNetworkButton
from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigParamControl, BigToggle
from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.lib.prime_state import PrimeType
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.cellular_manager import CellularManager
from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType
from openpilot.system.ui.widgets.scroller import NavScroller
class NetworkLayoutMici(NavScroller):
@@ -29,8 +28,7 @@ class NetworkLayoutMici(NavScroller):
self._network_metered_btn.set_enabled(False)
self._wifi_manager.set_tethering_active(checked)
self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback,
description="Share the devices internet connection through a Wi-Fi hotspot.")
self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback)
def tethering_password_callback(password: str):
if password:
@@ -60,39 +58,26 @@ class NetworkLayoutMici(NavScroller):
# TODO: signal for current network metered type when changing networks, this is wrong until you press it once
# TODO: disable when not connected
self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback,
description="Metered prevents large uploads on this Wi-Fi connection. Default uses the networks detected " +
"setting.")
self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback)
self._network_metered_btn.set_enabled(False)
self._wifi_button = WifiNetworkButton(self._wifi_manager)
self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui))
# ******** eSIM ********
self._cellular_manager = CellularManager()
self._esim_ui = EsimUI(
self._cellular_manager,
lambda: not ui_state.prime_state.is_full_prime(),
)
self._esim_button = EsimNetworkButton(self._cellular_manager)
self._esim_button.set_click_callback(lambda: gui_app.push_widget(self._esim_ui))
# ******** Advanced settings ********
# ******** Roaming toggle ********
self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming", description="Allow cellular data roaming.")
self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming")
# ******** APN settings ********
self._apn_btn = BigButton("apn settings", "edit",
description="Set the access point name required by your cellular carrier. Leave blank for automatic configuration.")
self._apn_btn = BigButton("apn settings", "edit")
self._apn_btn.set_click_callback(self._edit_apn)
# ******** Cellular metered toggle ********
self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered", description="Prevent large uploads over the cellular connection.")
self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered")
# Main scroller ----------------------------------
self._scroller.add_widgets([
self._wifi_button,
self._esim_button,
self._network_metered_btn,
self._tethering_toggle_btn,
self._tethering_password_btn,
@@ -106,7 +91,8 @@ class NetworkLayoutMici(NavScroller):
def _update_state(self):
super()._update_state()
show_cell_settings = not ui_state.prime_state.is_full_prime()
# If not using prime SIM, show GSM settings and enable IPv4 forwarding
show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE)
self._wifi_manager.set_ipv4_forward(show_cell_settings)
self._roaming_btn.set_visible(show_cell_settings)
self._apn_btn.set_visible(show_cell_settings)
@@ -116,16 +102,14 @@ class NetworkLayoutMici(NavScroller):
super().show_event()
self._wifi_manager.set_active(True)
# Process wifi and esim callbacks while at any point in the nav stack
# Process wifi callbacks while at any point in the nav stack
gui_app.add_nav_stack_tick(self._wifi_manager.process_callbacks)
gui_app.add_nav_stack_tick(self._cellular_manager.process_callbacks)
def hide_event(self):
super().hide_event()
self._wifi_manager.set_active(False)
gui_app.remove_nav_stack_tick(self._wifi_manager.process_callbacks)
gui_app.remove_nav_stack_tick(self._cellular_manager.process_callbacks)
def _edit_apn(self):
def update_apn(apn: str):
@@ -3,7 +3,7 @@ from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici
from openpilot.selfdrive.ui.mici.layouts.settings.network.network_layout import NetworkLayoutMici
from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici
from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton
from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici
from openpilot.selfdrive.ui.mici.layouts.settings.software import SoftwareLayoutMici
from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout
@@ -49,6 +49,7 @@ class SettingsLayout(NavScroller):
network_btn,
device_btn,
software_btn,
PairBigButton(),
firehose_btn,
developer_btn,
])
@@ -74,10 +74,6 @@ class SoftwareInfoLayoutMici(Widget):
class CheckUpdateButton(BigButton):
UPDATER_PROC = "openpilot.system.updated.updated"
CHECK_FOR_UPDATE = "SIGUSR1"
DOWNLOAD_UPDATE = "SIGHUP"
def __init__(self):
self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 75)
self._txt_up_to_date_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64)
@@ -101,20 +97,15 @@ class CheckUpdateButton(BigButton):
gui_app.push_widget(dlg)
return
self._signal_updater(self.DOWNLOAD_UPDATE if self.get_value() == "download update" else self.CHECK_FOR_UPDATE)
def check_for_update(self):
self._signal_updater(self.CHECK_FOR_UPDATE)
def _signal_updater(self, sig: str):
self.set_enabled(False)
self._state = UpdaterState.WAITING_FOR_UPDATER
self._hide_value_t = None
self.set_value("")
self.set_icon(self._txt_update_icon)
def run():
subprocess.run(f"pkill -{sig} -f {self.UPDATER_PROC}", shell=True)
if self.get_value() == "download update":
subprocess.run("pkill -SIGHUP -f openpilot.system.updated.updated", shell=True)
else:
subprocess.run("pkill -SIGUSR1 -f openpilot.system.updated.updated", shell=True)
threading.Thread(target=run, daemon=True).start()
@@ -193,7 +184,7 @@ class CheckUpdateButton(BigButton):
class InstallUpdateButton(BigButton):
def __init__(self):
super().__init__("install now", "", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70))
super().__init__("install update", "", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70))
self.set_visible(lambda: ui_state.is_offroad() and ui_state.params.get_bool("UpdateAvailable"))
def _update_state(self):
@@ -241,10 +232,8 @@ class BranchSelectPage(NavScroller):
class TargetBranchButton(BigButton):
def __init__(self, check_update_btn: CheckUpdateButton):
super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "",
description="Select the software branch to download on the next update check.")
self._check_update_btn = check_update_btn
def __init__(self):
super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "")
self.set_click_callback(self._on_click)
self.set_visible(not ui_state.params.get_bool("IsTestedBranch"))
self.set_enabled(lambda: ui_state.is_offroad())
@@ -257,15 +246,12 @@ class TargetBranchButton(BigButton):
self.set_value(target)
def _on_click(self):
if not ui_state.params.get("UpdaterAvailableBranches"):
gui_app.push_widget(BigDialog("", tr("Failed to get available branches. Ensure you're connected to the internet and try again.")))
return
gui_app.push_widget(BranchSelectPage(self._on_select))
def _on_select(self, branch: str):
ui_state.params.put("UpdaterTargetBranch", branch, block=True)
self.set_value(branch)
self._check_update_btn.check_for_update()
subprocess.run("pkill -SIGUSR1 -f openpilot.system.updated.updated", shell=True)
class SoftwareLayoutMici(NavScroller):
@@ -277,15 +263,12 @@ class SoftwareLayoutMici(NavScroller):
uninstall_openpilot_btn = EngagedConfirmationButton("uninstall sunnypilot", "uninstall",
gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64),
uninstall_openpilot_callback, exit_on_confirm=False,
description="Remove openpilot from this device.",
description_icon=gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64))
uninstall_openpilot_callback, exit_on_confirm=False)
check_update_btn = CheckUpdateButton()
self._scroller.add_widgets([
SoftwareInfoLayoutMici(),
check_update_btn,
CheckUpdateButton(),
InstallUpdateButton(),
TargetBranchButton(check_update_btn),
TargetBranchButton(),
uninstall_openpilot_btn,
])
@@ -41,34 +41,15 @@ class TogglesLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"],
description="Standard is recommended.\n" +
"Aggressive follows closer, with firmer gas and braking.\n" +
"Relaxed leaves more space.\n" +
"Use the steering wheel distance button on supported cars.")
self._experimental_btn = BigToggle("experimental mode", description_icon=gui_app.texture("icons_mici/experimental_mode.png", 64, 64),
initial_state=ui_state.params.get_bool("ExperimentalMode"), toggle_callback=self._on_experimental_mode,
description="Let the driving model control gas and brakes.\n" +
"Includes stopping for red lights and stop signs.\n" +
"Set speed is a maximum, not a target.\n" +
"These are alpha features. Expect mistakes.\n" +
"The path colors show acceleration and braking.")
self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"])
self._experimental_btn = BigToggle("experimental mode", initial_state=ui_state.params.get_bool("ExperimentalMode"),
toggle_callback=self._on_experimental_mode)
is_metric_toggle = BigParamControl("use metric units", "IsMetric")
ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled",
description="Warn when you drift across a detected lane line.\n" +
"Only above 31 mph (50 km/h), with no turn signal.")
always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM", description="Monitor the driver even when sunnypilot is not engaged.")
record_front = BigParamControl("record & upload cabin camera", "RecordFront",
description_icon=gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64),
toggle_callback=restart_needed_callback, description="Upload cabin camera data to help improve driver monitoring.")
record_mic = BigParamControl("record & upload mic audio", "RecordAudio", description_icon=gui_app.texture("icons_mici/microphone.png", 64, 64),
toggle_callback=restart_needed_callback,
description="Record microphone audio while driving.\n" +
"Audio is included in dashcam videos in sunnylink.")
enable_openpilot = BigParamControl("enable sunnypilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback,
description="Enable to use sunnypilot driver assistance.\n" +
"Disable to use your car's stock driver assistance.")
ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled")
always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM")
record_front = BigParamControl("record & upload cabin camera", "RecordFront", toggle_callback=restart_needed_callback)
record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback)
enable_openpilot = BigParamControl("enable sunnypilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback)
self._scroller.add_widgets([
self._personality_toggle,
@@ -10,7 +10,7 @@ from opendbc.car.structs import car
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
from openpilot.common.hardware import COMMA_HARDWARE
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
@@ -333,7 +333,7 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer):
self._alert_text1_label.set_text(alert_text1)
self._alert_text1_label.set_text_color(color)
self._alert_text1_label.set_font_size(font_size)
self._alert_text1_label.set_alignment(TextAlignment.LEFT if icon_side != 'left' else TextAlignment.RIGHT)
self._alert_text1_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
self._alert_text1_label.render(text_rect1)
alert_text2 = alert.text2.lower()
@@ -365,5 +365,5 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer):
self._alert_text2_label.set_text(alert_text2)
self._alert_text2_label.set_text_color(color)
self._alert_text2_label.set_font_size(small_font_size)
self._alert_text2_label.set_alignment(TextAlignment.LEFT if icon_side != 'left' else TextAlignment.RIGHT)
self._alert_text2_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
self._alert_text2_label.render(text_rect2)
@@ -11,7 +11,7 @@ from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer
from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer
from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall
from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent, TextAlignment, TextAlignmentVertical
from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets import Widget
from openpilot.common.filter_simple import BounceFilter
@@ -158,8 +158,8 @@ class AugmentedRoadView(CameraView):
self._confidence_ball = ConfidenceBall()
self._offroad_label = UnifiedLabel("start the car to\nuse sunnypilot", 54, FontWeight.DISPLAY,
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
alignment=TextAlignment.CENTER,
alignment_vertical=TextAlignmentVertical.MIDDLE)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
self._fade_texture = gui_app.texture("icons_mici/onroad/onroad_fade.png")
@@ -4,7 +4,7 @@ from openpilot.cereal.visionipc import VisionStreamType
from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
from openpilot.selfdrive.ui.ui_state import ui_state, device
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.nav_widget import NavWidget
@@ -76,7 +76,7 @@ class BaseCabinCameraDialog(Widget):
if not self._camera_view.frame:
gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD,
alignment=TextAlignment.CENTER)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
rl.end_scissor_mode()
self._publish_alert_sound(None)
return
@@ -124,12 +124,12 @@ class BaseCabinCameraDialog(Widget):
awareness_pct = dm_state.visionPolicyState.awarenessPercent if is_vision else dm_state.wheeltouchPolicyState.awarenessPercent
gui_label(rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height),
f"Awareness: {awareness_pct:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM,
alignment=TextAlignment.RIGHT,
alignment_vertical=TextAlignmentVertical.TOP,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
color=rl.Color(0, 0, 0, 180))
gui_label(rect, f"Awareness: {awareness_pct:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM,
alignment=TextAlignment.RIGHT,
alignment_vertical=TextAlignmentVertical.TOP,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
if dm_state.alertLevel == log.DriverMonitoringState.AlertLevel.none:
@@ -137,16 +137,16 @@ class BaseCabinCameraDialog(Widget):
# Show alert level
alert_level_str = f"{'Pay Attention' if is_vision else 'Touch Wheel'} - level {dm_state.alertLevel}"
alignment = TextAlignment.RIGHT if self.driver_state_renderer.is_rhd else TextAlignment.LEFT
alignment = rl.GuiTextAlignment.TEXT_ALIGN_RIGHT if self.driver_state_renderer.is_rhd else rl.GuiTextAlignment.TEXT_ALIGN_LEFT
shadow_rect = rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height)
gui_label(shadow_rect, alert_level_str, font_size=40, font_weight=FontWeight.BOLD,
alignment=alignment,
alignment_vertical=TextAlignmentVertical.BOTTOM,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
color=rl.Color(0, 0, 0, 180))
gui_label(rect, alert_level_str, font_size=40, font_weight=FontWeight.BOLD,
alignment=alignment,
alignment_vertical=TextAlignmentVertical.BOTTOM,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
def _load_eye_textures(self):
+44 -62
View File
@@ -6,7 +6,7 @@ from collections.abc import Callable
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.scroller import DO_ZOOM
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignmentVertical
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.common.filter_simple import BounceFilter
if TYPE_CHECKING:
@@ -29,41 +29,9 @@ class ScrollState(Enum):
POST_SCROLL = 2
class BaseButton(Widget):
def __init__(self, description: str, title: str, icon: Union[rl.Texture, None] = None):
class BigCircleButton(Widget):
def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0)):
super().__init__()
self._shake_start: float | None = None
if description:
# Dialogs also use buttons; import lazily to avoid a circular import.
from openpilot.selfdrive.ui.mici.widgets.dialog import SettingDescriptionDialog
self.set_long_press_callback(lambda: gui_app.push_widget(SettingDescriptionDialog(title, description, icon)))
else:
self.set_long_press_callback(self.trigger_shake)
def trigger_shake(self):
self._shake_start = rl.get_time()
@property
def _shake_offset(self) -> float:
SHAKE_DURATION = 0.5
SHAKE_AMPLITUDE = 24.0
SHAKE_FREQUENCY = 32.0
if self._shake_start is None:
return 0.0
t = rl.get_time() - self._shake_start
if t > SHAKE_DURATION:
return 0.0
decay = 1.0 - t / SHAKE_DURATION
return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY)
def set_position(self, x: float, y: float) -> None:
super().set_position(x + self._shake_offset, y)
class BigCircleButton(BaseButton):
def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0),
*, description: str = "",
description_icon: Union[rl.Texture, None] = None, title: str = ""):
super().__init__(description, title, description_icon or icon)
self._red = red
self._icon_offset = icon_offset
@@ -105,9 +73,8 @@ class BigCircleButton(BaseButton):
class BigCircleToggle(BigCircleButton):
def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0),
*, description: str = "", description_icon: Union[rl.Texture, None] = None, title: str = ""):
super().__init__(icon, False, icon_offset=icon_offset, description=description, description_icon=description_icon, title=title)
def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)):
super().__init__(icon, False, icon_offset=icon_offset)
self._toggle_callback = toggle_callback
# State
@@ -136,16 +103,14 @@ class BigCircleToggle(BigCircleButton):
0, 1.0, rl.WHITE)
class BigButton(BaseButton):
class BigButton(Widget):
LABEL_HORIZONTAL_PADDING = 40
LABEL_VERTICAL_PADDING = 23 # visually matches 30 in figma
"""A lightweight stand-in for the Qt BigButton, drawn & updated each frame."""
def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False,
*, description: str = "",
description_icon: Union[rl.Texture, None] = None):
super().__init__(description, text, description_icon or icon or None)
def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 402, 180))
self.text = text
self.value = value
@@ -154,15 +119,16 @@ class BigButton(BaseButton):
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._click_delay = 0.075
self._shake_start: float | None = None
self._grow_animation_until: float | None = None
self._rotate_icon_t: float | None = None
self._label = UnifiedLabel(text, font_size=self._get_label_font_size(), font_weight=FontWeight.BOLD,
text_color=LABEL_COLOR, alignment_vertical=TextAlignmentVertical.BOTTOM, scroll=scroll,
text_color=LABEL_COLOR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, scroll=scroll,
line_height=0.9)
self._sub_label = UnifiedLabel(value, font_size=COMPLICATION_SIZE, font_weight=FontWeight.ROMAN,
text_color=COMPLICATION_GREY, alignment_vertical=TextAlignmentVertical.BOTTOM)
text_color=COMPLICATION_GREY, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
self._update_label_layout()
self._load_images()
@@ -201,9 +167,9 @@ class BigButton(BaseButton):
def _update_label_layout(self):
self._label.set_font_size(self._get_label_font_size())
if self.value:
self._label.set_alignment_vertical(TextAlignmentVertical.TOP)
self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
else:
self._label.set_alignment_vertical(TextAlignmentVertical.BOTTOM)
self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
def set_text(self, text: str):
self.text = text
@@ -221,9 +187,28 @@ class BigButton(BaseButton):
def get_text(self):
return self.text
def trigger_shake(self):
self._shake_start = rl.get_time()
def trigger_grow_animation(self, duration: float = 0.65):
self._grow_animation_until = rl.get_time() + duration
@property
def _shake_offset(self) -> float:
SHAKE_DURATION = 0.5
SHAKE_AMPLITUDE = 24.0
SHAKE_FREQUENCY = 32.0
if self._shake_start is None:
return 0.0
t = rl.get_time() - self._shake_start
if t > SHAKE_DURATION:
return 0.0
decay = 1.0 - t / SHAKE_DURATION
return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY)
def set_position(self, x: float, y: float) -> None:
super().set_position(x + self._shake_offset, y)
def _handle_background(self) -> tuple[rl.Texture, float, float, float]:
if self._grow_animation_until is not None:
if rl.get_time() >= self._grow_animation_until:
@@ -287,10 +272,8 @@ class BigButton(BaseButton):
class BigToggle(BigButton):
def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None,
*, description: str = "",
description_icon: Union[rl.Texture, None] = None):
super().__init__(text, value, "", description=description, description_icon=description_icon)
def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None):
super().__init__(text, value, "")
self._checked = initial_state
self._toggle_callback = toggle_callback
@@ -325,8 +308,8 @@ class BigToggle(BigButton):
class BigMultiToggle(BigToggle):
def __init__(self, text: str, options: list[str], toggle_callback: Callable | None = None,
select_callback: Callable | None = None, *, description: str = "", description_icon: Union[rl.Texture, None] = None):
super().__init__(text, "", toggle_callback=toggle_callback, description=description, description_icon=description_icon)
select_callback: Callable | None = None):
super().__init__(text, "", toggle_callback=toggle_callback)
assert len(options) > 0
self._options = options
self._select_callback = select_callback
@@ -373,8 +356,8 @@ class GreyBigButton(BigButton):
self._sub_label.set_font_size(36)
self._sub_label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9)))
self._sub_label.set_font_weight(FontWeight.DISPLAY_REGULAR)
self._sub_label.set_alignment_vertical(TextAlignmentVertical.MIDDLE if not self._label.text else
TextAlignmentVertical.BOTTOM)
self._sub_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE if not self._label.text else
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
self._sub_label.set_line_height(0.95)
@property
@@ -391,9 +374,9 @@ class GreyBigButton(BigButton):
class BigMultiParamToggle(BigMultiToggle):
def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None,
select_callback: Callable | None = None, *, description: str = "", description_icon: Union[rl.Texture, None] = None):
select_callback: Callable | None = None):
assert Params is not None
super().__init__(text, options, toggle_callback, select_callback, description=description, description_icon=description_icon)
super().__init__(text, options, toggle_callback, select_callback)
self._param = param
self._params = Params()
@@ -409,10 +392,9 @@ class BigMultiParamToggle(BigMultiToggle):
class BigParamControl(BigToggle):
def __init__(self, text: str, param: str, toggle_callback: Callable | None = None, *, description: str = "",
description_icon: Union[rl.Texture, None] = None):
def __init__(self, text: str, param: str, toggle_callback: Callable | None = None):
assert Params is not None
super().__init__(text, "", toggle_callback=toggle_callback, description=description, description_icon=description_icon)
super().__init__(text, "", toggle_callback=toggle_callback)
self.param = param
self.params = Params()
self.set_checked(self.params.get_bool(self.param, False))
@@ -428,9 +410,9 @@ class BigParamControl(BigToggle):
# TODO: param control base class
class BigCircleParamControl(BigCircleToggle):
def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None,
icon_offset: tuple[int, int] = (0, 0), *, description: str = "", description_icon: Union[rl.Texture, None] = None, title: str = ""):
icon_offset: tuple[int, int] = (0, 0)):
assert Params is not None
super().__init__(icon, toggle_callback, icon_offset=icon_offset, description=description, description_icon=description_icon, title=title)
super().__init__(icon, toggle_callback, icon_offset=icon_offset)
self._param = param
self.params = Params()
self.set_checked(self.params.get_bool(self._param, False))
+4 -33
View File
@@ -1,11 +1,9 @@
import abc
import math
import re
import pyray as rl
from typing import Union
from collections.abc import Callable
from openpilot.system.ui.widgets.nav_widget import NavWidget
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.mici_keyboard import MiciKeyboard
from openpilot.system.ui.lib.text_measure import measure_text_cached
@@ -39,31 +37,6 @@ class BigDialog(BigDialogBase):
))
class SettingDescriptionDialog(NavScroller):
def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None):
super().__init__()
cards = [GreyBigButton(title, "scroll for details", icon or gui_app.texture("icons_mici/setup/green_info.png", 64, 64))]
# Explicit lines are authored cards; otherwise prefer sentence boundaries.
paragraphs = description.splitlines() if "\n" in description else re.split(r"(?<=[.!?])\s+", description.strip())
# Measure each card so longer text still fits with the actual font and padding.
for sentence in paragraphs:
card = GreyBigButton("", "")
words: list[str] = []
for word in sentence.split():
card.set_value(" ".join([*words, word]))
height = card._sub_label.get_content_height(card._subtitle_width_hint())
if words and height > card.rect.height - 2 * card.LABEL_VERTICAL_PADDING:
card.set_value(" ".join(words))
cards.append(card)
card = GreyBigButton("", "")
words = []
words.append(word)
if words:
card.set_value(" ".join(words))
cards.append(card)
self._scroller.add_widgets(cards)
class BigConfirmationDialog(BigDialogBase):
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None],
exit_on_confirm: bool = True, red: bool = False):
@@ -103,15 +76,14 @@ class BigInputDialog(BigDialogBase):
default_text: str = "",
minimum_length: int = 1,
confirm_callback: Callable[[str], None] | None = None,
auto_return_to_letters: str = "",
text_validator: Callable[[str], bool] | None = None):
auto_return_to_letters: str = ""):
super().__init__()
self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)),
font_weight=FontWeight.MEDIUM)
self._keyboard = MiciKeyboard(auto_return_to_letters=auto_return_to_letters)
self._keyboard.set_text(default_text)
self._keyboard.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget
self._text_valid = lambda text: len(text) >= minimum_length and (text_validator is None or text_validator(text))
self._minimum_length = minimum_length
self._backspace_held_time: float | None = None
@@ -128,8 +100,7 @@ class BigInputDialog(BigDialogBase):
def confirm_callback_wrapper():
text = self._keyboard.text()
if self._text_valid(text):
self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None)
self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None)
self._confirm_callback = confirm_callback_wrapper
def _update_state(self):
@@ -214,7 +185,7 @@ class BigInputDialog(BigDialogBase):
self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height)
# draw enter button
self._enter_img_alpha.update(255 if self._text_valid(text) else 0)
self._enter_img_alpha.update(255 if len(text) >= self._minimum_length else 0)
color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x))
rl.draw_texture_ex(self._enter_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y), 0.0, 1.0, color)
color = rl.Color(255, 255, 255, 255 - int(self._enter_img_alpha.x))
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from openpilot.cereal import messaging, log
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.common.hardware import COMMA_HARDWARE
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
@@ -76,10 +76,10 @@ class AlertRenderer(Widget):
self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
# font size is set dynamically
self._full_text1_label = Label("", font_size=0, font_weight=FontWeight.BOLD, text_alignment=TextAlignment.CENTER,
text_alignment_vertical=TextAlignmentVertical.TOP)
self._full_text2_label = Label("", font_size=ALERT_FONT_BIG, text_alignment=TextAlignment.CENTER,
text_alignment_vertical=TextAlignmentVertical.TOP)
self._full_text1_label = Label("", font_size=0, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
self._full_text2_label = Label("", font_size=ALERT_FONT_BIG, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
def get_alert(self, sm: messaging.SubMaster) -> Alert | None:
"""Generate the current alert based on selfdrive state."""
@@ -4,7 +4,7 @@ from openpilot.cereal.visionipc import VisionStreamType
from openpilot.selfdrive.ui.onroad.cameraview import CameraView
from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer
from openpilot.selfdrive.ui.ui_state import ui_state, device
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets.label import gui_label
@@ -38,7 +38,7 @@ class CabinCameraDialog(CameraView):
tr("camera starting"),
font_size=100,
font_weight=FontWeight.BOLD,
alignment=TextAlignment.CENTER,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
)
return -1

Some files were not shown because too many files have changed in this diff Show More