Compare commits

..

33 Commits

Author SHA1 Message Date
royjr 06687fdd42 Merge branch 'master' into elantra-2024-port 2026-08-22 13:20:15 -04:00
royjr 1dc9835357 Merge branch 'master' into elantra-2024-port 2026-08-15 01:51:15 -04:00
royjr eb3508b2ad Update opendbc_repo 2026-08-15 01:51:11 -04:00
royjr c37462ff1b Merge branch 'master' into elantra-2024-port 2026-08-10 19:54:49 -04:00
royjr 405c7925fb Update opendbc_repo 2026-08-10 19:53:18 -04:00
royjr 2a69601b4e Merge branch 'master' into elantra-2024-port 2026-06-13 22:13:06 -04:00
royjr 39892c944e Update opendbc_repo 2026-06-13 22:12:46 -04:00
royjr 472b619a86 Merge branch 'master' into elantra-2024-port 2026-04-23 01:25:53 -04:00
royjr 91df73dd69 Update opendbc_repo 2026-04-23 01:25:45 -04:00
royjr 6f69d5640d Merge branch 'master' into elantra-2024-port 2026-03-11 23:29:46 -04:00
royjr 4df26493ee Update opendbc_repo 2026-03-11 23:29:37 -04:00
royjr 6b785ffb61 Merge branch 'master' into elantra-2024-port 2026-02-16 11:34:02 -05:00
royjr c2be6fb124 Update opendbc_repo 2026-02-16 11:33:49 -05:00
royjr 3ee30559b2 Merge branch 'master' into elantra-2024-port 2026-02-02 00:48:12 -05:00
royjr cb18829902 Update opendbc_repo 2026-01-24 13:01:44 -05:00
royjr 8a72573b3d Merge branch 'master' into elantra-2024-port 2026-01-24 12:36:32 -05:00
royjr 21405d6760 Update opendbc_repo 2026-01-24 12:36:24 -05:00
royjr 769edd9816 Update opendbc_repo 2026-01-15 19:57:04 -05:00
royjr 0dba93f586 Merge branch 'master' into elantra-2024-port 2026-01-15 19:56:45 -05:00
royjr 10716f6454 Merge branch 'master' into elantra-2024-port 2025-10-30 11:19:26 -04:00
royjr c87445980e Update opendbc_repo 2025-10-30 11:13:54 -04:00
royjr 9fee6dc3a1 Merge branch 'master' into elantra-2024-port 2025-10-18 07:32:44 -04:00
royjr 3b4b4b99e2 Merge branch 'master' into elantra-2024-port 2025-10-14 21:56:59 -04:00
royjr 7dee148821 Update opendbc_repo 2025-10-14 21:56:42 -04:00
royjr 980613a004 Merge branch 'master' into elantra-2024-port 2025-10-09 22:34:32 -04:00
royjr ad91c9c75e Update opendbc_repo 2025-10-09 22:34:23 -04:00
royjr 154ee95e99 Merge branch 'master' into elantra-2024-port 2025-10-02 09:22:06 -04:00
royjr a22aed37f5 Update opendbc_repo 2025-10-02 09:21:59 -04:00
royjr 56766273f0 Merge branch 'master' into elantra-2024-port 2025-09-30 14:42:18 -04:00
royjr 679a7ad82a Update opendbc_repo 2025-09-30 14:42:12 -04:00
royjr d546609306 Merge branch 'master' into elantra-2024-port 2025-08-22 12:09:05 -04:00
royjr 9eeb027a7d Update opendbc_repo 2025-08-22 12:08:56 -04:00
royjr b37b3fd657 Update opendbc_repo 2025-05-23 22:27:15 -04:00
402 changed files with 12456 additions and 20997 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
@@ -8,23 +8,18 @@ on:
required: true
type: string
target_hardware:
description: 'Hardware target to compile for (qcom or chestnut)'
description: 'Hardware target to compile for (qcom or usbgpu)'
required: true
type: choice
default: 'qcom'
options:
- qcom
- chestnut
- usbgpu
hf_repo:
description: 'Hugging Face dataset repository'
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,9 +34,9 @@ jobs:
- name: Checkout sunnypilot repo
uses: actions/checkout@v4
with:
repository: sunnypilot/sunnypilot
path: sunnypilot
submodules: recursive
fetch-depth: 1
- name: Get tinygrad_repo ref
id: get-tinygrad-ref
@@ -52,20 +47,19 @@ 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 }}
fetch-depth: 1
- name: Get next JSON version to use (from GitHub docs repo)
id: get-json
run: |
cd docs/docs
PREFIX="driving_models_${{ inputs.target_hardware == 'chestnut' && 'chestnut_' || '' }}v"
PREFIX="driving_models_${{ inputs.target_hardware == 'usbgpu' && 'usbgpu_' || '' }}v"
latest=$(ls ${PREFIX}*.json | sed -E "s/${PREFIX}([0-9]+)\.json/\1/" | sort -n | tail -1)
next=$((latest+1))
json_file="${PREFIX}${next}.json"
@@ -84,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
@@ -124,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
@@ -169,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
@@ -0,0 +1,83 @@
name: Build default big model
on:
workflow_dispatch:
env:
HF_REPO: sunnypilot/sunnypilot_models_v1
HF_DEFAULTS_PATH: models/defaults/big
jobs:
resolve_name:
runs-on: ubuntu-24.04
outputs:
model_name: ${{ steps.name.outputs.model_name }}
onnx_ref: ${{ steps.name.outputs.onnx_ref }}
steps:
- uses: actions/checkout@v4
- id: name
run: |
NAME=$(PYTHONPATH=${{ github.workspace }} python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)")
ONNX_REF=$(git log -1 --format='%H' -- openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx)
echo "model_name=${NAME}" >> $GITHUB_OUTPUT
echo "onnx_ref=$ONNX_REF" >> $GITHUB_OUTPUT
build_model:
needs: resolve_name
uses: ./.github/workflows/sunnypilot-build-model.yaml
with:
upstream_branch: ${{ needs.resolve_name.outputs.onnx_ref }}
custom_name: ${{ needs.resolve_name.outputs.model_name }}
target_hardware: usbgpu
secrets: inherit
upload_defaults:
needs: [ resolve_name, build_model ]
runs-on: ubuntu-24.04
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- run: git lfs pull -I "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"
- name: Install huggingface_hub
run: pip install --upgrade "huggingface_hub>=0.22.0"
- name: Download artifact name
uses: actions/download-artifact@v4
with:
name: artifact-name-${{ needs.resolve_name.outputs.model_name }}
path: artifact_name
- name: Read artifact name
id: artifact
run: |
ARTIFACT_NAME=$(cat artifact_name/artifact_name.txt)
echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
- name: Download model artifact
uses: actions/download-artifact@v4
with:
name: ${{ steps.artifact.outputs.artifact_name }}
path: output
- name: Upload to HF and update default_models.json
env:
HF_OIDC_RESOURCE: datasets/${{ env.HF_REPO }}
ARTIFACT_NAME: ${{ steps.artifact.outputs.artifact_name }}
run: |
rm -f output/artifact_name.txt
export PYTHONPATH=$(pwd)
python3 release/ci/upload_default_model.py \
--hf-repo "${{ env.HF_REPO }}" \
--hf-defaults-path "${{ env.HF_DEFAULTS_PATH }}" \
--artifact-name "$ARTIFACT_NAME" \
--model-dir output \
--onnx-path "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" \
--onnx-ref "${{ needs.resolve_name.outputs.onnx_ref }}" \
--model-name "${{ needs.resolve_name.outputs.model_name }}" \
--tinygrad-ref "$(python3 openpilot/sunnypilot/models/tinygrad_ref.py)" \
--run-number "${{ github.run_number }}"
-516
View File
@@ -1,516 +0,0 @@
name: Build default models
on:
workflow_dispatch:
inputs:
target:
description: 'Model target to build'
required: true
type: choice
options:
- small
- big
- dm
workflow_call:
inputs:
target:
description: 'Model target to build (small, big, or dm)'
required: true
type: string
concurrency:
group: build-default-models-${{ inputs.target }}
cancel-in-progress: false
env:
HF_REPO: sunnypilot/sunnypilot_models_v1
jobs:
resolve:
runs-on: ubuntu-24.04
outputs:
model_name: ${{ steps.resolve.outputs.model_name }}
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 }}
tinygrad_ref: ${{ steps.resolve.outputs.tinygrad_ref }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- id: resolve
run: |
export PYTHONPATH=${{ github.workspace }}
if [ "${{ inputs.target }}" = "big" ]; then
NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)")
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=""
[ -n "$ONNX_PATH" ] && ONNX_REF=$(git log -1 --format='%H' -- "$ONNX_PATH")
TINYGRAD_REF=$(python3 openpilot/sunnypilot/models/tinygrad_ref.py)
if [ -z "$TINYGRAD_REF" ]; then
echo "::error::Failed to resolve tinygrad ref"
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
build_small_model:
needs: resolve
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:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Pull small ONNX via LFS
run: git lfs pull -I "${{ env.SMALL_ONNX }}"
- name: Set environment variables
run: |
source /etc/profile
export UV_PROJECT_ENVIRONMENT=${HOME}/venv
export UV_PYTHON_PREFERENCE=managed
export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python
export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
uv sync --frozen
printenv >> $GITHUB_ENV
- name: Disable powersave
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable
- name: Compile small model from ONNX
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: Chunk small pkl
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
python3 -c "
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
import os
pkl = '${{ github.workspace }}/${{ env.SMALL_PKL }}'
size = os.path.getsize(pkl)
targets = get_chunk_targets(pkl, size)
chunk_file(pkl, targets)
print(f'Chunked into {len(targets)} files')
"
- name: Prepare output
env:
MODEL_NAME: ${{ needs.resolve.outputs.safe_model_name }}
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models"
OUTPUT_DIR="${{ github.workspace }}/small_output"
PKL_BASE="driving_tinygrad.pkl"
mkdir -p "$OUTPUT_DIR"
cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/"
cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/"
ONNX_SHA256=$(git cat-file blob "$(git ls-files -s "${{ github.workspace }}/${{ env.SMALL_ONNX }}" | awk '{print $2}')" | grep '^oid sha256:' | cut -d: -f2)
python3 "${{ github.workspace }}/release/ci/model_generator.py" \
--model-dir "$MODELS_DIR" \
--output-dir "$OUTPUT_DIR" \
--custom-name "$MODEL_NAME" \
--upstream-branch "${{ needs.resolve.outputs.onnx_ref }}" \
--onnx-sha256 "$ONNX_SHA256"
echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt"
- name: Upload small model artifact
uses: actions/upload-artifact@v4
with:
name: model-${{ needs.resolve.outputs.safe_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 }}
path: ${{ github.workspace }}/small_output/artifact_name.txt
- name: Re-enable powersave
if: always()
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
build_big_model:
needs: resolve
if: ${{ inputs.target == 'big' }}
runs-on: [self-hosted, chestnut]
env:
MODELS_DIR: openpilot/selfdrive/modeld/models
COMPILER: tinygrad_repo/examples/openpilot
BIG_PKL: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
lfs: true
- name: Pull big PKL via LFS
run: git lfs pull -I "${{ env.BIG_PKL }}"
- name: Set environment variables
run: |
source /etc/profile
export UV_PROJECT_ENVIRONMENT=${HOME}/venv
export UV_PYTHON_PREFERENCE=managed
export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python
export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
uv sync --frozen
printenv >> $GITHUB_ENV
- name: Disable powersave
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable
- name: Wait for chestnut PCIe link
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
python3 -c "
import time
from openpilot.system.hardware.chestnut.flash import link_up
for i in range(10):
if link_up():
print(f'PCIe link up after {i+1} attempt(s)')
break
time.sleep(1)
else:
raise RuntimeError('Chestnut PCIe link not ready after 10 attempts')
"
- name: Chunk big pkl
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
python3 -c "
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
import os
pkl = '${{ github.workspace }}/${{ env.BIG_PKL }}'
size = os.path.getsize(pkl)
targets = get_chunk_targets(pkl, size)
chunk_file(pkl, targets)
print(f'Chunked into {len(targets)} files')
"
- name: Prepare output
env:
MODEL_NAME: ${{ needs.resolve.outputs.safe_model_name }}
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models"
OUTPUT_DIR="${{ github.workspace }}/big_output"
PKL_BASE="big_driving_tinygrad.pkl"
mkdir -p "$OUTPUT_DIR"
cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/"
cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/"
PKL_LFS_SHA256=$(sha256sum "$MODELS_DIR/${PKL_BASE}" | cut -d' ' -f1)
python3 "${{ github.workspace }}/release/ci/model_generator.py" \
--model-dir "$MODELS_DIR" \
--output-dir "$OUTPUT_DIR" \
--custom-name "$MODEL_NAME" \
--upstream-branch "${{ needs.resolve.outputs.onnx_ref }}" \
--onnx-sha256 "$PKL_LFS_SHA256"
echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt"
- name: Upload big model artifact
uses: actions/upload-artifact@v4
with:
name: model-${{ needs.resolve.outputs.safe_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 }}
path: ${{ github.workspace }}/big_output/artifact_name.txt
- name: Re-enable powersave
if: always()
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
upload_defaults:
needs: [ resolve, build_small_model, build_big_model, build_dm_model ]
if: |
${{
!cancelled() &&
(inputs.target == 'big' && needs.build_big_model.result == 'success' ||
inputs.target == 'small' && needs.build_small_model.result == 'success' ||
inputs.target == 'dm' && needs.build_dm_model.result == 'success')
}}
runs-on: ubuntu-24.04
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@v4
- name: Pull LFS for resolved ONNX files
if: ${{ needs.resolve.outputs.onnx_path != '' }}
run: git lfs pull -I "${{ needs.resolve.outputs.onnx_path }}"
- name: Install huggingface_hub
run: pip install --upgrade "huggingface_hub>=0.22.0"
- name: Download artifact name
if: ${{ inputs.target == 'small' || inputs.target == 'big' }}
uses: actions/download-artifact@v4
with:
name: artifact-name-${{ needs.resolve.outputs.safe_model_name }}
path: artifact_name
- name: Read artifact name
if: ${{ inputs.target == 'small' || inputs.target == 'big' }}
id: artifact
run: |
ARTIFACT_NAME=$(cat artifact_name/artifact_name.txt)
echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
- name: Download model artifact
if: ${{ inputs.target == 'small' || inputs.target == 'big' }}
uses: actions/download-artifact@v4
with:
name: ${{ steps.artifact.outputs.artifact_name }}
path: output
- name: Upload model to HF
if: ${{ inputs.target == 'small' || inputs.target == 'big' }}
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
ARTIFACT_NAME: ${{ steps.artifact.outputs.artifact_name }}
run: |
rm -f output/artifact_name.txt
export PYTHONPATH=$(pwd)
python3 release/ci/upload_default_model.py \
--hf-repo "${{ env.HF_REPO }}" \
--hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \
--artifact-name "$ARTIFACT_NAME" \
--model-dir output \
--model-name "${{ needs.resolve.outputs.model_name }}" \
--tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \
--run-number "${{ github.run_number }}"
- name: Download DM artifact
if: ${{ inputs.target == 'dm' }}
uses: actions/download-artifact@v4
with:
name: dm-model-${{ github.run_number }}
path: dm_output
- name: Generate DM metadata and upload to HF
if: ${{ inputs.target == 'dm' }}
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
export PYTHONPATH=$(pwd)
python3 -c "
import json, hashlib
from pathlib import Path
from datetime import datetime, UTC
dm_dir = Path('dm_output')
manifest = list(dm_dir.glob('*.chunkmanifest'))
assert manifest, 'No chunkmanifest found'
pkl_name = manifest[0].name.removesuffix('.chunkmanifest')
num_chunks = int(manifest[0].read_text().strip())
chunks = []
for i in range(num_chunks):
chunk = dm_dir / f'{pkl_name}.chunk{i+1:02d}of{num_chunks:02d}'
chunks.append({
'file_name': chunk.name,
'sha256': hashlib.sha256(chunk.read_bytes()).hexdigest()
})
digest = hashlib.sha256()
for c in chunks:
with open(dm_dir / c['file_name'], 'rb') as f:
while block := f.read(1024*1024):
digest.update(block)
metadata = {
'bundles': [{
'short_name': 'DMMODEL',
'display_name': '${{ needs.resolve.outputs.model_name }}',
'ref': '${{ needs.resolve.outputs.onnx_ref }}',
'runner': 'tinygrad',
'build_time': datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'),
'models': [{
'type': 'chunked',
'artifact': {
'file_name': pkl_name,
'download_uri': {'url': '', 'sha256': digest.hexdigest()},
'chunks': chunks
}
}]
}]
}
with open(dm_dir / 'metadata.json', 'w') as f:
json.dump(metadata, f, indent=2)
print('Generated DM metadata.json')
"
python3 release/ci/upload_default_model.py \
--hf-repo "${{ env.HF_REPO }}" \
--hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \
--artifact-name "dm-model-${{ github.run_number }}" \
--model-dir dm_output \
--onnx-path "${{ needs.resolve.outputs.onnx_path }}" \
--onnx-ref "${{ needs.resolve.outputs.onnx_ref }}" \
--model-name "${{ needs.resolve.outputs.model_name }}" \
--tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \
--run-number "${{ github.run_number }}"
build_dm_model:
needs: resolve
if: ${{ inputs.target == 'dm' }}
runs-on: [self-hosted, tici]
env:
DM_ONNX: openpilot/selfdrive/modeld/models/dmonitoring_model.onnx
DM_PKL: openpilot/selfdrive/modeld/models/dmonitoring_model_tinygrad.pkl
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Pull DM ONNX via LFS
run: git lfs pull -I "${{ env.DM_ONNX }}"
- name: Set environment variables
run: |
source /etc/profile
export UV_PROJECT_ENVIRONMENT=${HOME}/venv
export UV_PYTHON_PREFERENCE=managed
export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python
export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
uv sync --frozen
printenv >> $GITHUB_ENV
- name: Disable powersave
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable
- name: Compile DM model
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
taskset -c 7 env ${TG_FLAGS} python3 \
${{ github.workspace }}/tinygrad_repo/examples/openpilot/compile3.py \
${{ github.workspace }}/${{ env.DM_ONNX }} \
${{ github.workspace }}/${{ env.DM_PKL }}
- name: Chunk DM pkl
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
python3 -c "
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
import os
pkl = '${{ github.workspace }}/${{ env.DM_PKL }}'
size = os.path.getsize(pkl)
targets = get_chunk_targets(pkl, size)
chunk_file(pkl, targets)
print(f'Chunked {pkl} into {len(targets)} chunks')
"
- name: 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"
COMPILER="${{ github.workspace }}/tinygrad_repo/examples/openpilot"
MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models"
DM_SIZE=$(python3 -c "from openpilot.common.transformations.model import DM_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')")
for res in $(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')"); do
CAM_W=$(echo $res | cut -d x -f1)
CAM_H=$(echo $res | cut -d x -f2)
STRIDE_INFO=$(python3 -c "
import sys
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
s,y,u,sz = get_nv12_info(int(sys.argv[1]), int(sys.argv[2]))
print(f'{s},{y},{u},{sz}')
" $CAM_W $CAM_H)
WARP_PKL="${MODELS_DIR}/dm_warp_${res}_tinygrad.pkl"
taskset -c 7 env ${TG_FLAGS} python3 "${COMPILER}/compile_warp.py" \
--frame ${CAM_W},${CAM_H},${STRIDE_INFO} \
--warp-to ${DM_SIZE} \
--layout luma \
--border-fill 16 \
--transform-device NPY \
--output "${WARP_PKL}"
done
- name: Prepare DM output
run: |
mkdir -p dm_output
cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunk* dm_output/
cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunkmanifest dm_output/
cp ${{ github.workspace }}/openpilot/selfdrive/modeld/models/dm_warp_* dm_output/
- name: Upload DM artifact
uses: actions/upload-artifact@v4
with:
name: dm-model-${{ github.run_number }}
path: dm_output/
- name: Re-enable powersave
if: always()
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
@@ -30,7 +30,7 @@ on:
type: boolean
default: true
target_hardware:
description: 'Hardware target to compile for (qcom or chestnut)'
description: 'Hardware target to compile for (qcom or usbgpu)'
required: false
type: string
default: 'qcom'
@@ -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
@@ -106,20 +101,15 @@ on:
default: 'qcom'
options:
- qcom
- chestnut
- usbgpu
hf_repo:
description: 'Hugging Face dataset repository'
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
JSON_FILE: docs/docs/driving_models_${{ inputs.target_hardware == 'usbgpu' && 'usbgpu_v' || 'v' }}${{ inputs.json_version }}.json
jobs:
build_model:
@@ -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
@@ -1,73 +0,0 @@
name: Download HF model chunks
description: Resolve and download model chunks from HuggingFace in parallel
inputs:
hf_repo:
description: HuggingFace dataset repo
required: true
models:
description: 'JSON array of {hf_path, onnx_hash, canonical} objects'
required: true
dest_dir:
description: Destination directory for downloaded chunks
required: true
runs:
using: composite
steps:
- name: Download model chunks
shell: bash
env:
HF_REPO: ${{ inputs.hf_repo }}
MODELS_JSON: ${{ inputs.models }}
DEST_DIR: ${{ inputs.dest_dir }}
run: |
set -eo pipefail
DOWNLOAD_LIST=$(mktemp)
resolve_chunks() {
local HF_PATH="$1" ONNX_HASH="$2" CANONICAL="$3" DEST_DIR="$4"
local JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_PATH}/default_models.json"
local DEFAULTS BUNDLE ARTIFACT BASE_URL NUM_CHUNKS
DEFAULTS=$(curl -fsSL "$JSON_URL")
BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)')
ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact')
BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||')
NUM_CHUNKS=$(echo "$ARTIFACT" | jq -r '.chunks | length')
mkdir -p "$DEST_DIR"
while IFS= read -r CHUNK_NAME; do
CHUNK_IDX=$(echo "$CHUNK_NAME" | grep -oP 'chunk\K[0-9]+of[0-9]+' || true)
if [ -z "$CHUNK_IDX" ]; then
echo "::error::Failed to parse chunk index from: $CHUNK_NAME"
return 1
fi
ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${CHUNK_NAME}', safe=':/'))")
printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${CANONICAL}.chunk${CHUNK_IDX}" >> "$DOWNLOAD_LIST"
done < <(echo "$ARTIFACT" | jq -r '.chunks[].file_name')
echo "$NUM_CHUNKS" > "${DEST_DIR}/${CANONICAL}.chunkmanifest"
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
HF_PATH=$(echo "$model" | jq -r '.hf_path')
ONNX_HASH=$(echo "$model" | jq -r '.onnx_hash')
CANONICAL=$(echo "$model" | jq -r '.canonical')
resolve_chunks "$HF_PATH" "$ONNX_HASH" "$CANONICAL" "$DEST_DIR"
done
TOTAL=$(wc -l < "$DOWNLOAD_LIST")
echo "Downloading $TOTAL chunks with 8 parallel connections..."
xargs -P8 -d'\n' -I{} bash -c '
URL="${1%% *}"
DEST="${1#* }"
echo "Downloading $(basename "$DEST")"
curl -fsSL --retry 3 --retry-delay 5 -o "$DEST" "$URL"
' _ {} < "$DOWNLOAD_LIST"
rm -f "$DOWNLOAD_LIST"
+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:
+70 -83
View File
@@ -31,7 +31,7 @@ on:
type: string
default: ''
target_hardware:
description: 'Hardware target to compile for (qcom or chestnut)'
description: 'Hardware target to compile for (qcom or usbgpu)'
required: false
type: string
default: 'qcom'
@@ -57,7 +57,7 @@ on:
type: choice
options:
- qcom
- chestnut
- usbgpu
default: 'qcom'
@@ -102,28 +102,26 @@ jobs:
cat $GITHUB_OUTPUT
- 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
if [ "${{ inputs.target_hardware }}" != "usbgpu" ]; then
git lfs pull -X "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/dmonitoring_*.onnx"
rm -f selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/dmonitoring_*.onnx
else
git lfs pull -I "**/selfdrive/modeld/models/big_*.onnx,**/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, usbgpu]
needs: get_model
env:
MODEL_NAME: ${{ inputs.custom_name || inputs.upstream_branch }} (${{ needs.get_model.outputs.model_date }})
@@ -187,10 +185,10 @@ jobs:
CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')")
TG_FLAGS_QCOM="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
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"
if [ "${{ inputs.target_hardware }}" == "usbgpu" ]; then
echo "USBGPU build"
export USBGPU=1
TG_FLAGS="DEBUG=2 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
+96 -303
View File
@@ -39,8 +39,6 @@ jobs:
include_big_model: ${{ steps.strategy.outputs.include_big_model }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Extract deploy strategy
id: strategy
run: |
@@ -98,8 +96,6 @@ jobs:
}}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Wait for Tests
uses: ./.github/workflows/wait-for-action # Path to where you place the action
with:
@@ -123,7 +119,6 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
submodules: recursive
ref: ${{ env.SOURCE_BRANCH }}
repository: ${{ github.event.pull_request.head.repo.fork && github.event.pull_request.head.repo.full_name || github.repository }}
@@ -170,10 +165,7 @@ jobs:
scons -j1 cache_dir="$SCONS_CACHE" --minimal \
openpilot/selfdrive/locationd openpilot/sunnypilot/selfdrive/locationd
echo "Building rest of sunnypilot"
sudo rm -rf /tmp/* || true
mkdir -p "$BUILD_DIR/tmp"
export TMPDIR="$BUILD_DIR/tmp"
SKIP_TINYGRAD_COMPILE=1 /usr/bin/time -v scons -j$(nproc) cache_dir="$SCONS_CACHE" --minimal
/usr/bin/time -v scons -j$(nproc) cache_dir="$SCONS_CACHE" --minimal
touch ${BUILD_DIR}/prebuilt
if [[ "${{ runner.debug }}" == "1" ]]; then
ls -la ${BUILD_DIR}
@@ -219,245 +211,91 @@ 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:
GH_REPO: ${{ github.repository }}
HF_REPO: sunnypilot/sunnypilot_models_v1
HF_DEFAULTS_PATH: models/defaults/big
steps:
- name: Resolve tinygrad ref via API
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref_name }}
- run: git lfs pull -I "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"
- name: Check HF defaults and build if needed
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}/git/blobs/${BLOB_SHA}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2)
echo "big PKL hash: $ONNX_HASH"
[ -n "$ONNX_HASH" ] || { echo "::error::Failed to extract big PKL hash"; exit 1; }
TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha')
echo "tinygrad ref: $TINYGRAD_REF"
ACTUAL_ONNX_HASH=$(sha256sum "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" | cut -d' ' -f1)
echo "Repo ONNX hash: $ACTUAL_ONNX_HASH"
JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json"
check_defaults() {
DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 2>/dev/null) || return 1
TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null)
[ "$TINYGRAD_MATCH" = "true" ] || return 1
BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null)
check_hash() {
DEFAULTS=$(curl -fsSL "$JSON_URL" 2>/dev/null) || return 1
BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ACTUAL_ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null)
[ -n "$BUNDLE" ] && [ "$BUNDLE" != "null" ]
}
if check_defaults; then
echo "HF defaults match repo tinygrad ref"
exit 0
fi
if check_hash; then
echo "HF defaults match repo ONNX"
else
echo "No matching model on HF — triggering build"
gh workflow run build-default-big-model.yaml --ref "${{ github.head_ref || github.ref_name }}"
echo "No matching model on HF — dispatching build"
gh workflow run build-default-models.yaml --ref "$REF" -f target=big
sleep 10
echo "Waiting for build to start..."
sleep 120
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..."
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
RUN_ID=$(gh run list --workflow=build-default-big-model.yaml --branch="${{ github.head_ref || github.ref_name }}" --limit=1 --json databaseId --jq '.[0].databaseId')
if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then
echo "::error::Failed to find build-default-big-model run"
exit 1
fi
done
echo "::error::Build run did not complete within 45 minutes"
exit 1
echo "Waiting for run $RUN_ID..."
gh run watch "$RUN_ID"
CONCLUSION=$(gh run view "$RUN_ID" --json conclusion --jq '.conclusion')
if [ "$CONCLUSION" != "success" ]; then
echo "::error::build-default-big-model failed: $CONCLUSION"
exit 1
fi
if ! check_hash; then
echo "::error::HF defaults still don't match after build"
exit 1
fi
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Cancel run on failure
if: failure()
run: gh run cancel ${{ github.run_id }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
prepare_small_model:
needs: [ prepare_strategy ]
runs-on: ubuntu-24.04
concurrency:
group: prepare-small-model
cancel-in-progress: false
outputs:
driving_onnx_sha256: ${{ steps.resolve.outputs.driving_onnx_sha256 }}
env:
GH_REPO: ${{ github.repository }}
HF_REPO: sunnypilot/sunnypilot_models_v1
HF_DEFAULTS_PATH: models/defaults/small
steps:
- name: Resolve tinygrad ref via API
id: resolve
- name: Download big model chunks
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}/git/blobs/${BLOB_SHA}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2)
echo "Driving ONNX hash: $DRIVING_HASH"
[ -n "$DRIVING_HASH" ] || { echo "::error::Failed to extract driving ONNX hash"; exit 1; }
echo "driving_onnx_sha256=$DRIVING_HASH" >> "$GITHUB_OUTPUT"
TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha')
echo "tinygrad ref: $TINYGRAD_REF"
ACTUAL_ONNX_HASH=$(sha256sum "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" | cut -d' ' -f1)
JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json"
DEFAULTS=$(curl -fsSL "$JSON_URL")
BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ACTUAL_ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)')
check_defaults() {
DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 2>/dev/null) || return 1
TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null)
[ "$TINYGRAD_MATCH" = "true" ] || return 1
DRIVING=$(echo "$DEFAULTS" | jq --arg hash "$DRIVING_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null)
[ -n "$DRIVING" ] && [ "$DRIVING" != "null" ] || return 1
}
mkdir -p big_model_chunks
ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact')
BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||')
NUM_CHUNKS=$(echo "$ARTIFACT" | jq -r '.chunks | length')
if check_defaults; then
echo "HF defaults match repo 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..."
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
fi
CANONICAL="big_driving_tinygrad.pkl"
echo "$ARTIFACT" | jq -r '.chunks[].file_name' | while read CHUNK_NAME; do
CHUNK_IDX=$(echo "$CHUNK_NAME" | grep -oP 'chunk\K[0-9]+of[0-9]+')
CANONICAL_CHUNK="${CANONICAL}.chunk${CHUNK_IDX}"
ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${CHUNK_NAME}', safe=':/'))")
echo "Downloading $CHUNK_NAME -> $CANONICAL_CHUNK"
curl -fsSL -o "big_model_chunks/${CANONICAL_CHUNK}" "$ENCODED_URL"
done
echo "::error::Small model build did not complete within 30 minutes"
exit 1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
echo "$NUM_CHUNKS" > "big_model_chunks/${CANONICAL}.chunkmanifest"
- name: Cancel run on failure
if: failure()
run: gh run cancel ${{ github.run_id }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
prepare_dm_model:
needs: [ prepare_strategy ]
runs-on: ubuntu-24.04
concurrency:
group: prepare-dm-model
cancel-in-progress: false
outputs:
dm_onnx_sha256: ${{ steps.resolve.outputs.dm_onnx_sha256 }}
env:
GH_REPO: ${{ github.repository }}
HF_REPO: sunnypilot/sunnypilot_models_v1
HF_DEFAULTS_PATH: models/defaults/dm
steps:
- name: Resolve 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}/git/blobs/${BLOB_SHA}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2)
echo "DM ONNX hash: $DM_HASH"
[ -n "$DM_HASH" ] || { echo "::error::Failed to extract DM ONNX hash"; exit 1; }
echo "dm_onnx_sha256=$DM_HASH" >> "$GITHUB_OUTPUT"
TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha')
echo "tinygrad ref: $TINYGRAD_REF"
JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json"
check_defaults() {
DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 2>/dev/null) || return 1
TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null)
[ "$TINYGRAD_MATCH" = "true" ] || return 1
DM=$(echo "$DEFAULTS" | jq --arg hash "$DM_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null)
[ -n "$DM" ] && [ "$DM" != "null" ] || return 1
}
if check_defaults; then
echo "HF defaults match DM 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..."
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
fi
done
echo "::error::DM model build did not complete within 30 minutes"
exit 1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload big model chunks
uses: actions/upload-artifact@v4
with:
name: big-model-chunks
path: big_model_chunks/
compression-level: 0
- name: Cancel run on failure
if: failure()
@@ -467,24 +305,23 @@ jobs:
publish:
concurrency:
# We do a bit of a hack here to avoid canceling the publishing job if a new commit comes in while we're publishing by adding the sha to the group name.
# This means that if multiple commits come in while we're publishing, they will be queued up and publish one after the other.
# Otherwise, if a job is waiting to be published due to environment wait time, it would be canceled by a new commit and restart the wait time.
group: ${{ needs.prepare_strategy.outputs.publish_concurrency_group }}
cancel-in-progress: ${{ needs.prepare_strategy.outputs.cancel_publish_in_progress == 'true' }}
if: ${{
always() && !cancelled() &&
needs.build.result == 'success' &&
needs.prepare_strategy.result == 'success' &&
needs.prepare_small_model.result == 'success' &&
needs.prepare_dm_model.result == 'success' &&
(!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) &&
(needs.prepare_strategy.outputs.include_big_model != 'true' || needs.prepare_chestnut.result == 'success')
}}
needs: [ build, prepare_strategy, prepare_chestnut, prepare_small_model, prepare_dm_model ]
needs: [ build, prepare_strategy, prepare_chestnut ]
runs-on: ubuntu-24.04
environment: ${{ needs.prepare_strategy.outputs.environment }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Download prebuilt artifact
uses: actions/download-artifact@v4
@@ -496,16 +333,23 @@ jobs:
mkdir -p ${{ env.OUTPUT_DIR }}
tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }}
- name: Download model chunks from HF
uses: ./.github/workflows/download-hf-model-chunks
- name: Prepare chestnut output
if: ${{ needs.prepare_chestnut.result == 'success' }}
run: |
mkdir -p "${{ github.workspace }}/chestnut_output"
tar xzf prebuilt.tar.gz -C "${{ github.workspace }}/chestnut_output"
- name: Download big model chunks
if: ${{ needs.prepare_chestnut.result == 'success' }}
uses: actions/download-artifact@v4
with:
hf_repo: sunnypilot/sunnypilot_models_v1
dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models
models: |
[
{"hf_path": "models/defaults/small", "onnx_hash": "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}", "canonical": "driving_tinygrad.pkl"},
{"hf_path": "models/defaults/dm", "onnx_hash": "${{ needs.prepare_dm_model.outputs.dm_onnx_sha256 }}", "canonical": "dmonitoring_model_tinygrad.pkl"}
]
name: big-model-chunks
path: big_model_chunks
- name: Inject big model into chestnut
if: ${{ needs.prepare_chestnut.result == 'success' }}
run: |
cp big_model_chunks/* "${{ github.workspace }}/chestnut_output/openpilot/selfdrive/modeld/models/"
- name: Configure Git
run: |
@@ -527,6 +371,22 @@ jobs:
"https://x-access-token:${{github.token}}@github.com/sunnypilot/sunnypilot.git" \
"${{ needs.prepare_strategy.outputs.extra_version_identifier }}"
- name: Publish chestnut branch
if: ${{ needs.prepare_chestnut.result == 'success' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CHESTNUT_BRANCH="${{ needs.prepare_strategy.outputs.new_branch }}-chestnut"
CHESTNUT_DIR="${{ github.workspace }}/chestnut_output"
${{ env.CI_DIR }}/publish.sh \
"${{ github.workspace }}" \
"$CHESTNUT_DIR" \
"$CHESTNUT_BRANCH" \
"${{ needs.prepare_strategy.outputs.version }}" \
"https://x-access-token:${{github.token}}@github.com/sunnypilot/sunnypilot.git" \
"${{ needs.prepare_strategy.outputs.extra_version_identifier }}"
- name: Tag ${{ needs.prepare_strategy.outputs.environment }}
if: ${{ needs.prepare_strategy.outputs.is_stable_branch == 'true' && (github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')) }}
run: |
@@ -534,77 +394,12 @@ jobs:
git tag -f -a ${TAG} -m "${{ needs.prepare_strategy.outputs.environment }} @ ${{ needs.prepare_strategy.outputs.version }} of build ${{ needs.prepare_strategy.outputs.build }}."
git push -f origin ${TAG}
publish_chestnut:
concurrency:
group: ${{ needs.prepare_strategy.outputs.publish_concurrency_group }}-chestnut
cancel-in-progress: ${{ needs.prepare_strategy.outputs.cancel_publish_in_progress == 'true' }}
if: ${{
always() && !cancelled() &&
needs.build.result == 'success' &&
needs.prepare_strategy.result == 'success' &&
needs.prepare_small_model.result == 'success' &&
needs.prepare_dm_model.result == 'success' &&
needs.prepare_chestnut.result == 'success' &&
(!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt'))
}}
needs: [ build, prepare_strategy, prepare_chestnut, prepare_small_model, prepare_dm_model ]
runs-on: ubuntu-24.04
environment: ${{ needs.prepare_strategy.outputs.environment }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Download prebuilt artifact
uses: actions/download-artifact@v4
with:
name: prebuilt
- name: Untar prebuilt
run: |
mkdir -p ${{ env.OUTPUT_DIR }}
tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }}
- name: Download model chunks from HF
uses: ./.github/workflows/download-hf-model-chunks
with:
hf_repo: sunnypilot/sunnypilot_models_v1
dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models
models: |
[
{"hf_path": "models/defaults/small", "onnx_hash": "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}", "canonical": "driving_tinygrad.pkl"},
{"hf_path": "models/defaults/dm", "onnx_hash": "${{ needs.prepare_dm_model.outputs.dm_onnx_sha256 }}", "canonical": "dmonitoring_model_tinygrad.pkl"},
{"hf_path": "models/defaults/big", "onnx_hash": "${{ needs.prepare_chestnut.outputs.onnx_sha256 }}", "canonical": "big_driving_tinygrad.pkl"}
]
- name: Configure Git
run: |
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
- name: Publish chestnut branch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CHESTNUT_BRANCH="${{ needs.prepare_strategy.outputs.new_branch }}-chestnut"
${{ env.CI_DIR }}/publish.sh \
"${{ github.workspace }}" \
"${{ env.OUTPUT_DIR }}" \
"$CHESTNUT_BRANCH" \
"${{ needs.prepare_strategy.outputs.version }}" \
"https://x-access-token:${{github.token}}@github.com/sunnypilot/sunnypilot.git" \
"${{ needs.prepare_strategy.outputs.extra_version_identifier }}"
notify:
needs:
- prepare_strategy
- build
- publish
- publish_chestnut
- prepare_chestnut
- prepare_small_model
- prepare_dm_model
runs-on: ubuntu-24.04
if: ${{ (always() && !cancelled() && !failure())
&& needs.publish.result == 'success'
@@ -612,8 +407,6 @@ jobs:
&& (fromJSON(vars.DEV_FEEDBACK_NOTIFICATION_BRANCHES_V2)[github.head_ref || github.ref_name] != null) }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Prepare notification message
id: message
+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"
-85
View File
@@ -1,85 +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"
run: |
export PYTHONPATH="$GITHUB_WORKSPACE:$GITHUB_WORKSPACE/tinygrad_repo"
pip uninstall -y tinygrad || true
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 -3
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",
}
@@ -120,7 +121,6 @@ def _libflags(target, source, env, for_signature):
env = Environment(
ENV={
"PATH": os.environ['PATH'],
"TMPDIR": os.environ.get('TMPDIR', '/tmp'),
"PYTHONPATH": os.pathsep.join(submodule_python_paths),
"ACADOS_SOURCE_DIR": acados.DIR,
"ACADOS_PYTHON_INTERFACE_PATH": acados.TEMPLATE_DIR,
@@ -346,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 -12
View File
@@ -18,21 +18,19 @@ 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"
done
$DIR/openpilot/common/hardware/comma/updater $AGNOS_PY $MANIFEST
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 +42,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 +51,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 +67,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 +88,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"
-2
View File
@@ -131,7 +131,6 @@ struct ModelManagerSP @0xaedffd8f31e7b55d {
downloaded @2;
cached @3;
failed @4;
verifying @5;
}
struct DownloadProgress {
@@ -353,7 +352,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 -17
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"}},
@@ -60,7 +59,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"IsDriverViewEnabled", {CLEAR_ON_MANAGER_START, BOOL}},
{"IsEngaged", {PERSISTENT, BOOL}},
{"IsLdwEnabled", {PERSISTENT | BACKUP, BOOL}},
{"IsLiveStreaming", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, BOOL}},
{"IsLiveStreaming", {CLEAR_ON_MANAGER_START, BOOL}},
{"IsMetric", {PERSISTENT | BACKUP, BOOL}},
{"IsOffroad", {CLEAR_ON_MANAGER_START, BOOL}},
{"IsRhdDetected", {PERSISTENT, BOOL}},
@@ -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}},
@@ -137,8 +130,8 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"UpdaterLastFetchTime", {PERSISTENT, TIME}},
{"UptimeOffroad", {PERSISTENT, FLOAT, "0.0"}},
{"UptimeOnroad", {PERSISTENT, FLOAT, "0.0"}},
{"ChestnutActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
{"ChestnutLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
{"UsbGpuActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
{"UsbGpuLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
{"Version", {PERSISTENT, STRING}},
// --- sunnypilot params --- //
@@ -202,16 +195,14 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
// Model Manager params
{"ModelManager_ActiveBundle", {PERSISTENT, JSON}},
{"ModelManager_ActiveBundleUSBGPU", {PERSISTENT, JSON}}, //TODO-SP: kept for migration, remove on next sync?
{"ModelManager_ActiveBundleChestnut", {PERSISTENT, JSON}},
{"ModelManager_ActiveJson", {CLEAR_ON_MANAGER_START, JSON}},
{"ModelManager_ActiveJson", {CLEAR_ON_MANAGER_START, STRING}},
{"ModelManager_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}},
{"ModelManager_DownloadRef", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, STRING}},
{"ModelManager_DownloadIndex", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, INT}},
{"ModelManager_Favs", {PERSISTENT | BACKUP, STRING}},
{"ModelManager_LastSyncTime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}},
{"ModelManager_LastSyncTime_Chestnut", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}},
{"ModelManager_LastSyncTime_USBGPU", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}},
{"ModelManager_ModelsCache", {PERSISTENT | BACKUP, JSON}},
{"ModelManager_ModelsCache_Chestnut", {PERSISTENT | BACKUP, JSON}},
{"ModelManager_ModelsCache_USBGPU", {PERSISTENT | BACKUP, JSON}},
// Neural Network Lateral Control
{"NeuralNetworkLateralControl", {PERSISTENT | BACKUP, BOOL, "0"}},
@@ -254,7 +245,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
// mapd
{"MapAdvisorySpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT}},
{"Mapd_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}},
{"MapdVersion", {PERSISTENT, STRING}},
{"MapSpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT, "0.0"}},
{"NextMapSpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, 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 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bf97a6738b294ac0aed9b2d075916cee0b7d3215bcd23381760900ede6a92748
size 13256
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a8c5fece2a1c7587feb41cbe04c6aee08e768ecd9b5d00da6af9832a4ccc842
size 2034
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7409c53d7c72681c24982fd83b56ce70f80797c9c0f936d9296a5c18557ac472
size 7279
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:58bd6155433f623b1f75d134bd8ca4745d9aa71f6767eb807cdbcf7deb3089a1
size 10876
@@ -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)
+107 -52
View File
@@ -1,45 +1,94 @@
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, usbgpu_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'
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'
tg_devices = { # which device to put jit inputs to at runtime
'openpilot.selfdrive.modeld.modeld': {
'default': {'WARP_DEV': tg_backend, 'QUEUE_DEV': tg_backend},
'usbgpu': {'WARP_DEV': tg_backend, 'QUEUE_DEV': 'AMD'}
},
'openpilot.selfdrive.modeld.dmonitoringmodeld': {
'default': {'DEV': tg_backend}
},
}
USBGPU = usbgpu_present()
if USBGPU:
usbgpu_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
usbgpu_lock = File("models/.usb_gpu.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 ''
modeld_dir = Dir("#openpilot/selfdrive/modeld").abspath
compile_modeld_script = [
File(f"{modeld_dir}/compile_modeld.py"),
File(f"{modeld_dir}/get_model_metadata.py"),
File("#openpilot/system/camerad/cameras/nv12_info.py"),
File("#openpilot/common/hardware/hw.py"),
]
model_w, model_h = MEDMODEL_INPUT_SIZE
frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ
def chestnut_action(command):
def do_compile(target, source, env):
for usbgpu in [False, True] if USBGPU else [False]:
target_pkl_path = File(modeld_pkl_path(usbgpu)).abspath
# BIG_INTO_SMALL=1 builds the default target from the big model, e.g. to test it without a USB GPU
file_prefix, cmd_flags = ('big_', usbgpu_tg_flags) if usbgpu else ('big_' if os.getenv('BIG_INTO_SMALL') else '', tg_flags)
driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath)
camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in CAMERA_CONFIGS)
# 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):
@@ -47,44 +96,50 @@ def chestnut_action(command):
break
time.sleep(1)
else:
print("Chestnut not ready, skipping warp build")
print("Chestnut not ready, skipping big model 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"),
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, " [USBGPU] $TARGET") if usbgpu 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 usbgpu:
lenv.SideEffect(usbgpu_lock, node)
compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl')
compile_model('models/driving_supercombo.onnx', 'models/driving_tinygrad.pkl')
model_w, model_h = MEDMODEL_INPUT_SIZE
for chestnut in [False, True] if CHESTNUT else [False]:
file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags)
for cam_w, cam_h in camera_configs:
warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath
stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h)
cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_warp.py" '
f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{stride * (y_height + uv_height)} '
f'--warp-to {model_w}x{model_h} --layout yuv420 --frames 2 '
f'--output {warp_pkl_path}')
action = chestnut_action(cmd) if chestnut else cmd
node = lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], action)
if chestnut:
lenv.SideEffect(chestnut_lock, node)
# 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")],
)
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, usbgpu=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}')
+31 -11
View File
@@ -1,40 +1,60 @@
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 modeld_pkl_path(chestnut: bool):
prefix = 'big_' if chestnut else ''
def get_tg_input_devices(process_name: str, usbgpu: bool):
with open(TG_INPUT_DEVICES_PATH) as f:
return json.load(f)[process_name]['default' if not usbgpu else 'usbgpu']
def modeld_pkl_path(usbgpu: bool):
prefix = 'big_' if usbgpu 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())
def chestnut_present() -> bool:
def usbgpu_present() -> bool:
for d in USB_DEVICES_PATH.glob("*"):
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'))
def usbgpu_compiled() -> bool:
return Path(get_manifest_path(modeld_pkl_path(usbgpu=True))).is_file()
+101 -96
View File
@@ -1,17 +1,10 @@
#!/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
os.environ['GMMU'] = '0' # for usbgpu fast loading, noop for qcom
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 +26,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 usbgpu_present, usbgpu_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 +73,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,16 +88,14 @@ 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:
smu = Device["AMD"].iface.dev_impl.smu
metrics_t = smu.smu_mod.SmuMetricsExternal_t
smu._send_msg(smu.smu_mod.PPSMC_MSG_TransferTableSmu2Dram, smu.smu_mod.TABLE_SMU_METRICS, timeout=100)
metrics_buf = bytearray(smu.adev.vram.view(smu.driver_table_paddr, ctypes.sizeof(metrics_t))[:])
metrics = metrics_t.from_buffer(metrics_buf).SmuMetrics
metrics = smu.read_table(smu.smu_mod.SmuMetricsExternal_t, smu.smu_mod.TABLE_SMU_METRICS).SmuMetrics
self.metrics = {'tempC': metrics.AvgTemperature[smu.smu_mod.TEMP_HOTSPOT],
'memoryTempC': metrics.AvgTemperature[smu.smu_mod.TEMP_MEM],
'powerDrawW': metrics.AverageSocketPower,
@@ -116,8 +113,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 +138,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):
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']))
def __init__(self, cam_w: int, cam_h: int, usbgpu: bool):
ModelStateBase.__init__(self)
input_devices = get_tg_input_devices(PROCESS_NAME, usbgpu)
self.WARP_DEV, self.QUEUE_DEV = input_devices['WARP_DEV'], input_devices['QUEUE_DEV']
jits = load_oob(open_file_chunked(modeld_pkl_path(usbgpu)))
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
self.usbgpu = usbgpu
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]
inputs: dict[str, np.ndarray]) -> dict[str, np.ndarray] | None:
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,41 +184,46 @@ 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)
if after_enqueue is not None:
after_enqueue()
model_output = self.outputs['outputs'].numpy()[0]
if self.chestnut and not np.all(np.isfinite(model_output)):
raise RuntimeError("model output not finite")
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
)
model_output = outs.numpy()[0]
if self.usbgpu and not np.all(np.isfinite(model_output)):
# TODO remove with prev_feat
cloudlog.error("model output not finite, dropping frame")
return None
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):
cloudlog.warning("modeld init")
CHESTNUT = chestnut_present() and chestnut_compiled()
if CHESTNUT:
USBGPU = usbgpu_present() and usbgpu_compiled()
if USBGPU:
os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000'
params = Params()
params.put_bool("ChestnutLoading", CHESTNUT)
params.remove("ChestnutActive")
params.put_bool("UsbGpuLoading", USBGPU)
params.remove("UsbGpuActive")
config_realtime_process(7, 54)
@@ -259,7 +253,7 @@ def main(demo=False):
st = time.monotonic()
cloudlog.warning("loading model")
model = None
if CHESTNUT:
if USBGPU:
big_model = None
def load_big():
nonlocal big_model
@@ -273,22 +267,23 @@ def main(demo=False):
loader.start()
loader.join(BIG_MODEL_TIMEOUT)
model = big_model
params.put_bool("ChestnutActive", model is not None)
params.put_bool("UsbGpuActive", model is not None)
small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or CHESTNUT else None
small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or USBGPU else None
if model is None:
model = small_model
params.put_bool("ChestnutLoading", False)
params.put_bool("UsbGpuLoading", 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 USBGPU 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.usbgpu) if USBGPU else None
# setup filter to track dropped frames
frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_RUN_FREQ)
@@ -315,6 +310,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 +350,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)
@@ -396,15 +393,14 @@ 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)
model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None)
model_output = model.run(bufs, transforms, inputs)
except Exception:
if not params.get_bool("ChestnutActive"):
if not params.get_bool("UsbGpuActive"):
raise
# fallback to small model
cloudlog.exception("big model failed, fall back to small")
params.put_bool("ChestnutActive", False)
params.put_bool("UsbGpuActive", False)
assert small_model is not None
model = small_model
if chestnut_state is not None:
chestnut_state.big = False
@@ -423,7 +419,7 @@ def main(demo=False):
fill_model_msg(modelv2_send, model_output, action,
publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id,
frame_drop_ratio, meta_main.timestamp_eof, model_execution_time, extrinsics_calibration_seen)
modelv2_send.modelV2.big = model.chestnut
modelv2_send.modelV2.big = model.usbgpu
desire_state = modelv2_send.modelV2.meta.desireState
l_lane_change_prob = desire_state[log.Desire.laneChangeLeft]
@@ -433,13 +429,22 @@ 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 chestnut_state is not None and run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0:
chestnut_state.send()
if __name__ == "__main__":
try:
import argparse
@@ -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": {
+7 -9
View File
@@ -195,18 +195,17 @@ class SelfdriveD(CruiseHelper):
self.events.add(EventName.joystickDebug)
self.startup_event = None
loading = self.params.get_bool("ChestnutLoading")
loading = self.params.get_bool("UsbGpuLoading")
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)
big_active = self.params.get("ChestnutActive")
chestnut_present = self.sm['deviceState'].chestnutPresent
big_active = self.params.get("UsbGpuActive")
usbgpu_present = self.sm['deviceState'].chestnutPresent
model_unavailable = big_active is True and self.sm.seen['modelV2'] and not self.sm.alive['modelV2']
big_failed = big_active is False or model_unavailable or (self.big_model_active and not chestnut_present)
big_failed = big_active is False or model_unavailable or (self.big_model_active and not usbgpu_present)
if big_failed and not self.big_model_failed:
self.events.add(EventName.bigModelFailed)
self.big_model_failed = big_failed
@@ -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
+1 -8
View File
@@ -168,16 +168,9 @@ class Sidebar(Widget, SidebarSP):
# Home/Flag button
flag_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN)
button_img = self._flag_img if ui_state.started else self._home_img
button_pos = rl.Vector2(HOME_BTN.x, HOME_BTN.y)
icon_opacity = 1.0
if gui_app.sunnypilot_ui():
button_img, button_pos, icon_opacity = SidebarSP._get_home_icon(self, button_img)
tint = Colors.BUTTON_PRESSED if (ui_state.started and flag_pressed) else Colors.BUTTON_NORMAL
if icon_opacity < 1.0:
tint = rl.Color(tint[0], tint[1], tint[2], int(255 * icon_opacity))
rl.draw_texture_ex(button_img, button_pos, 0.0, 1.0, tint)
rl.draw_texture_ex(button_img, rl.Vector2(HOME_BTN.x, HOME_BTN.y), 0.0, 1.0, tint)
# Microphone button
if self._recording_audio:
@@ -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
+41 -51
View File
@@ -1,7 +1,4 @@
from __future__ import annotations
import datetime
import math
import time
from openpilot.cereal import log
@@ -11,8 +8,8 @@ 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.selfdrive.ui.ui_state import ui_state, ChestnutState
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.common.version import RELEASE_BRANCHES
HEAD_BUTTON_FONT_SIZE = 40
@@ -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,10 +139,8 @@ 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_failed_icon = IconWidget("icons_mici/chestnut_orange.png", (68, 40))
self._egpu_icon = IconWidget("icons_mici/egpu_green.png", (50, 37))
self._egpu_icon_gray = IconWidget("icons_mici/egpu_gray.png", (50, 37))
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._egpu_icon,
self._egpu_icon_gray,
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,9 @@ 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._egpu_icon.set_visible(ui_state.sm["deviceState"].chestnutPresent and ui_state.usbgpu_compiled)
self._egpu_icon_gray.set_visible(ui_state.sm["deviceState"].chestnutPresent and not ui_state.usbgpu_compiled)
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,

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