Compare commits

..

117 Commits

Author SHA1 Message Date
DevTekVE be63625fa8 Add Dockerfile and related scripts for sunnypilot CI build pipeline
This commit introduces `Dockerfile.sunnypilot`, `.dockerignore` updates, and a new `docker_build_sp.sh` script to support building and testing sunnypilot in a Dockerized environment.
2025-07-01 18:26:16 +02:00
DevTekVE cdf990c1b1 Sync: commaai/openpilot:master into sunnypilot/sunnypilot:master-new (#1020) 2025-06-28 22:06:31 +02:00
DevTekVE ea8eaed1aa Merge remote-tracking branch 'comma/master' into sync-20250627
# Conflicts:
#	README.md
#	opendbc_repo
#	panda
#	selfdrive/ui/qt/offroad/settings.cc
#	selfdrive/ui/translations/main_ar.ts
#	selfdrive/ui/translations/main_de.ts
#	selfdrive/ui/translations/main_es.ts
#	selfdrive/ui/translations/main_fr.ts
#	selfdrive/ui/translations/main_ja.ts
#	selfdrive/ui/translations/main_ko.ts
#	selfdrive/ui/translations/main_pt-BR.ts
#	selfdrive/ui/translations/main_th.ts
#	selfdrive/ui/translations/main_tr.ts
#	selfdrive/ui/translations/main_zh-CHS.ts
#	selfdrive/ui/translations/main_zh-CHT.ts
#	system/manager/build.py
#	system/ui/spinner.py
#	tinygrad_repo
#	tools/lib/framereader.py
Merge branch 'comma-202506127-bedcb896644528aed6af448e63eeadb3dd8b2c77' into sync-20250627

# Conflicts:
#	opendbc/safety/tests/libsafety/SConscript
Merge branch 'comma-202506127-bedcb896644528aed6af448e63eeadb3dd8b2c77' into sync-20250627

# Conflicts:
#	opendbc/safety/tests/libsafety/SConscript
Merge remote-tracking branch 'comma/master' into sync-20250627

# Conflicts:
#	opendbc/safety/tests/libsafety/SConscript
Merge branch 'comma-20250627-1020d355584265391eb3acb556e4353b581fa9c0' into sync-20250627
Merge branch 'comma-20250627-1020d355584265391eb3acb556e4353b581fa9c0' into sync-20250627
Sync: `commaai/opendbc:master` into `sunnypilot/opendbc:master-new`

Sync: `commaai/opendbc:master` into `sunnypilot/opendbc:master-new`
Sync: `commaai/panda:master` into `sunnypilot/panda:master-new`

Sync: `commaai/panda:master` into `sunnypilot/panda:master-new`
2025-06-28 21:43:38 +02:00
Shane Smiskol 4e094bc740 raylib UI: fix scrolling click behavior (#35609)
see look how nice using base classes are
2025-06-27 02:52:11 -07:00
Nayan 17432e1b0d ui: model panel - added download progress bars (#998)
* progress bars!!

* too many changes

---------

Co-authored-by: DevTekVE <devtekve@gmail.com>
2025-06-27 10:44:14 +02:00
Kacper Rączy 0218ae82ed Fix openpilot-prebuilt image build (#35607)
Fix tinygrad shell exec
2025-06-27 02:51:20 +00:00
Shane Smiskol 7b35f64049 raylib UI: implement easier to use Scroller (#35606)
* new scroller and widget

start

heck yeah

fix that

clean up

* fuck yeah

* line sep

* fix that

* fix clicking on action

* no custom width

* move all over

* clean up

* more clean up

* rm custom visible too

* more clean up

* lint

* dont use enabled generically yet

* ??
2025-06-26 17:58:34 -07:00
Andrei Radulescu 0a254fbc4e ui: avoid some raylib ui no dongleid errors (#35562)
avoid some ui.py errors on pc
2025-06-26 15:16:20 -07:00
Dean Lee 903f426bb9 ui: fix shader polygon artifacts on device (#35568)
fix shader polygon artifacts on device
2025-06-26 15:13:52 -07:00
Dean Lee 53d757a84f ui: fix raylib log message formatting by handing va_list arguments (#35561)
* fix raylib log message formatting by handing va_list arguments

* dd

* improve&simplify

* chadder says this works

* consis

* clean up

---------

Co-authored-by: Shane Smiskol <shane@smiskol.com>
2025-06-26 15:12:16 -07:00
Jimmy fa5fce465a ui: scroll to toggle on button press (#35604)
scroll so toggle in view when setCurrentPanel is called with param
2025-06-26 09:22:58 -07:00
Harald Schäfer d1893ee3eb OS camera calibration (#35603)
* wider narrow

* typo

* whiteapace
2025-06-25 22:08:56 -07:00
Shane Smiskol 56fca1353f raylib: scroll panel cleanup (#35599)
* no d

* we don't even use it

* use a deque

* hmm

* Revert "hmm"

This reverts commit 0203bf7214fa0145d101875006bbae2e8157d6d6.
2025-06-25 15:17:54 -07:00
Shane Smiskol a22eecd773 raylib: don't use time.time() (#35597)
Update inputbox.py
2025-06-25 14:58:06 -07:00
YassineYousfi 01b3f70c01 Vegetarian Filet o Fish model 🐟 (#35379)
* 7fcb3c70-391e-4bd6-b17d-a011b2845d06/700

* 0fc8b4da-7a24-4469-9428-ae7dcffc3c67/700

* fix

* c16e9412-c448-4589-9ff6-5362be0e9bc3/700

* b2714021-a7bc-41d6-8e1c-bfd20e59cc75/700
2025-06-25 11:18:28 -07:00
Andrei Radulescu 8b8f33f488 webcam: back to opencv (#35522)
* Revert "webcam: remove other cv2 usage (#33236)"

This reverts commit 0cade54015.

* Revert "remove cv2 usage (#33101)"

This reverts commit 144e9e271c.

* Revert "remove opencv-python-headless (#33082)"

This reverts commit 488e08507a.

* update uv.lock

* keep av bgr2nv12

* rename
2025-06-25 10:58:14 -07:00
Dean Lee d5b5383f1a ui: enable VSYNC by default (#35564)
enable VSYNC by default to fix visual artifacts on device
2025-06-25 01:02:17 -07:00
Shane Smiskol 91792aa767 build raylib: take commit (#35594)
* Update build.sh

* test

* rev
2025-06-23 16:59:41 -07:00
YassineYousfi c1e0b87059 liquid crystal model 💧❄️ (#35591)
986745e3-b382-41a7-b15a-2cdcb664d072/700
2025-06-23 13:06:15 -07:00
commaci-public 7f6f346c38 [bot] Update Python packages (#35593)
Update Python packages

Co-authored-by: Vehicle Researcher <user@comma.ai>
2025-06-23 10:17:23 -07:00
Shane Smiskol 5e3fc13751 Update TOTAL_SCONS_NODES 2025-06-20 13:46:10 -07:00
Adeeb Shihadeh 885f3f73e0 gps doesn't need to be an onroad alert anymore (#35585) 2025-06-20 11:13:34 -07:00
Maxime Desroches 2c78cfe200 update to latest userdata partition (#35582)
update userdata
2025-06-19 13:13:37 -07:00
Adeeb Shihadeh 4a4f3fce94 rm PYTHONPATH (#35579)
* rm PYTHONPATH

* still need that one for now
2025-06-19 12:36:38 -07:00
Maxime Desroches 5772683432 ci: faster process replay (#35578)
* waste

* update

* again

* we love tesla

* again again
2025-06-19 11:00:41 -07:00
Maxime Desroches 6a37d8a89e fix framereader indent 2025-06-19 10:38:26 -07:00
Harald Schäfer 87a6e369aa Framereader: minor cleanup (#35577)
* No wrapping

* unused test

* another list

* mypy

* cleaner

* Revert "cleaner"

This reverts commit ccc1446b9d649d64b20175e22a66e135c44b21e5.

* mypy
2025-06-19 09:49:51 -07:00
Harald Schäfer 5f3d876aaa model replay: framereader cache (#35576)
* Simpler cache version

* cachetools

* different LRU

* lint

* smaller

* just write LRU

* mypy

* same length
2025-06-18 16:29:22 -07:00
Adeeb Shihadeh 5f559cfcc7 make it easy to copy/paste 2025-06-18 15:50:31 -07:00
Adeeb Shihadeh 42fc89a0e5 update release checklist 2025-06-18 15:48:10 -07:00
YassineYousfi ccd55d3663 kerrygold model 🧈 (#35499)
* b92dd772-6ae6-4329-880d-7e1cc60dd9da/700

* 6a8a3da8-c264-4f91-b0a6-d04722cccfce/700

* 967279c1-7d3c-4463-9d35-58e0311a5f57/700

* flake
2025-06-18 09:21:05 -07:00
Shane Smiskol 25f5ec46d9 raylib ui: global Device class (#35573)
* device

* works

* clean up

* and

* more

* clean

* fixy

* cu

* slightly smaller
2025-06-17 19:49:02 -07:00
github-actions[bot] c460f5150f [bot] Update translations (#35565)
Update translations

Co-authored-by: Vehicle Researcher <user@comma.ai>
2025-06-16 13:30:00 -07:00
commaci-public b18037c38a [bot] Update Python packages (#35566)
Update Python packages

Co-authored-by: Vehicle Researcher <user@comma.ai>
2025-06-16 10:47:04 -07:00
programanichiro b5d5fa755f Multilang: update ja translation. (#35560)
* japanese translation

* スペース要らない。
2025-06-14 11:20:18 -07:00
Maxime Desroches f9792fe717 AGNOS 12.4 (#35558)
agnos12.4
2025-06-13 22:55:40 -07:00
Adeeb Shihadeh 03f3d6ccf1 update setup instructions 2025-06-13 16:52:08 -07:00
Adeeb Shihadeh 4eb64561f2 remove old workflow doc 2025-06-13 16:46:46 -07:00
Maxime Desroches 762f11c620 setup: warning for custom software (#35556)
* custom warn

* Update SConscript

* bump
2025-06-13 14:43:16 -07:00
Dean Lee 2a9e35609b ui: increase settings nav btn height (#35553)
increase nav btn height
2025-06-13 14:12:20 -07:00
Dean Lee 6352589902 ui: open device panel on settings click and send userFlag on flag click (#35554)
* open device panel when settings button clicked

* send userFlag on flag clicked

---------

Co-authored-by: Shane Smiskol <shane@smiskol.com>
2025-06-13 14:11:33 -07:00
commaci-public 7293a19472 [bot] Update Python packages (#35552)
Update Python packages

Co-authored-by: Vehicle Researcher <user@comma.ai>
2025-06-13 13:57:35 -07:00
github-actions[bot] f4df569064 [bot] Update translations (#35551)
Update translations

Co-authored-by: Vehicle Researcher <user@comma.ai>
2025-06-13 13:57:02 -07:00
Shane Smiskol 2706179f84 Revert "raylib ui: reduce DM drawing (#35547)" (#35557)
* Revert "raylib ui: reduce DM drawing (#35547)"

This reverts commit 7b8d6b6eb7.

* actually fix check
2025-06-13 13:56:13 -07:00
Shane Smiskol 25e123a23a raylib ui: common state update function (#35546)
* add _update_state

* nonlya

* visible already does this for us!

* do hud renderer and exp button

* temp

* this really needs some type of timer like QT

* this really needs some type of timer like QT

* todo

* use in model renderer

* Revert "use in model renderer"

This reverts commit d35f774155c9875209d06b8cd0b4849b1d8a60c4.

* no passing rect

* cl

* unused now
2025-06-12 21:22:13 -07:00
Shane Smiskol f275d6d892 raylib: log prime status failure to fetch 2025-06-12 21:05:36 -07:00
Shane Smiskol 62b301ae76 raylib ui: fix Firehose param caching (#35549)
* oof

* fixx
2025-06-12 21:04:42 -07:00
Shane Smiskol 2a1939f37a raylib: fix Firehose parsing v2 2025-06-12 20:21:01 -07:00
Shane Smiskol 7b8d6b6eb7 raylib ui: reduce DM drawing (#35547)
* reduce?

* clean up
2025-06-12 20:02:27 -07:00
Shane Smiskol e9fe40755c raylib ui: fix Firehose param loading (#35548)
fix
2025-06-12 17:44:20 -07:00
Shane Smiskol cd657f35f0 ui: update layout rects on change (#35545)
* update_layout_rects

* check prev

* about it

* need this since touch can change :(

* looks nicer

* Revert "looks nicer"

This reverts commit 8f36c92675db66695f22f93a01682426db9c05e8.
2025-06-12 16:53:09 -07:00
Shane Smiskol 98c34c4b7d Raylib: generic Widget visibility (#35543)
* generic visibility

* clean up

* fix op lint

* ? why do we care if it's None

* no need to make it too generic

* do driver state

* noise

* clean up

* draft on listview

* waiting for deanlees listview refactor - Revert "draft on listview"

This reverts commit 8ea4fa2a68361079bc79ac99e67c5cb58068daa4.

* rm demo
2025-06-12 15:23:02 -07:00
Shane Smiskol 3a10bdb1e7 Revert "ui: refactor ListView for generic widget support and simplified item architecture" (#35542)
Revert "ui: refactor ListView for generic widget support and simplified item …"

This reverts commit 32ae9efb3d.
2025-06-12 14:17:04 -07:00
Shane Smiskol 5138217673 raylib ui: store rects (#35538)
* simple version

* use it

* use it in one place
2025-06-12 14:11:11 -07:00
Dean Lee 32ae9efb3d ui: refactor ListView for generic widget support and simplified item architecture (#35536)
refactor list view

apply reviews
2025-06-12 08:55:13 -07:00
Dean Lee 723a52626d ui: simple HTML parser for regulatory Views (#35525)
* simple HTML parser for regulatory Views

* format

---------

Co-authored-by: Shane Smiskol <shane@smiskol.com>
2025-06-11 21:52:34 -07:00
Dean Lee f3d0a9ea13 ui: fix QR code refresh tracking in pairing dialog (#35529)
fix QR code refresh tracking

Co-authored-by: Shane Smiskol <shane@smiskol.com>
2025-06-11 21:36:24 -07:00
Dean Lee 9d8e4acec9 ui: setup widget->firehose settings navigation (#35531)
* setup widget->firehose settings navigation

* cleanup

---------

Co-authored-by: Shane Smiskol <shane@smiskol.com>
2025-06-11 21:21:59 -07:00
Dean Lee 79319d2447 ui: add driving personality selector to settings (#35524)
* Add driving personality selector to settings

* icon

* format

* type

---------

Co-authored-by: Shane Smiskol <shane@smiskol.com>
2025-06-11 21:18:07 -07:00
commaci-public 58763f4551 [bot] Update Python packages (#35535)
* Update Python packages

* fix xdist issue

* cmt

---------

Co-authored-by: Vehicle Researcher <user@comma.ai>
Co-authored-by: Shane Smiskol <shane@smiskol.com>
2025-06-11 20:28:48 -07:00
Shane Smiskol fcebb5eb9f fix repo maintenance (#35534)
* ?

* . can't be used since then it will be modeld folder
2025-06-11 17:31:36 -07:00
Shane Smiskol f7ce5fb94c Remove extra newlines in translations 2025-06-11 17:27:41 -07:00
Shane Smiskol 1562b88f63 Move format_fingerprints.py to opendbc (#35532)
* mv

* format fingerprints

* fixx

* no cereal

* bump
2025-06-11 15:30:45 -07:00
Dean Lee 3d987cb9b5 ui: fix wrong dash character (#35530)
Fix wrong dash character
2025-06-11 11:26:14 -07:00
Shane Smiskol e345f25ce4 lagd calib: hide on release (#35523)
* hide on release

* pull out
2025-06-10 16:16:05 -07:00
Dean Lee 03d2e7b2b0 ui: extract Widget base class to separate lib/widget.py (#35520)
* extract Widget base class to separate lib/widget.py

* format

* format

---------

Co-authored-by: Shane Smiskol <shane@smiskol.com>
2025-06-10 14:32:20 -07:00
Dean Lee 5ebbb46fdf ui: increase drag threshold to 12 pixels (#35521)
increase drag threshold to 12 pixels
2025-06-10 10:12:06 -07:00
Dean Lee 2017bf970f ui: implement ssh key control (#35518)
implement ssh key control
2025-06-10 01:49:47 -07:00
Shane Smiskol c1794e6f83 ui: expose lateral control learning state (#35519)
* add lagd

* add live torque params

* clean up

* too many openpilot is's

* add back

* fix weird pattern causing segfault

* cu

* 10 more lines for "all complete"

* Revert "10 more lines for "all complete""

This reverts commit de1ad0b7386f4c5d9967ea733edbe5bf1df5039c.

* one line
2025-06-10 01:48:46 -07:00
Shane Smiskol a9e8649137 ui: update calibration description when resetting 2025-06-10 01:41:53 -07:00
Shane Smiskol bfa3f3cccb Add calPerc progress tracking for torque calibration (#35512)
* Add calPerc field and torque progress

* Fix torqued test style and CarParams usage

* test: remove unused numpy import

* move here

* trying all combinations to see what's most linear

* clean up with best method

* no no

* epic

* clean up

* last min not needed

* doesn't hurt

* list comp
2025-06-10 00:22:13 -07:00
Harald Schäfer d9b6c16037 Cleanup framereader (#35513)
* squash

* misc cleanup

* no LLM garbage

* misc fixes

* typo

* fix CI

* fix hints

* LLM soo wordy

* improve
2025-06-09 22:39:35 -07:00
Shane Smiskol 75b6ec68c6 Add lagd calibration percentage (#35511)
* lagd: publish calibration percentage

* Refine lagd calibration progress

* stash

* cleanup (match calibrationd calculation logic)

* no no no

* nor

* two lines
2025-06-09 22:10:47 -07:00
Dean Lee 1c11e28448 ui: fix path self-intersections on hills (#35514)
fix path self-intersections on hills
2025-06-09 21:29:25 -07:00
eFini 14166c980e Multilang: update zh translation (#35516) 2025-06-09 20:32:57 -07:00
Dean Lee 61b8f6f478 ui: Implement core device settings functionality with enhanced dynamic controls (#35507)
* implement device settings functionality with power controls

* format

* Update selfdrive/ui/layouts/settings/device.py

Co-authored-by: Shane Smiskol <shane@smiskol.com>

* Update selfdrive/ui/layouts/settings/device.py

Co-authored-by: Shane Smiskol <shane@smiskol.com>

* add comment back

* add comments back

---------

Co-authored-by: Shane Smiskol <shane@smiskol.com>
2025-06-09 14:45:29 -07:00
programanichiro d3b300a148 Multilang: update ja translation. (#35506)
* ja translation

* 文言調整。
2025-06-09 14:43:50 -07:00
Dean Lee ffb677b53d ui: [fix] only show driver state icon when no alert is display (#35508)
only show driver state icon when no alert is display
2025-06-09 11:36:45 -07:00
Dean Lee fc27423ac2 ui: fix Immediate ALERT_STARTUP_PENDING after going onroad (#35509)
fix timeout alerts could appear immediately after going onroad
2025-06-09 11:35:19 -07:00
Dean Lee 08aeeabc9b ui: add FirehoseLayout to settings (#35505)
add FirehoseLayout
2025-06-09 09:56:56 -07:00
Dean Lee e015e319b7 ui: [fix] remove unused gui_label import in HomeLayout (#35510)
fix lint issue
2025-06-09 09:53:13 -07:00
Dean Lee 41db89afdc ui: add setup widget to handing device pairing and firehose mode prompt (#35503)
* add setup widget to handing device pairing and firehose mode prompt

* format

---------

Co-authored-by: Shane Smiskol <shane@smiskol.com>
2025-06-08 22:19:36 -07:00
Shane Smiskol f70592b7e9 raylib: format from today's prs 2025-06-08 22:16:45 -07:00
Shane Smiskol 9153f97900 PrimeWidget: redeclaring __init__ unneeded
for https://github.com/commaai/openpilot/pull/35496
2025-06-08 22:07:46 -07:00
Dean Lee 7b4e2e2430 ui: add ExperimentalModeButton to the home layout for toggling between driving modes (#35504)
add ExpermentalModeButton
2025-06-08 21:55:30 -07:00
Dean Lee 9a1e58102d ui: display subscription status based on prime state (#35502)
display subscription status based on PrimeState
2025-06-08 20:55:38 -07:00
Dean Lee 5df875390f ui: add pairing device dialog (#35501)
* add pairing device dialog

* refreshing QR code every 5 minutes

* fix lint issues

* int
2025-06-08 20:15:17 -07:00
Dean Lee 0e2f69883b ui: implement uninstall software in settings (#35494)
* implement uninstall software in SoftwareLayout

* use enum
2025-06-08 20:08:11 -07:00
Dean Lee f824e6c0ec ui: implement reset calibration feature in device settings (#35479)
* implement reset calibration feature in device settings

* check confirm dialog result

* fix null check

* use enum

* use enum
2025-06-08 20:08:01 -07:00
Dean Lee 191d0d429e ui: enhanced ListView with improved actions, dynamic content, and better UX (#35485)
improve list view
2025-06-08 19:31:55 -07:00
Dean Lee af48d23a68 ui: add PrimeState class (#35497)
* add PrimeState

* move to lib
2025-06-08 19:22:32 -07:00
Dean Lee 0c6856cf03 ui: implement driver camera preview in settings (#35480)
* implement driver camera preview in settings

rebase master

* rename to dialog
2025-06-08 19:22:23 -07:00
Dean Lee e93a7234bc pyui: add DialogResult enum (#35500)
add DialogResult enum
2025-06-08 19:12:56 -07:00
Dean Lee ce93a7215d add qrcode python package (#35498)
* add qrcode python package

* lock

* relock

---------

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
2025-06-08 13:15:15 -07:00
Dean Lee f0f249ecf8 ui: implement change language in settings (#35481)
implement change language in settings
2025-06-08 13:13:01 -07:00
Dean Lee a3daca8fd5 ui: implement PrimeAdWidget (#35496)
implement PrimeAdWidget
2025-06-08 13:05:30 -07:00
Adeeb Shihadeh 6d09b2405e raylib: fix shaders on macOS (#35411)
* fix shaders

* runs now
2025-06-07 20:37:43 -07:00
Shane Smiskol 8220599dd8 raylib: onroad callback setter (#35493)
* onroad callback setter

* fix name
2025-06-06 23:18:06 -07:00
Shane Smiskol 7c5155590f raylib: simpler callbacks (#35488)
* simpler no current callback

* clean up

* back

* fixx

* clean up
2025-06-06 23:10:34 -07:00
Shane Smiskol e0a2a7af64 raylib: use consistent mouse button constant 2025-06-06 23:08:24 -07:00
Shane Smiskol 9a2ec552f1 raylib toggles: on mouse release 2025-06-06 23:05:51 -07:00
Shane Smiskol 2c59b5f8c6 raylib: common mouse press hook (#35489)
* something like this

* need these

* rest

* another pr

* what is this merge conflict

f

* fix mouse down

* rm that!

* fix that

* rearrange

* fix bug where mouse held down on widget, dragged off, then let go

* temp

* fix that

* missing init
2025-06-06 23:00:55 -07:00
Shane Smiskol db5e413049 Experimental button should be raylib widget (#35491)
should be widget
2025-06-06 22:53:44 -07:00
Dean Lee 2031a33188 ui: add experimental mode toggle button with visual indicator (#35446)
* add experimental mode toggle button with visual indicator

* merge master

* implement a temporary state hold after mouse click"

* move to seperate class

---------

Co-authored-by: Shane Smiskol <shane@smiskol.com>
2025-06-06 22:14:18 -07:00
Shane Smiskol 7875cc4713 raylib: consistent use of rect in render function (#35490)
* updater: use rect

* spinner

* and text

* better name

* simple

* also simple
2025-06-06 22:11:41 -07:00
Dean Lee c3aa7cffed ui: add auto camera switching based on speed in experimental mode (#35437)
* add auto camera switching based on speed in experimental mode

* fix conflit
2025-06-06 21:10:51 -07:00
Shane Smiskol c145de96f9 raylib: match QT UI panel order (#35487)
match QT UI panel order
2025-06-06 20:21:11 -07:00
Shane Smiskol 4bbbf51236 Fix raylib issue (#35486)
* ugh fix that

* our linting is trash
2025-06-06 20:20:58 -07:00
Shane Smiskol 3ce87d0ac9 raylib: base widget class (#35484)
* use some widgets

* consistent name draw -> render

* more

* rest
2025-06-06 19:32:03 -07:00
Shane Smiskol a1ee5f5ba8 raylib spinner: temp fix crash 2025-06-06 17:18:50 -07:00
Shane Smiskol 29830440b4 format raylib (#35483)
* format raylib

* not really sure what this is
2025-06-06 15:43:47 -07:00
Dean Lee 541bd4d4d9 ui: switch spinner and text window back to standalone process (#35470)
switch spinner and text window back to standalone process
2025-06-06 13:20:05 -07:00
Dean Lee 6767bfce44 ui: add ModalOverlay system for unified modal dialog management (#35478)
add ModalOverlay
2025-06-06 13:18:07 -07:00
Adeeb Shihadeh 75434b10b9 add that back 2025-06-06 13:13:30 -07:00
Adeeb Shihadeh 63e7a0ca15 Revert "feat: remove esim.nmconnection, use AGNOS lte conn (#35389)"
This reverts commit 255b606fe4.
2025-06-06 13:09:09 -07:00
ZwX1616 ba2d2677c1 modeld: no hardcoded frame names (#35476)
* from model

* juggle
2025-06-06 13:05:54 -07:00
Dean Lee e389b19ed7 system/ui: fix setup error: 'WifiManagerWrapper' object has no attribute 'request_scan' (#35477)
remove request_scan()
2025-06-06 11:24:41 -07:00
124 changed files with 4992 additions and 3700 deletions
+13
View File
@@ -18,6 +18,19 @@
venv/ venv/
.venv/ .venv/
**/.idea
**/.hypothesis
**/.mypy_cache
**/.venv
**/.venv/
**/.ci_cache
**/*.rlog
**/Dockerfile*
**/dockerfile*
**/build_output
notebooks notebooks
phone phone
+2 -1
View File
@@ -54,8 +54,9 @@ jobs:
git add . git add .
- name: update car docs - name: update car docs
run: | run: |
export PYTHONPATH="$PWD"
scons -j$(nproc) --minimal opendbc_repo scons -j$(nproc) --minimal opendbc_repo
PYTHONPATH=. python selfdrive/car/docs.py python selfdrive/car/docs.py
git add docs/CARS.md git add docs/CARS.md
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@9153d834b60caba6d51c9b9510b087acf9f33f83 uses: peter-evans/create-pull-request@9153d834b60caba6d51c9b9510b087acf9f33f83
-1
View File
@@ -3,7 +3,6 @@ FROM ghcr.io/commaai/openpilot-base:latest
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV OPENPILOT_PATH=/home/batman/openpilot ENV OPENPILOT_PATH=/home/batman/openpilot
ENV PYTHONPATH=${OPENPILOT_PATH}:${PYTHONPATH}
RUN mkdir -p ${OPENPILOT_PATH} RUN mkdir -p ${OPENPILOT_PATH}
WORKDIR ${OPENPILOT_PATH} WORKDIR ${OPENPILOT_PATH}
+66
View File
@@ -0,0 +1,66 @@
FROM sunnypilot-base
ARG RUNNER_DEBUG=0
ENV PYTHONUNBUFFERED=1
ENV OPENPILOT_SRC_PATH=/tmp/openpilot
ENV BUILD_DIR=/data/openpilot
ENV OUTPUT_DIR=/output
RUN sudo apt update && sudo apt install -y rsync
RUN mkdir -p ${OPENPILOT_SRC_PATH}
RUN mkdir -p ${BUILD_DIR}
COPY . ${OPENPILOT_SRC_PATH}
ENV PYTHONPATH=${BUILD_DIR}
WORKDIR ${OPENPILOT_SRC_PATH}
RUN ./tools/ubuntu_setup.sh
RUN ./release/release_files.py | sort | uniq | rsync -rRl${RUNNER_DEBUG:+v} --files-from=- . $BUILD_DIR/
WORKDIR ${BUILD_DIR}
RUN sed -i '/from .board.jungle import PandaJungle, PandaJungleDFU/s/^/#/' panda/__init__.py
RUN scons --cache-readonly -j$(nproc) --minimal
RUN touch ${BUILD_DIR}/prebuilt
RUN sudo rm -rf ${OUTPUT_DIR}
RUN mkdir -p ${OUTPUT_DIR}
ENTRYPOINT [\
"rsync", \
"-am", \
"--include=**/panda/board/", \
"--include=**/panda/board/obj", \
"--include=**/panda/board/obj/panda.bin.signed", \
"--include=**/panda/board/obj/panda_h7.bin.signed", \
"--include=**/panda/board/obj/bootstub.panda.bin", \
"--include=**/panda/board/obj/bootstub.panda_h7.bin", \
"--exclude=.sconsign.dblite", \
"--exclude=*.a", \
"--exclude=*.o", \
"--exclude=*.os", \
"--exclude=*.pyc", \
"--exclude=moc_*", \
"--exclude=*.cc", \
"--exclude=Jenkinsfile", \
"--exclude=supercombo.onnx", \
"--exclude=**/panda/board/*", \
"--exclude=**/panda/board/obj/**", \
"--exclude=**/panda/certs/", \
"--exclude=**/panda/crypto/", \
"--exclude=**/release/", \
"--exclude=**/.github/", \
"--exclude=**/selfdrive/ui/replay/", \
"--exclude=**/__pycache__/", \
"--exclude=**/selfdrive/ui/*.h", \
"--exclude=**/selfdrive/ui/**/*.h", \
"--exclude=**/selfdrive/ui/qt/offroad/sunnypilot/", \
#"--exclude=${SCONS_CACHE_DIR:-}", \
"--exclude=**/.git/", \
"--exclude=**/SConstruct", \
"--exclude=**/SConscript", \
"--exclude=**/.venv/", \
"--delete-excluded", \
"--chown=1000:1000", \
"/data/openpilot/", \
"/output/" \
]
+2
View File
@@ -2281,6 +2281,7 @@ struct LiveTorqueParametersData {
points @10 :List(List(Float32)); points @10 :List(List(Float32));
version @11 :Int32; version @11 :Int32;
useParams @12 :Bool; useParams @12 :Bool;
calPerc @13 :Int8;
} }
struct LiveDelayData { struct LiveDelayData {
@@ -2291,6 +2292,7 @@ struct LiveDelayData {
lateralDelayEstimate @3 :Float32; lateralDelayEstimate @3 :Float32;
lateralDelayEstimateStd @5 :Float32; lateralDelayEstimateStd @5 :Float32;
points @4 :List(Float32); points @4 :List(Float32);
calPerc @6 :Int8;
enum Status { enum Status {
unestimated @0; unestimated @0;
+1 -1
View File
@@ -1 +1 @@
#define DEFAULT_MODEL "Filet o Fish (Default)" #define DEFAULT_MODEL "Vegetarian Filet o Fish (Default)"
+52
View File
@@ -0,0 +1,52 @@
import os
import subprocess
from openpilot.common.basedir import BASEDIR
class Spinner:
def __init__(self):
try:
self.spinner_proc = subprocess.Popen(["./spinner.py"],
stdin=subprocess.PIPE,
cwd=os.path.join(BASEDIR, "system", "ui"),
close_fds=True)
except OSError:
self.spinner_proc = None
def __enter__(self):
return self
def update(self, spinner_text: str):
if self.spinner_proc is not None:
self.spinner_proc.stdin.write(spinner_text.encode('utf8') + b"\n")
try:
self.spinner_proc.stdin.flush()
except BrokenPipeError:
pass
def update_progress(self, cur: float, total: float):
self.update(str(round(100 * cur / total)))
def close(self):
if self.spinner_proc is not None:
self.spinner_proc.kill()
try:
self.spinner_proc.communicate(timeout=2.)
except subprocess.TimeoutExpired:
print("WARNING: failed to kill spinner")
self.spinner_proc = None
def __del__(self):
self.close()
def __exit__(self, exc_type, exc_value, traceback):
self.close()
if __name__ == "__main__":
import time
with Spinner() as s:
s.update("Spinner text")
time.sleep(5.0)
print("gone")
time.sleep(5.0)
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
import os
import time
import subprocess
from openpilot.common.basedir import BASEDIR
class TextWindow:
def __init__(self, text):
try:
self.text_proc = subprocess.Popen(["./text.py", text],
stdin=subprocess.PIPE,
cwd=os.path.join(BASEDIR, "system", "ui"),
close_fds=True)
except OSError:
self.text_proc = None
def get_status(self):
if self.text_proc is not None:
self.text_proc.poll()
return self.text_proc.returncode
return None
def __enter__(self):
return self
def close(self):
if self.text_proc is not None:
self.text_proc.terminate()
self.text_proc = None
def wait_for_exit(self):
if self.text_proc is not None:
while True:
if self.get_status() == 1:
return
time.sleep(0.1)
def __del__(self):
self.close()
def __exit__(self, exc_type, exc_value, traceback):
self.close()
if __name__ == "__main__":
text = """Traceback (most recent call last):
File "./controlsd.py", line 608, in <module>
main()
File "./controlsd.py", line 604, in main
controlsd_thread(sm, pm, logcan)
File "./controlsd.py", line 455, in controlsd_thread
1/0
ZeroDivisionError: division by zero"""
print(text)
with TextWindow(text) as s:
for _ in range(100):
if s.get_status() == 1:
print("Got exit button")
break
time.sleep(0.1)
print("gone")
+8 -8
View File
@@ -15,7 +15,7 @@ A supported vehicle is one that just works when you install a comma device. All
|Audi|A3 2014-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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Audi&model=A3 2014-19">Buy Here</a></sub></details>||| |Audi|A3 2014-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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Audi&model=A3 2014-19">Buy Here</a></sub></details>|||
|Audi|A3 Sportback e-tron 2017-18|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Audi&model=A3 Sportback e-tron 2017-18">Buy Here</a></sub></details>||| |Audi|A3 Sportback e-tron 2017-18|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Audi&model=A3 Sportback e-tron 2017-18">Buy Here</a></sub></details>|||
|Audi|Q2 2018|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Audi&model=Q2 2018">Buy Here</a></sub></details>||| |Audi|Q2 2018|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Audi&model=Q2 2018">Buy Here</a></sub></details>|||
|Audi|Q3 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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Audi&model=Q3 2019-23">Buy Here</a></sub></details>||| |Audi|Q3 2019-24|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Audi&model=Q3 2019-24">Buy Here</a></sub></details>|||
|Audi|RS3 2018|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Audi&model=RS3 2018">Buy Here</a></sub></details>||| |Audi|RS3 2018|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Audi&model=RS3 2018">Buy Here</a></sub></details>|||
|Audi|S3 2015-17|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Audi&model=S3 2015-17">Buy Here</a></sub></details>||| |Audi|S3 2015-17|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Audi&model=S3 2015-17">Buy Here</a></sub></details>|||
|Chevrolet|Bolt EUV 2022-23|Premier or Premier Redline Trim without Super Cruise Package|openpilot available[<sup>1</sup>](#footnotes)|3 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 GM connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Chevrolet&model=Bolt EUV 2022-23">Buy Here</a></sub></details>|<a href="https://youtu.be/xvwzGMUA210" target="_blank"><img height="18px" src="assets/icon-youtube.svg"></img></a>|| |Chevrolet|Bolt EUV 2022-23|Premier or Premier Redline Trim without Super Cruise Package|openpilot available[<sup>1</sup>](#footnotes)|3 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 GM connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Chevrolet&model=Bolt EUV 2022-23">Buy Here</a></sub></details>|<a href="https://youtu.be/xvwzGMUA210" target="_blank"><img height="18px" src="assets/icon-youtube.svg"></img></a>||
@@ -38,6 +38,7 @@ A supported vehicle is one that just works when you install a comma device. All
|Ford|Escape Hybrid 2023-24|Co-Pilot360 Assist+|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 Ford Q4 connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Escape Hybrid 2023-24">Buy Here</a></sub></details>||https://www.youtube.com/watch?v=uUGkH6C_EQU| |Ford|Escape Hybrid 2023-24|Co-Pilot360 Assist+|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 Ford Q4 connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Escape Hybrid 2023-24">Buy Here</a></sub></details>||https://www.youtube.com/watch?v=uUGkH6C_EQU|
|Ford|Escape Plug-in Hybrid 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 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Escape Plug-in Hybrid 2020-22">Buy Here</a></sub></details>||| |Ford|Escape Plug-in Hybrid 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 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Escape Plug-in Hybrid 2020-22">Buy Here</a></sub></details>|||
|Ford|Escape Plug-in Hybrid 2023-24|Co-Pilot360 Assist+|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 Ford Q4 connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Escape Plug-in Hybrid 2023-24">Buy Here</a></sub></details>||https://www.youtube.com/watch?v=uUGkH6C_EQU| |Ford|Escape Plug-in Hybrid 2023-24|Co-Pilot360 Assist+|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 Ford Q4 connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Escape Plug-in Hybrid 2023-24">Buy Here</a></sub></details>||https://www.youtube.com/watch?v=uUGkH6C_EQU|
|Ford|Expedition 2022-24|Co-Pilot360 Assist 2.0|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 Ford Q4 connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Expedition 2022-24">Buy Here</a></sub></details>||https://www.youtube.com/watch?v=MewJc9LYp9M|
|Ford|Explorer 2020-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 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Explorer 2020-24">Buy Here</a></sub></details>||| |Ford|Explorer 2020-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 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Explorer 2020-24">Buy Here</a></sub></details>|||
|Ford|Explorer Hybrid 2020-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 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Explorer Hybrid 2020-24">Buy Here</a></sub></details>||| |Ford|Explorer Hybrid 2020-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 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Explorer Hybrid 2020-24">Buy Here</a></sub></details>|||
|Ford|F-150 2021-23|Co-Pilot360 Assist 2.0|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 Ford Q4 connector<br>- 1 USB-C coupler<br>- 1 angled mount (8 degrees)<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=F-150 2021-23">Buy Here</a></sub></details>||https://www.youtube.com/watch?v=MewJc9LYp9M| |Ford|F-150 2021-23|Co-Pilot360 Assist 2.0|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 Ford Q4 connector<br>- 1 USB-C coupler<br>- 1 angled mount (8 degrees)<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=F-150 2021-23">Buy Here</a></sub></details>||https://www.youtube.com/watch?v=MewJc9LYp9M|
@@ -53,7 +54,7 @@ A supported vehicle is one that just works when you install a comma device. All
|Ford|Maverick 2023-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 angled mount (8 degrees)<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Maverick 2023-24">Buy Here</a></sub></details>||| |Ford|Maverick 2023-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 angled mount (8 degrees)<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Maverick 2023-24">Buy Here</a></sub></details>|||
|Ford|Maverick Hybrid 2022|LARIAT Luxury|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 angled mount (8 degrees)<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Maverick Hybrid 2022">Buy Here</a></sub></details>||| |Ford|Maverick Hybrid 2022|LARIAT Luxury|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 angled mount (8 degrees)<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Maverick Hybrid 2022">Buy Here</a></sub></details>|||
|Ford|Maverick Hybrid 2023-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 angled mount (8 degrees)<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Maverick Hybrid 2023-24">Buy Here</a></sub></details>||| |Ford|Maverick Hybrid 2023-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 angled mount (8 degrees)<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Maverick Hybrid 2023-24">Buy Here</a></sub></details>|||
|Ford|Mustang Mach-E 2021-23|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 Ford Q4 connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Mustang Mach-E 2021-23">Buy Here</a></sub></details>||https://www.youtube.com/watch?v=uUGkH6C_EQU| |Ford|Mustang Mach-E 2021-24|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 Ford Q4 connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Mustang Mach-E 2021-24">Buy Here</a></sub></details>||https://www.youtube.com/watch?v=uUGkH6C_EQU|
|Ford|Ranger 2024|Adaptive Cruise Control with Lane Centering|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 Ford Q4 connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Ranger 2024">Buy Here</a></sub></details>||https://www.youtube.com/watch?v=uUGkH6C_EQU| |Ford|Ranger 2024|Adaptive Cruise Control with Lane Centering|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 Ford Q4 connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Ford&model=Ranger 2024">Buy Here</a></sub></details>||https://www.youtube.com/watch?v=uUGkH6C_EQU|
|Genesis|G70 2018|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Hyundai F connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Genesis&model=G70 2018">Buy Here</a></sub></details>||| |Genesis|G70 2018|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Hyundai F connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Genesis&model=G70 2018">Buy Here</a></sub></details>|||
|Genesis|G70 2019-21|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 Hyundai F connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Genesis&model=G70 2019-21">Buy Here</a></sub></details>||| |Genesis|G70 2019-21|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 Hyundai F connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Genesis&model=G70 2019-21">Buy Here</a></sub></details>|||
@@ -77,8 +78,8 @@ A supported vehicle is one that just works when you install a comma device. All
|Honda|Civic 2022-24|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 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=Civic 2022-24">Buy Here</a></sub></details>|<a href="https://youtu.be/ytiOT5lcp6Q" target="_blank"><img height="18px" src="assets/icon-youtube.svg"></img></a>|| |Honda|Civic 2022-24|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 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=Civic 2022-24">Buy Here</a></sub></details>|<a href="https://youtu.be/ytiOT5lcp6Q" target="_blank"><img height="18px" src="assets/icon-youtube.svg"></img></a>||
|Honda|Civic Hatchback 2017-21|Honda Sensing|openpilot available[<sup>1</sup>](#footnotes)|0 mph|12 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 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=Civic Hatchback 2017-21">Buy Here</a></sub></details>||| |Honda|Civic Hatchback 2017-21|Honda Sensing|openpilot available[<sup>1</sup>](#footnotes)|0 mph|12 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 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=Civic Hatchback 2017-21">Buy Here</a></sub></details>|||
|Honda|Civic Hatchback 2022-24|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 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=Civic Hatchback 2022-24">Buy Here</a></sub></details>|<a href="https://youtu.be/ytiOT5lcp6Q" target="_blank"><img height="18px" src="assets/icon-youtube.svg"></img></a>|| |Honda|Civic Hatchback 2022-24|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 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=Civic Hatchback 2022-24">Buy Here</a></sub></details>|<a href="https://youtu.be/ytiOT5lcp6Q" target="_blank"><img height="18px" src="assets/icon-youtube.svg"></img></a>||
|Honda|Civic Hatchback Hybrid 2023 (Europe only)|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 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=Civic Hatchback Hybrid 2023 (Europe only)">Buy Here</a></sub></details>|||
|Honda|Civic Hatchback Hybrid 2025|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 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=Civic Hatchback Hybrid 2025">Buy Here</a></sub></details>||| |Honda|Civic Hatchback Hybrid 2025|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 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=Civic Hatchback Hybrid 2025">Buy Here</a></sub></details>|||
|Honda|Civic Hatchback Hybrid (Europe only) 2023|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 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=Civic Hatchback Hybrid (Europe only) 2023">Buy Here</a></sub></details>|||
|Honda|CR-V 2015-16|Touring Trim|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 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=CR-V 2015-16">Buy Here</a></sub></details>||| |Honda|CR-V 2015-16|Touring Trim|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 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=CR-V 2015-16">Buy Here</a></sub></details>|||
|Honda|CR-V 2017-22|Honda Sensing|openpilot available[<sup>1</sup>](#footnotes)|0 mph|12 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 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=CR-V 2017-22">Buy Here</a></sub></details>||| |Honda|CR-V 2017-22|Honda Sensing|openpilot available[<sup>1</sup>](#footnotes)|0 mph|12 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 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=CR-V 2017-22">Buy Here</a></sub></details>|||
|Honda|CR-V Hybrid 2017-22|Honda Sensing|openpilot available[<sup>1</sup>](#footnotes)|0 mph|12 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 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=CR-V Hybrid 2017-22">Buy Here</a></sub></details>||| |Honda|CR-V Hybrid 2017-22|Honda Sensing|openpilot available[<sup>1</sup>](#footnotes)|0 mph|12 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 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Honda&model=CR-V Hybrid 2017-22">Buy Here</a></sub></details>|||
@@ -115,7 +116,6 @@ A supported vehicle is one that just works when you install a comma device. All
|Hyundai|Ioniq Plug-in Hybrid 2019|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Hyundai C connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Hyundai&model=Ioniq Plug-in Hybrid 2019">Buy Here</a></sub></details>||| |Hyundai|Ioniq Plug-in Hybrid 2019|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Hyundai C connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Hyundai&model=Ioniq Plug-in Hybrid 2019">Buy Here</a></sub></details>|||
|Hyundai|Ioniq Plug-in Hybrid 2020-22|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 Hyundai H connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Hyundai&model=Ioniq Plug-in Hybrid 2020-22">Buy Here</a></sub></details>||| |Hyundai|Ioniq Plug-in Hybrid 2020-22|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 Hyundai H connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Hyundai&model=Ioniq Plug-in Hybrid 2020-22">Buy Here</a></sub></details>|||
|Hyundai|Kona 2020|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|6 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Hyundai B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Hyundai&model=Kona 2020">Buy Here</a></sub></details>||| |Hyundai|Kona 2020|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|6 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Hyundai B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Hyundai&model=Kona 2020">Buy Here</a></sub></details>|||
|Hyundai|Kona 2022|Smart Cruise Control (SCC)|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 Hyundai O connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Hyundai&model=Kona 2022">Buy Here</a></sub></details>|||
|Hyundai|Kona Electric 2018-21|Smart Cruise Control (SCC)|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 Hyundai G connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Hyundai&model=Kona Electric 2018-21">Buy Here</a></sub></details>||| |Hyundai|Kona Electric 2018-21|Smart Cruise Control (SCC)|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 Hyundai G connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Hyundai&model=Kona Electric 2018-21">Buy Here</a></sub></details>|||
|Hyundai|Kona Electric 2022-23|Smart Cruise Control (SCC)|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 Hyundai O connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Hyundai&model=Kona Electric 2022-23">Buy Here</a></sub></details>||| |Hyundai|Kona Electric 2022-23|Smart Cruise Control (SCC)|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 Hyundai O connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Hyundai&model=Kona Electric 2022-23">Buy Here</a></sub></details>|||
|Hyundai|Kona Electric (with HDA II, Korea only) 2023[<sup>6</sup>](#footnotes)|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Hyundai R connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Hyundai&model=Kona Electric (with HDA II, Korea only) 2023">Buy Here</a></sub></details>|<a href="https://www.youtube.com/watch?v=U2fOCmcQ8hw" target="_blank"><img height="18px" src="assets/icon-youtube.svg"></img></a>|| |Hyundai|Kona Electric (with HDA II, Korea only) 2023[<sup>6</sup>](#footnotes)|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Hyundai R connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Hyundai&model=Kona Electric (with HDA II, Korea only) 2023">Buy Here</a></sub></details>|<a href="https://www.youtube.com/watch?v=U2fOCmcQ8hw" target="_blank"><img height="18px" src="assets/icon-youtube.svg"></img></a>||
@@ -181,12 +181,12 @@ A supported vehicle is one that just works when you install a comma device. All
|Kia|Telluride 2020-22|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 Hyundai H connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Kia&model=Telluride 2020-22">Buy Here</a></sub></details>||| |Kia|Telluride 2020-22|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 Hyundai H connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Kia&model=Telluride 2020-22">Buy Here</a></sub></details>|||
|Lexus|CT Hybrid 2017-18|Lexus Safety System+|openpilot available[<sup>2</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=CT Hybrid 2017-18">Buy Here</a></sub></details>||| |Lexus|CT Hybrid 2017-18|Lexus Safety System+|openpilot available[<sup>2</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=CT Hybrid 2017-18">Buy Here</a></sub></details>|||
|Lexus|ES 2017-18|All|openpilot available[<sup>2</sup>](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=ES 2017-18">Buy Here</a></sub></details>||| |Lexus|ES 2017-18|All|openpilot available[<sup>2</sup>](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=ES 2017-18">Buy Here</a></sub></details>|||
|Lexus|ES 2019-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=ES 2019-24">Buy Here</a></sub></details>||| |Lexus|ES 2019-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=ES 2019-25">Buy Here</a></sub></details>|||
|Lexus|ES Hybrid 2017-18|All|openpilot available[<sup>2</sup>](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=ES Hybrid 2017-18">Buy Here</a></sub></details>||| |Lexus|ES Hybrid 2017-18|All|openpilot available[<sup>2</sup>](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=ES Hybrid 2017-18">Buy Here</a></sub></details>|||
|Lexus|ES Hybrid 2019-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=ES Hybrid 2019-25">Buy Here</a></sub></details>|<a href="https://youtu.be/BZ29osRVJeg?t=12" target="_blank"><img height="18px" src="assets/icon-youtube.svg"></img></a>|| |Lexus|ES Hybrid 2019-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=ES Hybrid 2019-25">Buy Here</a></sub></details>|<a href="https://youtu.be/BZ29osRVJeg?t=12" target="_blank"><img height="18px" src="assets/icon-youtube.svg"></img></a>||
|Lexus|GS F 2016|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=GS F 2016">Buy Here</a></sub></details>||| |Lexus|GS F 2016|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=GS F 2016">Buy Here</a></sub></details>|||
|Lexus|IS 2017-19|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=IS 2017-19">Buy Here</a></sub></details>||| |Lexus|IS 2017-19|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=IS 2017-19">Buy Here</a></sub></details>|||
|Lexus|IS 2022-23|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=IS 2022-23">Buy Here</a></sub></details>||| |Lexus|IS 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=IS 2022-24">Buy Here</a></sub></details>|||
|Lexus|LC 2024|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=LC 2024">Buy Here</a></sub></details>||| |Lexus|LC 2024|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=LC 2024">Buy Here</a></sub></details>|||
|Lexus|NX 2018-19|All|openpilot available[<sup>2</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=NX 2018-19">Buy Here</a></sub></details>||| |Lexus|NX 2018-19|All|openpilot available[<sup>2</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=NX 2018-19">Buy Here</a></sub></details>|||
|Lexus|NX 2020-21|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=NX 2020-21">Buy Here</a></sub></details>||| |Lexus|NX 2020-21|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Lexus&model=NX 2020-21">Buy Here</a></sub></details>|||
@@ -313,11 +313,11 @@ A supported vehicle is one that just works when you install a comma device. All
|Volkswagen|Polo GTI 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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Polo GTI 2018-23">Buy Here</a></sub></details>[<sup>17</sup>](#footnotes)||| |Volkswagen|Polo GTI 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Polo GTI 2018-23">Buy Here</a></sub></details>[<sup>17</sup>](#footnotes)|||
|Volkswagen|T-Cross 2021|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=T-Cross 2021">Buy Here</a></sub></details>[<sup>17</sup>](#footnotes)||| |Volkswagen|T-Cross 2021|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=T-Cross 2021">Buy Here</a></sub></details>[<sup>17</sup>](#footnotes)|||
|Volkswagen|T-Roc 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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=T-Roc 2018-23">Buy Here</a></sub></details>||| |Volkswagen|T-Roc 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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=T-Roc 2018-23">Buy Here</a></sub></details>|||
|Volkswagen|Taos 2022-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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Taos 2022-23">Buy Here</a></sub></details>||| |Volkswagen|Taos 2022-24|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Taos 2022-24">Buy Here</a></sub></details>|||
|Volkswagen|Teramont 2018-22|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Teramont 2018-22">Buy Here</a></sub></details>||| |Volkswagen|Teramont 2018-22|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Teramont 2018-22">Buy Here</a></sub></details>|||
|Volkswagen|Teramont Cross Sport 2021-22|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Teramont Cross Sport 2021-22">Buy Here</a></sub></details>||| |Volkswagen|Teramont Cross Sport 2021-22|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Teramont Cross Sport 2021-22">Buy Here</a></sub></details>|||
|Volkswagen|Teramont X 2021-22|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Teramont X 2021-22">Buy Here</a></sub></details>||| |Volkswagen|Teramont X 2021-22|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Teramont X 2021-22">Buy Here</a></sub></details>|||
|Volkswagen|Tiguan 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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Tiguan 2018-23">Buy Here</a></sub></details>||| |Volkswagen|Tiguan 2018-24|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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Tiguan 2018-24">Buy Here</a></sub></details>|||
|Volkswagen|Tiguan eHybrid 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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Tiguan eHybrid 2021-23">Buy Here</a></sub></details>||| |Volkswagen|Tiguan eHybrid 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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Tiguan eHybrid 2021-23">Buy Here</a></sub></details>|||
|Volkswagen|Touran 2016-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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Touran 2016-23">Buy Here</a></sub></details>||| |Volkswagen|Touran 2016-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 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x.html?make=Volkswagen&model=Touran 2016-23">Buy Here</a></sub></details>|||
-1
View File
@@ -7,7 +7,6 @@ Development is coordinated through [Discord](https://discord.comma.ai) and GitHu
### Getting Started ### Getting Started
* Setup your [development environment](../tools/) * Setup your [development environment](../tools/)
* Read about the [development workflow](WORKFLOW.md)
* Join our [Discord](https://discord.comma.ai) * Join our [Discord](https://discord.comma.ai)
* Docs are at https://docs.comma.ai and https://blog.comma.ai * Docs are at https://docs.comma.ai and https://blog.comma.ai
-33
View File
@@ -1,33 +0,0 @@
# openpilot development workflow
Aside from the ML models, most tools used for openpilot development are in this repo.
Most development happens on normal Ubuntu workstations, and not in cars or directly on comma devices. See the [setup guide](../tools) for getting your PC setup for openpilot development.
## Quick start
```bash
# get the latest stuff
git pull
git lfs pull
git submodule update --init --recursive
# update dependencies
tools/ubuntu_setup.sh
# build everything
scons -j$(nproc)
# build just the ui with either of these
scons -j8 selfdrive/ui/
cd selfdrive/ui/ && scons -u -j8
# test everything
pytest
# test just logging services
cd system/loggerd && pytest .
# run the linter
op lint
```
+1 -1
View File
@@ -7,7 +7,7 @@ export OPENBLAS_NUM_THREADS=1
export VECLIB_MAXIMUM_THREADS=1 export VECLIB_MAXIMUM_THREADS=1
if [ -z "$AGNOS_VERSION" ]; then if [ -z "$AGNOS_VERSION" ]; then
export AGNOS_VERSION="12.3" export AGNOS_VERSION="12.4"
fi fi
export STAGING_ROOT="/data/safe_staging" export STAGING_ROOT="/data/safe_staging"
+1 -1
Submodule panda updated: 5ac4fa5bb0...c33cfa0803
+8 -3
View File
@@ -59,7 +59,7 @@ dependencies = [
"future-fstrings", "future-fstrings",
# joystickd # joystickd
"pygame", "inputs",
# these should be removed # these should be removed
"psutil", "psutil",
@@ -68,6 +68,9 @@ dependencies = [
# logreader # logreader
"zstandard", "zstandard",
# ui
"qrcode",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -85,7 +88,8 @@ testing = [
"pytest-cov", "pytest-cov",
"pytest-cpp", "pytest-cpp",
"pytest-subtests", "pytest-subtests",
"pytest-xdist", # https://github.com/pytest-dev/pytest-xdist/issues/1215
"pytest-xdist @ git+https://github.com/sshane/pytest-xdist@909e97b49d12401c10608f9d777bfc9dab8a4413",
"pytest-timeout", "pytest-timeout",
"pytest-randomly", "pytest-randomly",
"pytest-asyncio", "pytest-asyncio",
@@ -102,10 +106,11 @@ dev = [
"azure-storage-blob", "azure-storage-blob",
"dbus-next", "dbus-next",
"dictdiffer", "dictdiffer",
"lru-dict",
"matplotlib", "matplotlib",
"opencv-python-headless",
"parameterized >=0.8, <0.9", "parameterized >=0.8, <0.9",
"pyautogui", "pyautogui",
"pygame",
"pyopencl; platform_machine != 'aarch64'", # broken on arm64 "pyopencl; platform_machine != 'aarch64'", # broken on arm64
"pytools < 2024.1.11; platform_machine != 'aarch64'", # pyopencl use a broken version "pytools < 2024.1.11; platform_machine != 'aarch64'", # pyopencl use a broken version
"pywinctl", "pywinctl",
+15 -20
View File
@@ -1,36 +1,31 @@
# openpilot releases # openpilot releases
```
## release checklist ## release checklist
**Go to `devel-staging`** **Go to `devel-staging`**
- [ ] update RELEASES.md
- [ ] update `devel-staging`: `git reset --hard origin/master-ci` - [ ] update `devel-staging`: `git reset --hard origin/master-ci`
- [ ] open a pull request from `devel-staging` to `devel` - [ ] open a pull request from `devel-staging` to `devel`
- [ ] post on Discord
**Go to `devel`** **Go to `devel`**
- [ ] update RELEASES.md
- [ ] close out milestone
- [ ] post on Discord dev channel
- [ ] bump version on master: `common/version.h` and `RELEASES.md` - [ ] bump version on master: `common/version.h` and `RELEASES.md`
- [ ] merge the pull request - [ ] before merging the pull request
- [ ] update from previous release -> new release
tests: - [ ] update from new release -> previous release
- [ ] update from previous release -> new release - [ ] fresh install with `openpilot-test.comma.ai`
- [ ] update from new release -> previous release - [ ] drive on fresh install
- [ ] fresh install with `openpilot-test.comma.ai` - [ ] no submodules or LFS
- [ ] drive on fresh install - [ ] check sentry, MTBF, etc.
- [ ] comma body test
- [ ] no submodules or LFS
- [ ] check sentry, MTBF, etc.
**Go to `release3`** **Go to `release3`**
- [ ] publish the blog post - [ ] publish the blog post
- [ ] `git reset --hard origin/release3-staging` - [ ] `git reset --hard origin/release3-staging`
- [ ] tag the release - [ ] tag the release: `git tag v0.X.X <commit-hash> && git push origin v0.X.X`
```
git tag v0.X.X <commit-hash>
git push origin v0.X.X
```
- [ ] create GitHub release - [ ] create GitHub release
- [ ] final test install on `openpilot.comma.ai` - [ ] final test install on `openpilot.comma.ai`
- [ ] update production - [ ] update factory provisioning
- [ ] Post on Discord, X, etc. - [ ] close out milestone
- [ ] post on Discord, X, etc.
```
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
# run_openpilot_docker.sh
# POSIX-compliant script to run openpilot in Docker for local testing
# === Configurable Variables ===
# Base image to use (required)
BASE_IMAGE="${BASE_IMAGE:-commaai/openpilot-base:latest}"
# Working directory inside the container
WORKDIR="/tmp/openpilot"
# Local project path
LOCAL_DIR="$PWD"
# Shared memory size (adjust for large builds/tests)
SHM_SIZE="2G"
# Environment configuration
CI=1
PYTHONWARNINGS="error"
FILEREADER_CACHE=1
PYTHONPATH="$WORKDIR"
# Optional: GitHub Actions env vars — set them only if needed for local mirroring/debug
USE_GITHUB_ENV_VARS=false # set to true to enable GitHub-related mounts/envs
GITHUB_WORKSPACE="${GITHUB_WORKSPACE:-$HOME/openpilot_ci}" # fallback path
# === Docker Command ===
docker run --rm \
--shm-size "$SHM_SIZE" \
-v "$LOCAL_DIR":"$WORKDIR" \
-w "$WORKDIR" \
-e CI="$CI" \
-e PYTHONWARNINGS="$PYTHONWARNINGS" \
-e FILEREADER_CACHE="$FILEREADER_CACHE" \
-e PYTHONPATH="$PYTHONPATH" \
${USE_GITHUB_ENV_VARS:+\
-e NUM_JOBS \
-e JOB_ID \
-e GITHUB_ACTION \
-e GITHUB_REF \
-e GITHUB_HEAD_REF \
-e GITHUB_SHA \
-e GITHUB_REPOSITORY \
-e GITHUB_RUN_ID \
-v "$GITHUB_WORKSPACE/.ci_cache/scons_cache":/tmp/scons_cache \
-v "$GITHUB_WORKSPACE/.ci_cache/comma_download_cache":/tmp/comma_download_cache \
-v "$GITHUB_WORKSPACE/.ci_cache/openpilot_cache":/tmp/openpilot_cache } \
"$BASE_IMAGE" /bin/bash -c "${1:-/bin/bash}"
+3 -4
View File
@@ -12,11 +12,10 @@
<h5>Quectel/EG25-G</h5> <h5>Quectel/EG25-G</h5>
<p>FCC ID: XMR201903EG25G</p> <p>FCC ID: XMR201903EG25G</p>
<p> <p>This device complies with Part 15 of the FCC Rules.</p>
This device complies with Part 15 of the FCC Rules. <p>Operation is subject to the following two conditions:</p>
Operation is subject to the following two conditions:
<p>(1) this device may not cause harmful interference, and <p>(1) this device may not cause harmful interference, and</p>
<p>(2) this device must accept any interference received, including interference that may cause undesired operation.</p> <p>(2) this device must accept any interference received, including interference that may cause undesired operation.</p>
The following test reports are subject to this declaration: The following test reports are subject to this declaration:
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env python3
import jinja2
import os
from cereal import car
from openpilot.common.basedir import BASEDIR
from opendbc.car.interfaces import get_interface_attr
Ecu = car.CarParams.Ecu
CARS = get_interface_attr('CAR')
FW_VERSIONS = get_interface_attr('FW_VERSIONS')
FINGERPRINTS = get_interface_attr('FINGERPRINTS')
ECU_NAME = {v: k for k, v in Ecu.schema.enumerants.items()}
FINGERPRINTS_PY_TEMPLATE = jinja2.Template("""
{%- if FINGERPRINTS[brand] %}
# ruff: noqa: E501
{% endif %}
{% if FW_VERSIONS[brand] %}
from opendbc.car.structs import CarParams
{% endif %}
from opendbc.car.{{brand}}.values import CAR
{% if FW_VERSIONS[brand] %}
Ecu = CarParams.Ecu
{% endif %}
{% if comments +%}
{{ comments | join() }}
{% endif %}
{% if FINGERPRINTS[brand] %}
FINGERPRINTS = {
{% for car, fingerprints in FINGERPRINTS[brand].items() %}
CAR.{{car.name}}: [{
{% for fingerprint in fingerprints %}
{% if not loop.first %}
{{ "{" }}
{% endif %}
{% for key, value in fingerprint.items() %}{{key}}: {{value}}{% if not loop.last %}, {% endif %}{% endfor %}
}{% if loop.last %}]{% endif %},
{% endfor %}
{% endfor %}
}
{% endif %}
FW_VERSIONS{% if not FW_VERSIONS[brand] %}: dict[str, dict[tuple, list[bytes]]]{% endif %} = {
{% for car, _ in FW_VERSIONS[brand].items() %}
CAR.{{car.name}}: {
{% for key, fw_versions in FW_VERSIONS[brand][car].items() %}
(Ecu.{{ECU_NAME[key[0]]}}, 0x{{"%0x" | format(key[1] | int)}}, \
{% if key[2] %}0x{{"%0x" | format(key[2] | int)}}{% else %}{{key[2]}}{% endif %}): [
{% for fw_version in (fw_versions + extra_fw_versions.get(car, {}).get(key, [])) | unique | sort %}
{{fw_version}},
{% endfor %}
],
{% endfor %}
},
{% endfor %}
}
""", trim_blocks=True)
def format_brand_fw_versions(brand, extra_fw_versions: None | dict[str, dict[tuple, list[bytes]]] = None):
extra_fw_versions = extra_fw_versions or {}
fingerprints_file = os.path.join(BASEDIR, f"opendbc/car/{brand}/fingerprints.py")
with open(fingerprints_file) as f:
comments = [line for line in f.readlines() if line.startswith("#") and "noqa" not in line]
with open(fingerprints_file, "w") as f:
f.write(FINGERPRINTS_PY_TEMPLATE.render(brand=brand, comments=comments, ECU_NAME=ECU_NAME,
FINGERPRINTS=FINGERPRINTS, FW_VERSIONS=FW_VERSIONS,
extra_fw_versions=extra_fw_versions))
if __name__ == "__main__":
for brand in FW_VERSIONS.keys():
format_brand_fw_versions(brand)
+6 -2
View File
@@ -13,6 +13,7 @@ from typing import NoReturn
from cereal import log, car from cereal import log, car
import cereal.messaging as messaging import cereal.messaging as messaging
from openpilot.system.hardware import HARDWARE
from openpilot.common.conversions import Conversions as CV from openpilot.common.conversions import Conversions as CV
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.common.realtime import config_realtime_process from openpilot.common.realtime import config_realtime_process
@@ -36,8 +37,11 @@ RPY_INIT = np.array([0.0,0.0,0.0])
WIDE_FROM_DEVICE_EULER_INIT = np.array([0.0, 0.0, 0.0]) WIDE_FROM_DEVICE_EULER_INIT = np.array([0.0, 0.0, 0.0])
HEIGHT_INIT = np.array([1.22]) HEIGHT_INIT = np.array([1.22])
# These values are needed to accommodate the model frame in the narrow cam of the C3 # These values are needed to accommodate the model frame in the narrow cam
PITCH_LIMITS = np.array([-0.09074112085129739, 0.17]) if HARDWARE.get_device_type() == 'mici':
PITCH_LIMITS = np.array([-0.143101, 0.22235988])
else:
PITCH_LIMITS = np.array([-0.09074112085129739, 0.17])
YAW_LIMITS = np.array([-0.06912048084718224, 0.06912048084718235]) YAW_LIMITS = np.array([-0.06912048084718224, 0.06912048084718235])
DEBUG = os.getenv("DEBUG") is not None DEBUG = os.getenv("DEBUG") is not None
+6
View File
@@ -82,6 +82,12 @@ class PointBuckets:
total_points_valid = self.__len__() >= self.min_points_total total_points_valid = self.__len__() >= self.min_points_total
return individual_buckets_valid and total_points_valid return individual_buckets_valid and total_points_valid
def get_valid_percent(self) -> int:
total_points_perc = min(self.__len__() / self.min_points_total * 100, 100)
individual_buckets_perc = min(min(len(v) / min_pts * 100 for v, min_pts in
zip(self.buckets.values(), self.buckets_min_points.values(), strict=True)), 100)
return int((total_points_perc + individual_buckets_perc) / 2)
def is_calculable(self) -> bool: def is_calculable(self) -> bool:
return all(len(v) > 0 for v in self.buckets.values()) return all(len(v) > 0 for v in self.buckets.values())
+2
View File
@@ -229,6 +229,8 @@ class LateralLagEstimator:
liveDelay.lateralDelayEstimateStd = 0.0 liveDelay.lateralDelayEstimateStd = 0.0
liveDelay.validBlocks = self.block_avg.valid_blocks liveDelay.validBlocks = self.block_avg.valid_blocks
liveDelay.calPerc = min(100 * (self.block_avg.valid_blocks * self.block_size + self.block_avg.idx) //
(self.min_valid_block_count * self.block_size), 100)
if debug: if debug:
liveDelay.points = self.block_avg.values.flatten().tolist() liveDelay.points = self.block_avg.values.flatten().tolist()
+4
View File
@@ -94,6 +94,7 @@ class TestLagd:
assert np.allclose(msg.liveDelay.lateralDelay, estimator.initial_lag) assert np.allclose(msg.liveDelay.lateralDelay, estimator.initial_lag)
assert np.allclose(msg.liveDelay.lateralDelayEstimate, estimator.initial_lag) assert np.allclose(msg.liveDelay.lateralDelayEstimate, estimator.initial_lag)
assert msg.liveDelay.validBlocks == 0 assert msg.liveDelay.validBlocks == 0
assert msg.liveDelay.calPerc == 0
def test_estimator_basics(self, subtests): def test_estimator_basics(self, subtests):
for lag_frames in range(5): for lag_frames in range(5):
@@ -107,6 +108,7 @@ class TestLagd:
assert np.allclose(msg.liveDelay.lateralDelayEstimate, lag_frames * DT, atol=0.01) assert np.allclose(msg.liveDelay.lateralDelayEstimate, lag_frames * DT, atol=0.01)
assert np.allclose(msg.liveDelay.lateralDelayEstimateStd, 0.0, atol=0.01) assert np.allclose(msg.liveDelay.lateralDelayEstimateStd, 0.0, atol=0.01)
assert msg.liveDelay.validBlocks == BLOCK_NUM_NEEDED assert msg.liveDelay.validBlocks == BLOCK_NUM_NEEDED
assert msg.liveDelay.calPerc == 100
def test_disabled_estimator(self): def test_disabled_estimator(self):
mocked_CP = car.CarParams(steerActuatorDelay=0.8) mocked_CP = car.CarParams(steerActuatorDelay=0.8)
@@ -119,6 +121,7 @@ class TestLagd:
assert np.allclose(msg.liveDelay.lateralDelayEstimate, lag_frames * DT, atol=0.01) assert np.allclose(msg.liveDelay.lateralDelayEstimate, lag_frames * DT, atol=0.01)
assert np.allclose(msg.liveDelay.lateralDelayEstimateStd, 0.0, atol=0.01) assert np.allclose(msg.liveDelay.lateralDelayEstimateStd, 0.0, atol=0.01)
assert msg.liveDelay.validBlocks == BLOCK_NUM_NEEDED assert msg.liveDelay.validBlocks == BLOCK_NUM_NEEDED
assert msg.liveDelay.calPerc == 100
def test_estimator_masking(self): def test_estimator_masking(self):
mocked_CP, lag_frames = car.CarParams(steerActuatorDelay=0.8), random.randint(1, 19) mocked_CP, lag_frames = car.CarParams(steerActuatorDelay=0.8), random.randint(1, 19)
@@ -127,6 +130,7 @@ class TestLagd:
msg = estimator.get_msg(True) msg = estimator.get_msg(True)
assert np.allclose(msg.liveDelay.lateralDelayEstimate, lag_frames * DT, atol=0.01) assert np.allclose(msg.liveDelay.lateralDelayEstimate, lag_frames * DT, atol=0.01)
assert np.allclose(msg.liveDelay.lateralDelayEstimateStd, 0.0, atol=0.01) assert np.allclose(msg.liveDelay.lateralDelayEstimateStd, 0.0, atol=0.01)
assert msg.liveDelay.calPerc == 100
@pytest.mark.skipif(PC, reason="only on device") @pytest.mark.skipif(PC, reason="only on device")
@pytest.mark.timeout(60) @pytest.mark.timeout(60)
+25
View File
@@ -0,0 +1,25 @@
from cereal import car
from openpilot.selfdrive.locationd.torqued import TorqueEstimator
def test_cal_percent():
est = TorqueEstimator(car.CarParams())
msg = est.get_msg()
assert msg.liveTorqueParameters.calPerc == 0
for (low, high), min_pts in zip(est.filtered_points.buckets.keys(),
est.filtered_points.buckets_min_points.values(), strict=True):
for _ in range(int(min_pts)):
est.filtered_points.add_point((low + high) / 2.0, 0.0)
# enough bucket points, but not enough total points
msg = est.get_msg()
assert msg.liveTorqueParameters.calPerc == (len(est.filtered_points) / est.min_points_total * 100 + 100) / 2
# add enough points to bucket with most capacity
key = list(est.filtered_points.buckets)[0]
for _ in range(est.min_points_total - len(est.filtered_points)):
est.filtered_points.add_point((key[0] + key[1]) / 2.0, 0.0)
msg = est.get_msg()
assert msg.liveTorqueParameters.calPerc == 100
+1
View File
@@ -233,6 +233,7 @@ class TorqueEstimator(ParameterEstimator):
liveTorqueParameters.latAccelOffsetFiltered = float(self.filtered_params['latAccelOffset'].x) liveTorqueParameters.latAccelOffsetFiltered = float(self.filtered_params['latAccelOffset'].x)
liveTorqueParameters.frictionCoefficientFiltered = float(self.filtered_params['frictionCoefficient'].x) liveTorqueParameters.frictionCoefficientFiltered = float(self.filtered_params['frictionCoefficient'].x)
liveTorqueParameters.totalBucketPoints = len(self.filtered_points) liveTorqueParameters.totalBucketPoints = len(self.filtered_points)
liveTorqueParameters.calPerc = self.filtered_points.get_valid_percent()
liveTorqueParameters.decay = self.decay liveTorqueParameters.decay = self.decay
liveTorqueParameters.maxResets = self.resets liveTorqueParameters.maxResets = self.resets
return msg return msg
+1 -1
View File
@@ -60,7 +60,7 @@ import subprocess
from tinygrad import Device from tinygrad import Device
# because tg doesn't support multi-process # because tg doesn't support multi-process
devs = subprocess.check_output('python3 -c "from tinygrad import Device; print(list(Device.get_available_devices()))"', shell=True) devs = subprocess.check_output('python3 -c "from tinygrad import Device; print(list(Device.get_available_devices()))"', shell=True, cwd=env.Dir('#').abspath)
if b"AMD" in devs: if b"AMD" in devs:
del Device del Device
print("USB GPU detected... building") print("USB GPU detected... building")
+20 -21
View File
@@ -86,10 +86,20 @@ class ModelState:
prev_desire: np.ndarray # for tracking the rising edge of the pulse prev_desire: np.ndarray # for tracking the rising edge of the pulse
def __init__(self, context: CLContext): def __init__(self, context: CLContext):
self.frames = { with open(VISION_METADATA_PATH, 'rb') as f:
'input_imgs': DrivingModelFrame(context, ModelConstants.TEMPORAL_SKIP), vision_metadata = pickle.load(f)
'big_input_imgs': DrivingModelFrame(context, ModelConstants.TEMPORAL_SKIP) self.vision_input_shapes = vision_metadata['input_shapes']
} self.vision_input_names = list(self.vision_input_shapes.keys())
self.vision_output_slices = vision_metadata['output_slices']
vision_output_size = vision_metadata['output_shapes']['outputs'][1]
with open(POLICY_METADATA_PATH, 'rb') as f:
policy_metadata = pickle.load(f)
self.policy_input_shapes = policy_metadata['input_shapes']
self.policy_output_slices = policy_metadata['output_slices']
policy_output_size = policy_metadata['output_shapes']['outputs'][1]
self.frames = {name: DrivingModelFrame(context, ModelConstants.TEMPORAL_SKIP) for name in self.vision_input_names}
self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32) self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32)
self.full_features_buffer = np.zeros((1, ModelConstants.FULL_HISTORY_BUFFER_LEN, ModelConstants.FEATURE_LEN), dtype=np.float32) self.full_features_buffer = np.zeros((1, ModelConstants.FULL_HISTORY_BUFFER_LEN, ModelConstants.FEATURE_LEN), dtype=np.float32)
@@ -106,18 +116,6 @@ class ModelState:
'features_buffer': np.zeros((1, ModelConstants.INPUT_HISTORY_BUFFER_LEN, ModelConstants.FEATURE_LEN), dtype=np.float32), 'features_buffer': np.zeros((1, ModelConstants.INPUT_HISTORY_BUFFER_LEN, ModelConstants.FEATURE_LEN), dtype=np.float32),
} }
with open(VISION_METADATA_PATH, 'rb') as f:
vision_metadata = pickle.load(f)
self.vision_input_shapes = vision_metadata['input_shapes']
self.vision_output_slices = vision_metadata['output_slices']
vision_output_size = vision_metadata['output_shapes']['outputs'][1]
with open(POLICY_METADATA_PATH, 'rb') as f:
policy_metadata = pickle.load(f)
self.policy_input_shapes = policy_metadata['input_shapes']
self.policy_output_slices = policy_metadata['output_slices']
policy_output_size = policy_metadata['output_shapes']['outputs'][1]
# img buffers are managed in openCL transform code # img buffers are managed in openCL transform code
self.vision_inputs: dict[str, Tensor] = {} self.vision_inputs: dict[str, Tensor] = {}
self.vision_output = np.zeros(vision_output_size, dtype=np.float32) self.vision_output = np.zeros(vision_output_size, dtype=np.float32)
@@ -135,7 +133,7 @@ class ModelState:
parsed_model_outputs = {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 return parsed_model_outputs
def run(self, buf: VisionBuf, wbuf: VisionBuf, transform: np.ndarray, transform_wide: np.ndarray, def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray],
inputs: dict[str, np.ndarray], prepare_only: bool) -> dict[str, np.ndarray] | None: inputs: dict[str, np.ndarray], prepare_only: bool) -> dict[str, np.ndarray] | None:
# Model decides when action is completed, so desire input is just a pulse triggered on rising edge # Model decides when action is completed, so desire input is just a pulse triggered on rising edge
inputs['desire'][0] = 0 inputs['desire'][0] = 0
@@ -148,8 +146,7 @@ class ModelState:
self.numpy_inputs['traffic_convention'][:] = inputs['traffic_convention'] self.numpy_inputs['traffic_convention'][:] = inputs['traffic_convention']
self.numpy_inputs['lateral_control_params'][:] = inputs['lateral_control_params'] self.numpy_inputs['lateral_control_params'][:] = inputs['lateral_control_params']
imgs_cl = {'input_imgs': self.frames['input_imgs'].prepare(buf, transform.flatten()), imgs_cl = {name: self.frames[name].prepare(bufs[name], transforms[name].flatten()) for name in self.vision_input_names}
'big_input_imgs': self.frames['big_input_imgs'].prepare(wbuf, transform_wide.flatten())}
if TICI and not USBGPU: if TICI and not USBGPU:
# The imgs tensors are backed by opencl memory, only need init once # The imgs tensors are backed by opencl memory, only need init once
@@ -328,14 +325,16 @@ def main(demo=False):
if prepare_only: if prepare_only:
cloudlog.error(f"skipping model eval. Dropped {vipc_dropped_frames} frames") cloudlog.error(f"skipping model eval. Dropped {vipc_dropped_frames} frames")
bufs = {name: buf_extra if 'big' in name else buf_main for name in model.vision_input_names}
transforms = {name: model_transform_extra if 'big' in name else model_transform_main for name in model.vision_input_names}
inputs:dict[str, np.ndarray] = { inputs:dict[str, np.ndarray] = {
'desire': vec_desire, 'desire': vec_desire,
'traffic_convention': traffic_convention, 'traffic_convention': traffic_convention,
'lateral_control_params': lateral_control_params, 'lateral_control_params': lateral_control_params,
} }
mt1 = time.perf_counter() mt1 = time.perf_counter()
model_output = model.run(buf_main, buf_extra, model_transform_main, model_transform_extra, inputs, prepare_only) model_output = model.run(bufs, transforms, inputs, prepare_only)
mt2 = time.perf_counter() mt2 = time.perf_counter()
model_execution_time = mt2 - mt1 model_execution_time = mt2 - mt1
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:98f0121ccb6f850077b04cc91bd33d370fc6cbdc2bd35f1ab55628a15a813f36 oid sha256:5f714fb38bcd44b5d9f44e3a8e1595e1dfdf7558f0eec3485cf3f2dbb6dc7d8d
size 15966721 size 15971805
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:897f80d0388250f99bba69b6a8434560cc0fd83157cbeb0bc134c67fe4e64624 oid sha256:3ac4867fbc618037e8d03143edbfeeae960f2025644b5dcf36c6665271b4f874
size 34882971 size 34883375
-5
View File
@@ -585,11 +585,6 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = {
}, },
EventName.noGps: { EventName.noGps: {
ET.PERMANENT: Alert(
"Poor GPS reception",
"Ensure device has a clear view of the sky",
AlertStatus.normal, AlertSize.mid,
Priority.LOWER, VisualAlert.none, AudibleAlert.none, .2, creation_delay=600.)
}, },
EventName.tooDistracted: { EventName.tooDistracted: {
+8 -8
View File
@@ -382,16 +382,16 @@ class SelfdriveD(CruiseHelper):
if (planner_fcw or model_fcw) and not self.CP.notCar: if (planner_fcw or model_fcw) and not self.CP.notCar:
self.events.add(EventName.fcw) self.events.add(EventName.fcw)
# GPS checks
gps_ok = self.sm.recv_frame[self.gps_location_service] > 0 and (self.sm.frame - self.sm.recv_frame[self.gps_location_service]) * DT_CTRL < 2.0
if not gps_ok and self.sm['livePose'].inputsOK and (self.distance_traveled > 1500):
self.events.add(EventName.noGps)
if gps_ok:
self.distance_traveled = 0
self.distance_traveled += abs(CS.vEgo) * DT_CTRL
# TODO: fix simulator # TODO: fix simulator
if not SIMULATION or REPLAY: if not SIMULATION or REPLAY:
# Not show in first 1.5 km to allow for driving out of garage. This event shows after 5 minutes
gps_ok = self.sm.recv_frame[self.gps_location_service] > 0 and (self.sm.frame - self.sm.recv_frame[self.gps_location_service]) * DT_CTRL < 2.0
if not gps_ok and self.sm['livePose'].inputsOK and (self.distance_traveled > 1500):
self.events.add(EventName.noGps)
if gps_ok:
self.distance_traveled = 0
self.distance_traveled += abs(CS.vEgo) * DT_CTRL
if self.sm['modelV2'].frameDropPerc > 20: if self.sm['modelV2'].frameDropPerc > 20:
self.events.add(EventName.modeldLagging) self.events.add(EventName.modeldLagging)
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -e
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)"
OP_ROOT="$DIR/../../"
if [ -z "$BUILD" ]; then
docker pull ghcr.io/commaai/openpilot-base:latest
else
docker build --cache-from ghcr.io/commaai/openpilot-base:latest -t ghcr.io/commaai/openpilot-base:latest -f $OP_ROOT/Dockerfile.openpilot_base .
fi
docker run \
-it \
--rm \
--volume $OP_ROOT:$OP_ROOT \
--workdir $PWD \
--env PYTHONPATH=$OP_ROOT \
ghcr.io/commaai/openpilot-base:latest \
/bin/bash
+29 -6
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
import pickle
import sys import sys
from collections import defaultdict from collections import defaultdict
from typing import Any from typing import Any
@@ -189,22 +190,44 @@ def model_replay(lr, frs):
print("----------------- Model Timing -----------------") print("----------------- Model Timing -----------------")
print("------------------------------------------------") print("------------------------------------------------")
print(tabulate(rows, header, tablefmt="simple_grid", stralign="center", numalign="center", floatfmt=".4f")) print(tabulate(rows, header, tablefmt="simple_grid", stralign="center", numalign="center", floatfmt=".4f"))
assert timings_ok assert timings_ok or PC
return msgs return msgs
def get_frames():
regen_cache = "--regen-cache" in sys.argv
frames_cache = '/tmp/model_replay_cache' if PC else '/data/model_replay_cache'
os.makedirs(frames_cache, exist_ok=True)
cache_name = f'{frames_cache}/{TEST_ROUTE}_{SEGMENT}_{START_FRAME}_{END_FRAME}.pkl'
if os.path.isfile(cache_name) and not regen_cache:
try:
print(f"Loading frames from cache {cache_name}")
return pickle.load(open(cache_name, "rb"))
except Exception as e:
print(f"Failed to load frames from cache {cache_name}: {e}")
frs = {
'roadCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "fcamera.hevc"), pix_fmt='nv12', cache_size=END_FRAME - START_FRAME),
'driverCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "dcamera.hevc"), pix_fmt='nv12', cache_size=END_FRAME - START_FRAME),
'wideRoadCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "ecamera.hevc"), pix_fmt='nv12', cache_size=END_FRAME - START_FRAME),
}
for fr in frs.values():
for fidx in range(START_FRAME, END_FRAME):
fr.get(fidx)
fr.it = None
print(f"Dumping frame cache {cache_name}")
pickle.dump(frs, open(cache_name, "wb"))
return frs
if __name__ == "__main__": if __name__ == "__main__":
update = "--update" in sys.argv or (os.getenv("GIT_BRANCH", "") == 'master') update = "--update" in sys.argv or (os.getenv("GIT_BRANCH", "") == 'master')
replay_dir = os.path.dirname(os.path.abspath(__file__)) replay_dir = os.path.dirname(os.path.abspath(__file__))
# load logs # load logs
lr = list(LogReader(get_url(TEST_ROUTE, SEGMENT, "rlog.zst"))) lr = list(LogReader(get_url(TEST_ROUTE, SEGMENT, "rlog.zst")))
frs = { frs = get_frames()
'roadCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "fcamera.hevc"), readahead=True),
'driverCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "dcamera.hevc"), readahead=True),
'wideRoadCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "ecamera.hevc"), readahead=True)
}
log_msgs = [] log_msgs = []
# run replays # run replays
@@ -27,7 +27,7 @@ from openpilot.selfdrive.test.process_replay.vision_meta import meta_from_camera
from openpilot.selfdrive.test.process_replay.migration import migrate_all from openpilot.selfdrive.test.process_replay.migration import migrate_all
from openpilot.selfdrive.test.process_replay.capture import ProcessOutputCapture from openpilot.selfdrive.test.process_replay.capture import ProcessOutputCapture
from openpilot.tools.lib.logreader import LogIterable from openpilot.tools.lib.logreader import LogIterable
from openpilot.tools.lib.framereader import BaseFrameReader from openpilot.tools.lib.framereader import FrameReader
# Numpy gives different results based on CPU features after version 19 # Numpy gives different results based on CPU features after version 19
NUMPY_TOLERANCE = 1e-7 NUMPY_TOLERANCE = 1e-7
@@ -209,6 +209,7 @@ class ProcessContainer:
streams_metas = available_streams(all_msgs) streams_metas = available_streams(all_msgs)
for meta in streams_metas: for meta in streams_metas:
if meta.camera_state in self.cfg.vision_pubs: if meta.camera_state in self.cfg.vision_pubs:
assert frs[meta.camera_state].pix_fmt == 'nv12'
frame_size = (frs[meta.camera_state].w, frs[meta.camera_state].h) frame_size = (frs[meta.camera_state].w, frs[meta.camera_state].h)
vipc_server.create_buffers(meta.stream, 2, *frame_size) vipc_server.create_buffers(meta.stream, 2, *frame_size)
vipc_server.start_listener() vipc_server.start_listener()
@@ -224,7 +225,7 @@ class ProcessContainer:
def start( def start(
self, params_config: dict[str, Any], environ_config: dict[str, Any], self, params_config: dict[str, Any], environ_config: dict[str, Any],
all_msgs: LogIterable, frs: dict[str, BaseFrameReader] | None, all_msgs: LogIterable, frs: dict[str, FrameReader] | None,
fingerprint: str | None, capture_output: bool fingerprint: str | None, capture_output: bool
): ):
with self.prefix as p: with self.prefix as p:
@@ -266,7 +267,7 @@ class ProcessContainer:
self.prefix.clean_dirs() self.prefix.clean_dirs()
self._clean_env() self._clean_env()
def run_step(self, msg: capnp._DynamicStructReader, frs: dict[str, BaseFrameReader] | None) -> list[capnp._DynamicStructReader]: def run_step(self, msg: capnp._DynamicStructReader, frs: dict[str, FrameReader] | None) -> list[capnp._DynamicStructReader]:
assert self.rc and self.pm and self.sockets and self.process.proc assert self.rc and self.pm and self.sockets and self.process.proc
output_msgs = [] output_msgs = []
@@ -296,7 +297,7 @@ class ProcessContainer:
camera_state = getattr(m, m.which()) camera_state = getattr(m, m.which())
camera_meta = meta_from_camera_state(m.which()) camera_meta = meta_from_camera_state(m.which())
assert frs is not None assert frs is not None
img = frs[m.which()].get(camera_state.frameId, pix_fmt="nv12")[0] img = frs[m.which()].get(camera_state.frameId)
self.vipc_server.send(camera_meta.stream, img.flatten().tobytes(), self.vipc_server.send(camera_meta.stream, img.flatten().tobytes(),
camera_state.frameId, camera_state.timestampSof, camera_state.timestampEof) camera_state.frameId, camera_state.timestampSof, camera_state.timestampEof)
self.msg_queue = [] self.msg_queue = []
@@ -655,7 +656,7 @@ def replay_process_with_name(name: str | Iterable[str], lr: LogIterable, *args,
def replay_process( def replay_process(
cfg: ProcessConfig | Iterable[ProcessConfig], lr: LogIterable, frs: dict[str, BaseFrameReader] = None, cfg: ProcessConfig | Iterable[ProcessConfig], lr: LogIterable, frs: dict[str, FrameReader] = None,
fingerprint: str = None, return_all_logs: bool = False, custom_params: dict[str, Any] = None, fingerprint: str = None, return_all_logs: bool = False, custom_params: dict[str, Any] = None,
captured_output_store: dict[str, dict[str, str]] = None, disable_progress: bool = False captured_output_store: dict[str, dict[str, str]] = None, disable_progress: bool = False
) -> list[capnp._DynamicStructReader]: ) -> list[capnp._DynamicStructReader]:
@@ -683,7 +684,7 @@ def replay_process(
def _replay_multi_process( def _replay_multi_process(
cfgs: list[ProcessConfig], lr: LogIterable, frs: dict[str, BaseFrameReader] | None, fingerprint: str | None, cfgs: list[ProcessConfig], lr: LogIterable, frs: dict[str, FrameReader] | None, fingerprint: str | None,
custom_params: dict[str, Any] | None, captured_output_store: dict[str, dict[str, str]] | None, disable_progress: bool custom_params: dict[str, Any] | None, captured_output_store: dict[str, dict[str, str]] | None, disable_progress: bool
) -> list[capnp._DynamicStructReader]: ) -> list[capnp._DynamicStructReader]:
if fingerprint is not None: if fingerprint is not None:
+1 -1
View File
@@ -1 +1 @@
9e2fe2942fbf77f24bccdbef15893831f9c0b390 f440c9e0469d32d350aa99ddaa8f44591a2ce690
+2 -25
View File
@@ -3,40 +3,17 @@ import os
import argparse import argparse
import time import time
import capnp import capnp
import numpy as np
from typing import Any from typing import Any
from collections.abc import Iterable from collections.abc import Iterable
from openpilot.selfdrive.test.process_replay.process_replay import CONFIGS, FAKEDATA, ProcessConfig, replay_process, get_process_config, \ from openpilot.selfdrive.test.process_replay.process_replay import CONFIGS, FAKEDATA, ProcessConfig, replay_process, get_process_config, \
check_openpilot_enabled, check_most_messages_valid, get_custom_params_from_lr check_openpilot_enabled, check_most_messages_valid, get_custom_params_from_lr
from openpilot.selfdrive.test.process_replay.vision_meta import DRIVER_CAMERA_FRAME_SIZES
from openpilot.selfdrive.test.update_ci_routes import upload_route from openpilot.selfdrive.test.update_ci_routes import upload_route
from openpilot.tools.lib.framereader import FrameReader, BaseFrameReader, FrameType from openpilot.tools.lib.framereader import FrameReader
from openpilot.tools.lib.logreader import LogReader, LogIterable, save_log from openpilot.tools.lib.logreader import LogReader, LogIterable, save_log
from openpilot.tools.lib.openpilotci import get_url from openpilot.tools.lib.openpilotci import get_url
class DummyFrameReader(BaseFrameReader):
def __init__(self, w: int, h: int, frame_count: int, pix_val: int):
self.pix_val = pix_val
self.w, self.h = w, h
self.frame_count = frame_count
self.frame_type = FrameType.raw
def get(self, idx, count=1, pix_fmt="rgb24"):
if pix_fmt == "rgb24":
shape = (self.h, self.w, 3)
elif pix_fmt == "nv12" or pix_fmt == "yuv420p":
shape = (int((self.h * self.w) * 3 / 2),)
else:
raise NotImplementedError
return [np.full(shape, self.pix_val, dtype=np.uint8) for _ in range(count)]
@staticmethod
def zero_dcamera():
return DummyFrameReader(*DRIVER_CAMERA_FRAME_SIZES[("tici", "ar0231")], 1200, 0)
def regen_segment( def regen_segment(
lr: LogIterable, frs: dict[str, Any] = None, lr: LogIterable, frs: dict[str, Any] = None,
@@ -64,7 +41,7 @@ def setup_data_readers(
frs['wideRoadCameraState'] = FrameReader(get_url(route, str(sidx), "ecamera.hevc")) frs['wideRoadCameraState'] = FrameReader(get_url(route, str(sidx), "ecamera.hevc"))
if needs_driver_cam: if needs_driver_cam:
if dummy_driver_cam: if dummy_driver_cam:
frs['driverCameraState'] = DummyFrameReader.zero_dcamera() frs['driverCameraState'] = FrameReader(get_url(route, str(sidx), "fcamera.hevc")) # Use fcam as dummy
else: else:
device_type = next(str(msg.initData.deviceType) for msg in lr if msg.which() == "initData") device_type = next(str(msg.initData.deviceType) for msg in lr if msg.which() == "initData")
assert device_type != "neo", "Driver camera not supported on neo segments. Use dummy dcamera." assert device_type != "neo", "Driver camera not supported on neo segments. Use dummy dcamera."
@@ -19,7 +19,6 @@ from openpilot.tools.lib.logreader import LogReader, save_log
IS_AZURE_TOKEN_DEFINED = os.getenv("AZURE_TOKEN") IS_AZURE_TOKEN_DEFINED = os.getenv("AZURE_TOKEN")
source_segments = [ source_segments = [
("BODY", "937ccb7243511b65|2022-05-24--16-03-09--1"), # COMMA.COMMA_BODY
("HYUNDAI", "02c45f73a2e5c6e9|2021-01-01--19-08-22--1"), # HYUNDAI.HYUNDAI_SONATA ("HYUNDAI", "02c45f73a2e5c6e9|2021-01-01--19-08-22--1"), # HYUNDAI.HYUNDAI_SONATA
("HYUNDAI2", "d545129f3ca90f28|2022-11-07--20-43-08--3"), # HYUNDAI.HYUNDAI_KIA_EV6 (+ QCOM GPS) ("HYUNDAI2", "d545129f3ca90f28|2022-11-07--20-43-08--3"), # HYUNDAI.HYUNDAI_KIA_EV6 (+ QCOM GPS)
("TOYOTA", "0982d79ebb0de295|2021-01-04--17-13-21--13"), # TOYOTA.TOYOTA_PRIUS ("TOYOTA", "0982d79ebb0de295|2021-01-04--17-13-21--13"), # TOYOTA.TOYOTA_PRIUS
@@ -44,7 +43,6 @@ source_segments = [
] ]
segments = [ segments = [
("BODY", "regen2F3C7259F1B|2025-04-08--23-00-23--0"),
("HYUNDAI", "regenAA0FC4ED71E|2025-04-08--22-57-50--0"), ("HYUNDAI", "regenAA0FC4ED71E|2025-04-08--22-57-50--0"),
("HYUNDAI2", "regenAFB9780D823|2025-04-08--23-00-34--0"), ("HYUNDAI2", "regenAFB9780D823|2025-04-08--23-00-34--0"),
("TOYOTA", "regen218A4DCFAA1|2025-04-08--22-57-51--0"), ("TOYOTA", "regen218A4DCFAA1|2025-04-08--22-57-51--0"),
@@ -65,7 +63,7 @@ segments = [
] ]
# dashcamOnly makes don't need to be tested until a full port is done # dashcamOnly makes don't need to be tested until a full port is done
excluded_interfaces = ["mock", "tesla"] excluded_interfaces = ["mock", "body"]
BASE_URL = "https://commadataci.blob.core.windows.net/openpilotci/" BASE_URL = "https://commadataci.blob.core.windows.net/openpilotci/"
REF_COMMIT_FN = os.path.join(PROC_REPLAY_DIR, "ref_commit") REF_COMMIT_FN = os.path.join(PROC_REPLAY_DIR, "ref_commit")
@@ -253,7 +251,7 @@ if __name__ == "__main__":
continue continue
# to speed things up, we only test all segments on card # to speed things up, we only test all segments on card
if cfg.proc_name != 'card' and car_brand not in ('HYUNDAI', 'TOYOTA', 'HONDA', 'SUBARU', 'FORD', 'RIVIAN', 'TESLA'): if cfg.proc_name not in ('card', 'controlsd', 'lagd') and car_brand not in ('HYUNDAI', 'TOYOTA'):
continue continue
cur_log_fn = os.path.join(FAKEDATA, f"{segment}_{cfg.proc_name}_{cur_commit}.zst") cur_log_fn = os.path.join(FAKEDATA, f"{segment}_{cfg.proc_name}_{cur_commit}.zst")
+2 -2
View File
@@ -1,6 +1,6 @@
from parameterized import parameterized from parameterized import parameterized
from openpilot.selfdrive.test.process_replay.regen import regen_segment, DummyFrameReader from openpilot.selfdrive.test.process_replay.regen import regen_segment
from openpilot.selfdrive.test.process_replay.process_replay import check_openpilot_enabled from openpilot.selfdrive.test.process_replay.process_replay import check_openpilot_enabled
from openpilot.tools.lib.openpilotci import get_url from openpilot.tools.lib.openpilotci import get_url
from openpilot.tools.lib.logreader import LogReader from openpilot.tools.lib.logreader import LogReader
@@ -18,7 +18,7 @@ def ci_setup_data_readers(route, sidx):
lr = LogReader(get_url(route, sidx, "rlog.bz2")) lr = LogReader(get_url(route, sidx, "rlog.bz2"))
frs = { frs = {
'roadCameraState': FrameReader(get_url(route, sidx, "fcamera.hevc")), 'roadCameraState': FrameReader(get_url(route, sidx, "fcamera.hevc")),
'driverCameraState': DummyFrameReader.zero_dcamera() 'driverCameraState': FrameReader(get_url(route, sidx, "fcamera.hevc")),
} }
if next((True for m in lr if m.which() == "wideRoadCameraState"), False): if next((True for m in lr if m.which() == "wideRoadCameraState"), False):
frs["wideRoadCameraState"] = FrameReader(get_url(route, sidx, "ecamera.hevc")) frs["wideRoadCameraState"] = FrameReader(get_url(route, sidx, "ecamera.hevc"))
+1 -1
View File
@@ -112,7 +112,7 @@ if GetOption('extras'):
obj = raylib_env.Object(f"installer/installers/installer_{name}.o", ["installer/installer.cc"], CPPDEFINES=d) obj = raylib_env.Object(f"installer/installers/installer_{name}.o", ["installer/installer.cc"], CPPDEFINES=d)
f = raylib_env.Program(f"installer/installers/installer_{name}", [obj, cont, inter], LIBS=raylib_libs) f = raylib_env.Program(f"installer/installers/installer_{name}", [obj, cont, inter], LIBS=raylib_libs)
# keep installers small # keep installers small
assert f[0].get_size() < 1300*1e3, f[0].get_size() assert f[0].get_size() < 1900*1e3, f[0].get_size()
# build watch3 # build watch3
if arch in ['x86_64', 'aarch64', 'Darwin'] or GetOption('extras'): if arch in ['x86_64', 'aarch64', 'Darwin'] or GetOption('extras'):
+24 -22
View File
@@ -4,10 +4,12 @@ from collections.abc import Callable
from enum import IntEnum from enum import IntEnum
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAlert from openpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAlert
from openpilot.selfdrive.ui.widgets.exp_mode_button import ExperimentalModeButton
from openpilot.selfdrive.ui.widgets.prime import PrimeWidget
from openpilot.selfdrive.ui.widgets.setup import SetupWidget
from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.label import gui_label
from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_COLOR from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_COLOR
from openpilot.system.ui.lib.widget import Widget
HEADER_HEIGHT = 80 HEADER_HEIGHT = 80
HEAD_BUTTON_FONT_SIZE = 40 HEAD_BUTTON_FONT_SIZE = 40
@@ -25,8 +27,9 @@ class HomeLayoutState(IntEnum):
ALERTS = 2 ALERTS = 2
class HomeLayout: class HomeLayout(Widget):
def __init__(self): def __init__(self):
super().__init__()
self.params = Params() self.params = Params()
self.update_alert = UpdateAlert() self.update_alert = UpdateAlert()
@@ -47,6 +50,10 @@ class HomeLayout:
self.update_notif_rect = rl.Rectangle(0, 0, 200, HEADER_HEIGHT - 10) self.update_notif_rect = rl.Rectangle(0, 0, 200, HEADER_HEIGHT - 10)
self.alert_notif_rect = rl.Rectangle(0, 0, 220, HEADER_HEIGHT - 10) self.alert_notif_rect = rl.Rectangle(0, 0, 220, HEADER_HEIGHT - 10)
self._prime_widget = PrimeWidget()
self._setup_widget = SetupWidget()
self._exp_mode_button = ExperimentalModeButton()
self._setup_callbacks() self._setup_callbacks()
def _setup_callbacks(self): def _setup_callbacks(self):
@@ -59,9 +66,7 @@ class HomeLayout:
def _set_state(self, state: HomeLayoutState): def _set_state(self, state: HomeLayoutState):
self.current_state = state self.current_state = state
def render(self, rect: rl.Rectangle): def _render(self, rect: rl.Rectangle):
self._update_layout_rects(rect)
current_time = time.time() current_time = time.time()
if current_time - self.last_refresh >= REFRESH_INTERVAL: if current_time - self.last_refresh >= REFRESH_INTERVAL:
self._refresh() self._refresh()
@@ -78,16 +83,16 @@ class HomeLayout:
elif self.current_state == HomeLayoutState.ALERTS: elif self.current_state == HomeLayoutState.ALERTS:
self._render_alerts_view() self._render_alerts_view()
def _update_layout_rects(self, rect: rl.Rectangle): def _update_layout_rects(self):
self.header_rect = rl.Rectangle( self.header_rect = rl.Rectangle(
rect.x + CONTENT_MARGIN, rect.y + CONTENT_MARGIN, rect.width - 2 * CONTENT_MARGIN, HEADER_HEIGHT self._rect.x + CONTENT_MARGIN, self._rect.y + CONTENT_MARGIN, self._rect.width - 2 * CONTENT_MARGIN, HEADER_HEIGHT
) )
content_y = rect.y + CONTENT_MARGIN + HEADER_HEIGHT + SPACING content_y = self._rect.y + CONTENT_MARGIN + HEADER_HEIGHT + SPACING
content_height = rect.height - CONTENT_MARGIN - HEADER_HEIGHT - SPACING - CONTENT_MARGIN content_height = self._rect.height - CONTENT_MARGIN - HEADER_HEIGHT - SPACING - CONTENT_MARGIN
self.content_rect = rl.Rectangle( self.content_rect = rl.Rectangle(
rect.x + CONTENT_MARGIN, content_y, rect.width - 2 * CONTENT_MARGIN, content_height self._rect.x + CONTENT_MARGIN, content_y, self._rect.width - 2 * CONTENT_MARGIN, content_height
) )
left_width = self.content_rect.width - RIGHT_COLUMN_WIDTH - SPACING left_width = self.content_rect.width - RIGHT_COLUMN_WIDTH - SPACING
@@ -170,28 +175,25 @@ class HomeLayout:
self.offroad_alert.render(self.content_rect) self.offroad_alert.render(self.content_rect)
def _render_left_column(self): def _render_left_column(self):
rl.draw_rectangle_rounded(self.left_column_rect, 0.02, 10, PRIME_BG_COLOR) self._prime_widget.render(self.left_column_rect)
gui_label(self.left_column_rect, "Prime Widget", 48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
def _render_right_column(self): def _render_right_column(self):
widget_height = (self.right_column_rect.height - SPACING) // 2 exp_height = 125
exp_rect = rl.Rectangle( exp_rect = rl.Rectangle(
self.right_column_rect.x, self.right_column_rect.y, self.right_column_rect.width, widget_height self.right_column_rect.x, self.right_column_rect.y, self.right_column_rect.width, exp_height
) )
rl.draw_rectangle_rounded(exp_rect, 0.02, 10, PRIME_BG_COLOR) self._exp_mode_button.render(exp_rect)
gui_label(exp_rect, "Experimental Mode", 36, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
setup_rect = rl.Rectangle( setup_rect = rl.Rectangle(
self.right_column_rect.x, self.right_column_rect.x,
self.right_column_rect.y + widget_height + SPACING, self.right_column_rect.y + exp_height + SPACING,
self.right_column_rect.width, self.right_column_rect.width,
widget_height, self.right_column_rect.height - exp_height - SPACING,
) )
rl.draw_rectangle_rounded(setup_rect, 0.02, 10, PRIME_BG_COLOR) self._setup_widget.render(setup_rect)
gui_label(setup_rect, "Setup", 36, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
def _refresh(self): def _refresh(self):
# TODO: implement _update_state with a timer
self.update_available = self.update_alert.refresh() self.update_available = self.update_alert.refresh()
self.alert_count = self.offroad_alert.refresh() self.alert_count = self.offroad_alert.refresh()
self._update_state_priority(self.update_available, self.alert_count > 0) self._update_state_priority(self.update_available, self.alert_count > 0)
+38 -41
View File
@@ -1,10 +1,12 @@
import pyray as rl import pyray as rl
from enum import IntEnum from enum import IntEnum
import cereal.messaging as messaging
from openpilot.selfdrive.ui.layouts.sidebar import Sidebar, SIDEBAR_WIDTH from openpilot.selfdrive.ui.layouts.sidebar import Sidebar, SIDEBAR_WIDTH
from openpilot.selfdrive.ui.layouts.home import HomeLayout from openpilot.selfdrive.ui.layouts.home import HomeLayout
from openpilot.selfdrive.ui.layouts.settings.settings import SettingsLayout from openpilot.selfdrive.ui.layouts.settings.settings import SettingsLayout, PanelType
from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.ui_state import device, ui_state
from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView
from openpilot.system.ui.lib.widget import Widget
class MainState(IntEnum): class MainState(IntEnum):
@@ -13,14 +15,15 @@ class MainState(IntEnum):
ONROAD = 2 ONROAD = 2
class MainLayout: class MainLayout(Widget):
def __init__(self): def __init__(self):
super().__init__()
self._pm = messaging.PubMaster(['userFlag'])
self._sidebar = Sidebar() self._sidebar = Sidebar()
self._sidebar_visible = True
self._current_mode = MainState.HOME self._current_mode = MainState.HOME
self._prev_onroad = False self._prev_onroad = False
self._window_rect = None
self._current_callback: callable | None = None
# Initialize layouts # Initialize layouts
self._layouts = {MainState.HOME: HomeLayout(), MainState.SETTINGS: SettingsLayout(), MainState.ONROAD: AugmentedRoadView()} self._layouts = {MainState.HOME: HomeLayout(), MainState.SETTINGS: SettingsLayout(), MainState.ONROAD: AugmentedRoadView()}
@@ -31,32 +34,23 @@ class MainLayout:
# Set callbacks # Set callbacks
self._setup_callbacks() self._setup_callbacks()
def render(self, rect): def _render(self, _):
self._current_callback = None
self._update_layout_rects(rect)
self._handle_onroad_transition() self._handle_onroad_transition()
self._render_main_content() self._render_main_content()
self._handle_input()
if self._current_callback:
self._current_callback()
def _setup_callbacks(self): def _setup_callbacks(self):
self._sidebar.set_callbacks( self._sidebar.set_callbacks(on_settings=self._on_settings_clicked,
on_settings=lambda: setattr(self, '_current_callback', self._on_settings_clicked), on_flag=self._on_flag_clicked)
on_flag=lambda: setattr(self, '_current_callback', self._on_flag_clicked), self._layouts[MainState.HOME]._setup_widget.set_open_settings_callback(lambda: self.open_settings(PanelType.FIREHOSE))
) self._layouts[MainState.SETTINGS].set_callbacks(on_close=self._set_mode_for_state)
self._layouts[MainState.SETTINGS].set_callbacks( self._layouts[MainState.ONROAD].set_callbacks(on_click=self._on_onroad_clicked)
on_close=lambda: setattr(self, '_current_callback', self._set_mode_for_state) device.add_interactive_timeout_callback(self._set_mode_for_state)
)
def _update_layout_rects(self, rect): def _update_layout_rects(self):
self._window_rect = rect self._sidebar_rect = rl.Rectangle(self._rect.x, self._rect.y, SIDEBAR_WIDTH, self._rect.height)
self._sidebar_rect = rl.Rectangle(rect.x, rect.y, SIDEBAR_WIDTH, rect.height)
x_offset = SIDEBAR_WIDTH if self._sidebar_visible else 0 x_offset = SIDEBAR_WIDTH if self._sidebar.is_visible else 0
self._content_rect = rl.Rectangle(rect.y + x_offset, rect.y, rect.width - x_offset, rect.height) self._content_rect = rl.Rectangle(self._rect.y + x_offset, self._rect.y, self._rect.width - x_offset, self._rect.height)
def _handle_onroad_transition(self): def _handle_onroad_transition(self):
if ui_state.started != self._prev_onroad: if ui_state.started != self._prev_onroad:
@@ -66,31 +60,34 @@ class MainLayout:
def _set_mode_for_state(self): def _set_mode_for_state(self):
if ui_state.started: if ui_state.started:
# Don't hide sidebar from interactive timeout
if self._current_mode != MainState.ONROAD:
self._sidebar.set_visible(False)
self._current_mode = MainState.ONROAD self._current_mode = MainState.ONROAD
self._sidebar_visible = False
else: else:
self._current_mode = MainState.HOME self._current_mode = MainState.HOME
self._sidebar_visible = True self._sidebar.set_visible(True)
def open_settings(self, panel_type: PanelType):
self._layouts[MainState.SETTINGS].set_current_panel(panel_type)
self._current_mode = MainState.SETTINGS
self._sidebar.set_visible(False)
def _on_settings_clicked(self): def _on_settings_clicked(self):
self._current_mode = MainState.SETTINGS self.open_settings(PanelType.DEVICE)
self._sidebar_visible = False
def _on_flag_clicked(self): def _on_flag_clicked(self):
pass user_flag = messaging.new_message('userFlag')
user_flag.valid = True
self._pm.send('userFlag', user_flag)
def _on_onroad_clicked(self):
self._sidebar.set_visible(not self._sidebar.is_visible)
def _render_main_content(self): def _render_main_content(self):
# Render sidebar # Render sidebar
if self._sidebar_visible: if self._sidebar.is_visible:
self._sidebar.render(self._sidebar_rect) self._sidebar.render(self._sidebar_rect)
content_rect = self._content_rect if self._sidebar_visible else self._window_rect content_rect = self._content_rect if self._sidebar.is_visible else self._rect
self._layouts[self._current_mode].render(content_rect) self._layouts[self._current_mode].render(content_rect)
def _handle_input(self):
if self._current_mode != MainState.ONROAD or not rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
return
mouse_pos = rl.get_mouse_position()
if rl.check_collision_point_rec(mouse_pos, self._content_rect):
self._sidebar_visible = not self._sidebar_visible
+4 -6
View File
@@ -1,19 +1,17 @@
import pyray as rl import pyray as rl
from openpilot.system.ui.lib.widget import Widget
from openpilot.system.ui.lib.wifi_manager import WifiManagerWrapper from openpilot.system.ui.lib.wifi_manager import WifiManagerWrapper
from openpilot.system.ui.widgets.network import WifiManagerUI from openpilot.system.ui.widgets.network import WifiManagerUI
class NetworkLayout: class NetworkLayout(Widget):
def __init__(self): def __init__(self):
super().__init__()
self.wifi_manager = WifiManagerWrapper() self.wifi_manager = WifiManagerWrapper()
self.wifi_ui = WifiManagerUI(self.wifi_manager) self.wifi_ui = WifiManagerUI(self.wifi_manager)
def render(self, rect: rl.Rectangle): def _render(self, rect: rl.Rectangle):
self.wifi_ui.render(rect) self.wifi_ui.render(rect)
@property
def require_full_screen(self):
return self.wifi_ui.require_full_screen
def shutdown(self): def shutdown(self):
self.wifi_manager.shutdown() self.wifi_manager.shutdown()
+14 -5
View File
@@ -1,5 +1,8 @@
from openpilot.system.ui.lib.list_view import ListView, toggle_item from openpilot.system.ui.lib.list_view import toggle_item
from openpilot.system.ui.lib.scroller import Scroller
from openpilot.system.ui.lib.widget import Widget
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.selfdrive.ui.widgets.ssh_key import ssh_key_item
# Description constants # Description constants
DESCRIPTIONS = { DESCRIPTIONS = {
@@ -8,11 +11,16 @@ DESCRIPTIONS = {
"See https://docs.comma.ai/how-to/connect-to-comma for more info." "See https://docs.comma.ai/how-to/connect-to-comma for more info."
), ),
'joystick_debug_mode': "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)", 'joystick_debug_mode': "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)",
'ssh_key': (
"Warning: This grants SSH access to all public keys in your GitHub settings. Never enter a GitHub username " +
"other than your own. A comma employee will NEVER ask you to add their GitHub username."
),
} }
class DeveloperLayout: class DeveloperLayout(Widget):
def __init__(self): def __init__(self):
super().__init__()
self._params = Params() self._params = Params()
items = [ items = [
toggle_item( toggle_item(
@@ -21,6 +29,7 @@ class DeveloperLayout:
initial_state=self._params.get_bool("AdbEnabled"), initial_state=self._params.get_bool("AdbEnabled"),
callback=self._on_enable_adb, callback=self._on_enable_adb,
), ),
ssh_key_item("SSH Key", description=DESCRIPTIONS["ssh_key"]),
toggle_item( toggle_item(
"Joystick Debug Mode", "Joystick Debug Mode",
description=DESCRIPTIONS["joystick_debug_mode"], description=DESCRIPTIONS["joystick_debug_mode"],
@@ -41,10 +50,10 @@ class DeveloperLayout:
), ),
] ]
self._list_widget = ListView(items) self._scroller = Scroller(items, line_separator=True, spacing=0)
def render(self, rect): def _render(self, rect):
self._list_widget.render(rect) self._scroller.render(rect)
def _on_enable_adb(self): pass def _on_enable_adb(self): pass
def _on_joystick_debug_mode(self): pass def _on_joystick_debug_mode(self): pass
+124 -21
View File
@@ -1,47 +1,150 @@
from openpilot.system.ui.lib.list_view import ListView, text_item, button_item import os
import json
from openpilot.common.basedir import BASEDIR
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.hardware import TICI from openpilot.system.hardware import TICI
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.list_view import text_item, button_item, dual_button_item
from openpilot.system.ui.lib.scroller import Scroller
from openpilot.system.ui.lib.widget import Widget, DialogResult
from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.system.ui.widgets.confirm_dialog import confirm_dialog, alert_dialog
from openpilot.system.ui.widgets.html_render import HtmlRenderer
# Description constants # Description constants
DESCRIPTIONS = { DESCRIPTIONS = {
'pair_device': "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer.", 'pair_device': "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer.",
'driver_camera': "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)", 'driver_camera': "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)",
'reset_calibration': ( 'reset_calibration': (
"openpilot requires the device to be mounted within 4° left or right and within 5° " + "openpilot requires the device to be mounted within 4° left or right and within 5° " +
"up or 9° down. openpilot is continuously calibrating, resetting is rarely required." "up or 9° down. openpilot is continuously calibrating, resetting is rarely required."
), ),
'review_guide': "Review the rules, features, and limitations of openpilot", 'review_guide': "Review the rules, features, and limitations of openpilot",
} }
class DeviceLayout: class DeviceLayout(Widget):
def __init__(self): def __init__(self):
params = Params() super().__init__()
dongle_id = params.get("DongleId", encoding="utf-8") or "N/A"
serial = params.get("HardwareSerial") or "N/A" self._params = Params()
self._select_language_dialog: MultiOptionDialog | None = None
self._driver_camera: DriverCameraDialog | None = None
self._pair_device_dialog: PairingDialog | None = None
self._fcc_dialog: HtmlRenderer | None = None
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _initialize_items(self):
dongle_id = self._params.get("DongleId", encoding="utf-8") or "N/A"
serial = self._params.get("HardwareSerial") or "N/A"
items = [ items = [
text_item("Dongle ID", dongle_id), text_item("Dongle ID", dongle_id),
text_item("Serial", serial), text_item("Serial", serial),
button_item("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], self._on_pair_device), button_item("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], callback=self._pair_device),
button_item("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], self._on_driver_camera), button_item("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], callback=self._show_driver_camera, enabled=ui_state.is_offroad),
button_item("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], self._on_reset_calibration), button_item("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], callback=self._reset_calibration_prompt),
regulatory_btn := button_item("Regulatory", "VIEW", callback=self._on_regulatory),
button_item("Review Training Guide", "REVIEW", DESCRIPTIONS['review_guide'], self._on_review_training_guide), button_item("Review Training Guide", "REVIEW", DESCRIPTIONS['review_guide'], self._on_review_training_guide),
button_item("Change Language", "CHANGE", callback=self._show_language_selection, enabled=ui_state.is_offroad),
dual_button_item("Reboot", "Power Off", left_callback=self._reboot_prompt, right_callback=self._power_off_prompt),
] ]
regulatory_btn.set_visible(TICI)
return items
if TICI: def _render(self, rect):
items.append(button_item("Regulatory", "VIEW", callback=self._on_regulatory)) self._scroller.render(rect)
items.append(button_item("Change Language", "CHANGE", callback=self._on_change_language)) def _show_language_selection(self):
try:
languages_file = os.path.join(BASEDIR, "selfdrive/ui/translations/languages.json")
with open(languages_file, encoding='utf-8') as f:
languages = json.load(f)
self._list_widget = ListView(items) self._select_language_dialog = MultiOptionDialog("Select a language", languages)
gui_app.set_modal_overlay(self._select_language_dialog, callback=self._handle_language_selection)
except FileNotFoundError:
pass
def render(self, rect): def _handle_language_selection(self, result: int):
self._list_widget.render(rect) if result == 1 and self._select_language_dialog:
selected_language = self._select_language_dialog.selection
self._params.put("LanguageSetting", selected_language)
self._select_language_dialog = None
def _show_driver_camera(self):
if not self._driver_camera:
self._driver_camera = DriverCameraDialog()
gui_app.set_modal_overlay(self._driver_camera, callback=lambda result: setattr(self, '_driver_camera', None))
def _reset_calibration_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(lambda: alert_dialog("Disengage to Reset Calibration"))
return
gui_app.set_modal_overlay(
lambda: confirm_dialog("Are you sure you want to reset calibration?", "Reset"),
callback=self._reset_calibration,
)
def _reset_calibration(self, result: int):
if ui_state.engaged or result != DialogResult.CONFIRM:
return
self._params.remove("CalibrationParams")
self._params.remove("LiveTorqueParameters")
self._params.remove("LiveParameters")
self._params.remove("LiveParametersV2")
self._params.remove("LiveDelay")
self._params.put_bool("OnroadCycleRequested", True)
def _reboot_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(lambda: alert_dialog("Disengage to Reboot"))
return
gui_app.set_modal_overlay(
lambda: confirm_dialog("Are you sure you want to reboot?", "Reboot"),
callback=self._perform_reboot,
)
def _perform_reboot(self, result: int):
if not ui_state.engaged and result == DialogResult.CONFIRM:
self._params.put_bool_nonblocking("DoReboot", True)
def _power_off_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(lambda: alert_dialog("Disengage to Power Off"))
return
gui_app.set_modal_overlay(
lambda: confirm_dialog("Are you sure you want to power off?", "Power Off"),
callback=self._perform_power_off,
)
def _perform_power_off(self, result: int):
if not ui_state.engaged and result == DialogResult.CONFIRM:
self._params.put_bool_nonblocking("DoShutdown", True)
def _pair_device(self):
if not self._pair_device_dialog:
self._pair_device_dialog = PairingDialog()
gui_app.set_modal_overlay(self._pair_device_dialog, callback=lambda result: setattr(self, '_pair_device_dialog', None))
def _on_regulatory(self):
if not self._fcc_dialog:
self._fcc_dialog = HtmlRenderer(os.path.join(BASEDIR, "selfdrive/assets/offroad/fcc.html"))
gui_app.set_modal_overlay(self._fcc_dialog,
callback=lambda result: setattr(self, '_fcc_dialog', None),
)
def _on_pair_device(self): pass
def _on_driver_camera(self): pass
def _on_reset_calibration(self): pass
def _on_review_training_guide(self): pass def _on_review_training_guide(self): pass
def _on_regulatory(self): pass
def _on_change_language(self): pass
+180
View File
@@ -0,0 +1,180 @@
import pyray as rl
import json
import time
import threading
from openpilot.common.api import Api, api_get
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.widget import Widget
from openpilot.selfdrive.ui.ui_state import ui_state
TITLE = "Firehose Mode"
DESCRIPTION = (
"openpilot learns to drive by watching humans, like you, drive.\n\n"
+ "Firehose Mode allows you to maximize your training data uploads to improve "
+ "openpilot's driving models. More data means bigger models, which means better Experimental Mode."
)
INSTRUCTIONS = (
"For maximum effectiveness, bring your device inside and connect to a good USB-C adapter and Wi-Fi weekly.\n\n"
+ "Firehose Mode can also work while you're driving if connected to a hotspot or unlimited SIM card.\n\n"
+ "Frequently Asked Questions\n\n"
+ "Does it matter how or where I drive? Nope, just drive as you normally would.\n\n"
+ "Do all of my segments get pulled in Firehose Mode? No, we selectively pull a subset of your segments.\n\n"
+ "What's a good USB-C adapter? Any fast phone or laptop charger should be fine.\n\n"
+ "Does it matter which software I run? Yes, only upstream openpilot (and particular forks) are able to be used for training."
)
class FirehoseLayout(Widget):
PARAM_KEY = "ApiCache_FirehoseStats"
GREEN = rl.Color(46, 204, 113, 255)
RED = rl.Color(231, 76, 60, 255)
GRAY = rl.Color(68, 68, 68, 255)
LIGHT_GRAY = rl.Color(228, 228, 228, 255)
UPDATE_INTERVAL = 30 # seconds
def __init__(self):
super().__init__()
self.params = Params()
self.segment_count = self._get_segment_count()
self.scroll_panel = GuiScrollPanel()
self.running = True
self.update_thread = threading.Thread(target=self._update_loop, daemon=True)
self.update_thread.start()
self.last_update_time = 0
def _get_segment_count(self) -> int:
stats = self.params.get(self.PARAM_KEY, encoding='utf8')
if not stats:
return 0
try:
return int(json.loads(stats).get("firehose", 0))
except Exception:
cloudlog.exception(f"Failed to decode firehose stats: {stats}")
return 0
def __del__(self):
self.running = False
if self.update_thread and self.update_thread.is_alive():
self.update_thread.join(timeout=1.0)
def _render(self, rect: rl.Rectangle):
# Calculate content dimensions
content_width = rect.width - 80
content_height = self._calculate_content_height(int(content_width))
content_rect = rl.Rectangle(rect.x, rect.y, rect.width, content_height)
# Handle scrolling and render with clipping
scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect)
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
self._render_content(rect, scroll_offset)
rl.end_scissor_mode()
def _calculate_content_height(self, content_width: int) -> int:
height = 80 # Top margin
# Title
height += 100 + 40
# Description
desc_font = gui_app.font(FontWeight.NORMAL)
desc_lines = wrap_text(desc_font, DESCRIPTION, 45, content_width)
height += len(desc_lines) * 45 + 40
# Status section
height += 32 # Separator
status_text, _ = self._get_status()
status_lines = wrap_text(gui_app.font(FontWeight.BOLD), status_text, 60, content_width)
height += len(status_lines) * 60 + 20
# Contribution count (if available)
if self.segment_count > 0:
contrib_text = f"{self.segment_count} segment(s) of your driving is in the training dataset so far."
contrib_lines = wrap_text(gui_app.font(FontWeight.BOLD), contrib_text, 52, content_width)
height += len(contrib_lines) * 52 + 20
# Instructions section
height += 32 # Separator
inst_lines = wrap_text(gui_app.font(FontWeight.NORMAL), INSTRUCTIONS, 40, content_width)
height += len(inst_lines) * 40 + 40 # Bottom margin
return height
def _render_content(self, rect: rl.Rectangle, scroll_offset: rl.Vector2):
x = int(rect.x + 40)
y = int(rect.y + 40 + scroll_offset.y)
w = int(rect.width - 80)
# Title
title_font = gui_app.font(FontWeight.MEDIUM)
rl.draw_text_ex(title_font, TITLE, rl.Vector2(x, y), 100, 0, rl.WHITE)
y += 140
# Description
y = self._draw_wrapped_text(x, y, w, DESCRIPTION, gui_app.font(FontWeight.NORMAL), 45, rl.WHITE)
y += 40
# Separator
rl.draw_rectangle(x, y, w, 2, self.GRAY)
y += 30
# Status
status_text, status_color = self._get_status()
y = self._draw_wrapped_text(x, y, w, status_text, gui_app.font(FontWeight.BOLD), 60, status_color)
y += 20
# Contribution count (if available)
if self.segment_count > 0:
contrib_text = f"{self.segment_count} segment(s) of your driving is in the training dataset so far."
y = self._draw_wrapped_text(x, y, w, contrib_text, gui_app.font(FontWeight.BOLD), 52, rl.WHITE)
y += 20
# Separator
rl.draw_rectangle(x, y, w, 2, self.GRAY)
y += 30
# Instructions
self._draw_wrapped_text(x, y, w, INSTRUCTIONS, gui_app.font(FontWeight.NORMAL), 40, self.LIGHT_GRAY)
def _draw_wrapped_text(self, x, y, width, text, font, size, color):
wrapped = wrap_text(font, text, size, width)
for line in wrapped:
rl.draw_text_ex(font, line, rl.Vector2(x, y), size, 0, color)
y += size
return y
def _get_status(self) -> tuple[str, rl.Color]:
network_type = ui_state.sm["deviceState"].networkType
network_metered = ui_state.sm["deviceState"].networkMetered
if not network_metered and network_type != 0: # Not metered and connected
return "ACTIVE", self.GREEN
else:
return "INACTIVE: connect to an unmetered network", self.RED
def _fetch_firehose_stats(self):
try:
dongle_id = self.params.get("DongleId", encoding='utf8')
if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID:
return
identity_token = Api(dongle_id).get_token()
response = api_get(f"v1/devices/{dongle_id}/firehose_stats", access_token=identity_token)
if response.status_code == 200:
data = response.json()
self.segment_count = data.get("firehose", 0)
self.params.put(self.PARAM_KEY, json.dumps(data))
except Exception as e:
cloudlog.error(f"Failed to fetch firehose stats: {e}")
def _update_loop(self):
while self.running:
if not ui_state.started:
self._fetch_firehose_stats()
time.sleep(self.UPDATE_INTERVAL)
+20 -47
View File
@@ -2,15 +2,15 @@ import pyray as rl
from dataclasses import dataclass from dataclasses import dataclass
from enum import IntEnum from enum import IntEnum
from collections.abc import Callable from collections.abc import Callable
from openpilot.common.params import Params
from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout
from openpilot.selfdrive.ui.layouts.settings.device import DeviceLayout from openpilot.selfdrive.ui.layouts.settings.device import DeviceLayout
from openpilot.selfdrive.ui.layouts.settings.firehose import FirehoseLayout
from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout
from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout
from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.label import gui_text_box
from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.selfdrive.ui.layouts.network import NetworkLayout from openpilot.selfdrive.ui.layouts.network import NetworkLayout
from openpilot.system.ui.lib.widget import Widget
# Import individual panels # Import individual panels
@@ -18,9 +18,8 @@ SETTINGS_CLOSE_TEXT = "X"
# Constants # Constants
SIDEBAR_WIDTH = 500 SIDEBAR_WIDTH = 500
CLOSE_BTN_SIZE = 200 CLOSE_BTN_SIZE = 200
NAV_BTN_HEIGHT = 80 NAV_BTN_HEIGHT = 110
PANEL_MARGIN = 50 PANEL_MARGIN = 50
SCROLL_SPEED = 30
# Colors # Colors
SIDEBAR_COLOR = rl.BLACK SIDEBAR_COLOR = rl.BLACK
@@ -29,7 +28,6 @@ CLOSE_BTN_COLOR = rl.Color(41, 41, 41, 255)
CLOSE_BTN_PRESSED = rl.Color(59, 59, 59, 255) CLOSE_BTN_PRESSED = rl.Color(59, 59, 59, 255)
TEXT_NORMAL = rl.Color(128, 128, 128, 255) TEXT_NORMAL = rl.Color(128, 128, 128, 255)
TEXT_SELECTED = rl.Color(255, 255, 255, 255) TEXT_SELECTED = rl.Color(255, 255, 255, 255)
TEXT_PRESSED = rl.Color(173, 173, 173, 255)
class PanelType(IntEnum): class PanelType(IntEnum):
@@ -45,23 +43,22 @@ class PanelType(IntEnum):
class PanelInfo: class PanelInfo:
name: str name: str
instance: object instance: object
button_rect: rl.Rectangle button_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
class SettingsLayout: class SettingsLayout(Widget):
def __init__(self): def __init__(self):
self._params = Params() super().__init__()
self._current_panel = PanelType.DEVICE self._current_panel = PanelType.DEVICE
self._max_scroll = 0.0
# Panel configuration # Panel configuration
self._panels = { self._panels = {
PanelType.DEVICE: PanelInfo("Device", DeviceLayout(), rl.Rectangle(0, 0, 0, 0)), PanelType.DEVICE: PanelInfo("Device", DeviceLayout()),
PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayout(), rl.Rectangle(0, 0, 0, 0)), PanelType.NETWORK: PanelInfo("Network", NetworkLayout()),
PanelType.SOFTWARE: PanelInfo("Software", SoftwareLayout(), rl.Rectangle(0, 0, 0, 0)), PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayout()),
PanelType.FIREHOSE: PanelInfo("Firehose", None, rl.Rectangle(0, 0, 0, 0)), PanelType.SOFTWARE: PanelInfo("Software", SoftwareLayout()),
PanelType.NETWORK: PanelInfo("Network", NetworkLayout(), rl.Rectangle(0, 0, 0, 0)), PanelType.FIREHOSE: PanelInfo("Firehose", FirehoseLayout()),
PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayout(), rl.Rectangle(0, 0, 0, 0)), PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayout()),
} }
self._font_medium = gui_app.font(FontWeight.MEDIUM) self._font_medium = gui_app.font(FontWeight.MEDIUM)
@@ -73,7 +70,7 @@ class SettingsLayout:
def set_callbacks(self, on_close: Callable): def set_callbacks(self, on_close: Callable):
self._close_callback = on_close self._close_callback = on_close
def render(self, rect: rl.Rectangle): def _render(self, rect: rl.Rectangle):
# Calculate layout # Calculate layout
sidebar_rect = rl.Rectangle(rect.x, rect.y, SIDEBAR_WIDTH, rect.height) sidebar_rect = rl.Rectangle(rect.x, rect.y, SIDEBAR_WIDTH, rect.height)
panel_rect = rl.Rectangle(rect.x + SIDEBAR_WIDTH, rect.y, rect.width - SIDEBAR_WIDTH, rect.height) panel_rect = rl.Rectangle(rect.x + SIDEBAR_WIDTH, rect.y, rect.width - SIDEBAR_WIDTH, rect.height)
@@ -82,9 +79,6 @@ class SettingsLayout:
self._draw_sidebar(sidebar_rect) self._draw_sidebar(sidebar_rect)
self._draw_current_panel(panel_rect) self._draw_current_panel(panel_rect)
if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT):
self.handle_mouse_release(rl.get_mouse_position())
def _draw_sidebar(self, rect: rl.Rectangle): def _draw_sidebar(self, rect: rl.Rectangle):
rl.draw_rectangle_rec(rect, SIDEBAR_COLOR) rl.draw_rectangle_rec(rect, SIDEBAR_COLOR)
@@ -109,17 +103,9 @@ class SettingsLayout:
self._close_btn_rect = close_btn_rect self._close_btn_rect = close_btn_rect
# Navigation buttons # Navigation buttons
nav_start_y = rect.y + 300 y = rect.y + 300
button_spacing = 20
i = 0
for panel_type, panel_info in self._panels.items(): for panel_type, panel_info in self._panels.items():
button_rect = rl.Rectangle( button_rect = rl.Rectangle(rect.x + 50, y, rect.width - 150, NAV_BTN_HEIGHT)
rect.x + 50,
nav_start_y + i * (NAV_BTN_HEIGHT + button_spacing),
rect.width - 150, # Right-aligned with margin
NAV_BTN_HEIGHT,
)
# Button styling # Button styling
is_selected = panel_type == self._current_panel is_selected = panel_type == self._current_panel
@@ -133,7 +119,8 @@ class SettingsLayout:
# Store button rect for click detection # Store button rect for click detection
panel_info.button_rect = button_rect panel_info.button_rect = button_rect
i += 1
y += NAV_BTN_HEIGHT
def _draw_current_panel(self, rect: rl.Rectangle): def _draw_current_panel(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded( rl.draw_rectangle_rounded(
@@ -144,17 +131,8 @@ class SettingsLayout:
panel = self._panels[self._current_panel] panel = self._panels[self._current_panel]
if panel.instance: if panel.instance:
panel.instance.render(content_rect) panel.instance.render(content_rect)
else:
gui_text_box(
content_rect,
f"Demo {self._panels[self._current_panel].name} Panel",
font_size=170,
color=rl.WHITE,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
)
def handle_mouse_release(self, mouse_pos: rl.Vector2) -> bool: def _handle_mouse_release(self, mouse_pos: rl.Vector2) -> bool:
# Check close button # Check close button
if rl.check_collision_point_rec(mouse_pos, self._close_btn_rect): if rl.check_collision_point_rec(mouse_pos, self._close_btn_rect):
if self._close_callback: if self._close_callback:
@@ -164,20 +142,15 @@ class SettingsLayout:
# Check navigation buttons # Check navigation buttons
for panel_type, panel_info in self._panels.items(): for panel_type, panel_info in self._panels.items():
if rl.check_collision_point_rec(mouse_pos, panel_info.button_rect): if rl.check_collision_point_rec(mouse_pos, panel_info.button_rect):
self._switch_to_panel(panel_type) self.set_current_panel(panel_type)
return True return True
return False return False
def _switch_to_panel(self, panel_type: PanelType): def set_current_panel(self, panel_type: PanelType):
if panel_type != self._current_panel: if panel_type != self._current_panel:
self._current_panel = panel_type self._current_panel = panel_type
def set_current_panel(self, index: int, param: str = ""):
panel_types = list(self._panels.keys())
if 0 <= index < len(panel_types):
self._switch_to_panel(panel_types[index])
def close_settings(self): def close_settings(self):
if self._close_callback: if self._close_callback:
self._close_callback() self._close_callback()
+28 -7
View File
@@ -1,7 +1,20 @@
from openpilot.system.ui.lib.list_view import ListView, button_item, text_item from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.list_view import button_item, text_item
from openpilot.system.ui.lib.scroller import Scroller
from openpilot.system.ui.lib.widget import Widget, DialogResult
from openpilot.system.ui.widgets.confirm_dialog import confirm_dialog
class SoftwareLayout:
class SoftwareLayout(Widget):
def __init__(self): def __init__(self):
super().__init__()
self._params = Params()
items = self._init_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
items = [ items = [
text_item("Current Version", ""), text_item("Current Version", ""),
button_item("Download", "CHECK", callback=self._on_download_update), button_item("Download", "CHECK", callback=self._on_download_update),
@@ -9,13 +22,21 @@ class SoftwareLayout:
button_item("Target Branch", "SELECT", callback=self._on_select_branch), button_item("Target Branch", "SELECT", callback=self._on_select_branch),
button_item("Uninstall", "UNINSTALL", callback=self._on_uninstall), button_item("Uninstall", "UNINSTALL", callback=self._on_uninstall),
] ]
return items
self._list_widget = ListView(items) def _render(self, rect):
self._scroller.render(rect)
def render(self, rect):
self._list_widget.render(rect)
def _on_download_update(self): pass def _on_download_update(self): pass
def _on_install_update(self): pass def _on_install_update(self): pass
def _on_select_branch(self): pass def _on_select_branch(self): pass
def _on_uninstall(self): pass
def _on_uninstall(self):
def handle_uninstall_confirmation(result):
if result == DialogResult.CONFIRM:
self._params.put_bool("DoUninstall", True)
gui_app.set_modal_overlay(
lambda: confirm_dialog("Are you sure you want to uninstall?", "Uninstall"),
callback=handle_uninstall_confirmation,
)
+26 -6
View File
@@ -1,4 +1,6 @@
from openpilot.system.ui.lib.list_view import ListView, toggle_item from openpilot.system.ui.lib.list_view import multiple_button_item, toggle_item
from openpilot.system.ui.lib.scroller import Scroller
from openpilot.system.ui.lib.widget import Widget
from openpilot.common.params import Params from openpilot.common.params import Params
# Description constants # Description constants
@@ -8,9 +10,14 @@ DESCRIPTIONS = {
"Your attention is required at all times to use this feature." "Your attention is required at all times to use this feature."
), ),
"DisengageOnAccelerator": "When enabled, pressing the accelerator pedal will disengage openpilot.", "DisengageOnAccelerator": "When enabled, pressing the accelerator pedal will disengage openpilot.",
"LongitudinalPersonality": (
"Standard is recommended. In aggressive mode, openpilot will follow lead cars closer and be more aggressive with the gas and brake. " +
"In relaxed mode openpilot will stay further away from lead cars. On supported cars, you can cycle through these personalities with " +
"your steering wheel distance button."
),
"IsLdwEnabled": ( "IsLdwEnabled": (
"Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " + "Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " +
"without a turn signal activated while driving over 31 mph (50 km/h)." "without a turn signal activated while driving over 31 mph (50 km/h)."
), ),
"AlwaysOnDM": "Enable driver monitoring even when openpilot is not engaged.", "AlwaysOnDM": "Enable driver monitoring even when openpilot is not engaged.",
'RecordFront': "Upload data from the driver facing camera and help improve the driver monitoring algorithm.", 'RecordFront': "Upload data from the driver facing camera and help improve the driver monitoring algorithm.",
@@ -18,8 +25,9 @@ DESCRIPTIONS = {
} }
class TogglesLayout: class TogglesLayout(Widget):
def __init__(self): def __init__(self):
super().__init__()
self._params = Params() self._params = Params()
items = [ items = [
toggle_item( toggle_item(
@@ -39,6 +47,15 @@ class TogglesLayout:
self._params.get_bool("DisengageOnAccelerator"), self._params.get_bool("DisengageOnAccelerator"),
icon="disengage_on_accelerator.png", icon="disengage_on_accelerator.png",
), ),
multiple_button_item(
"Driving Personality",
DESCRIPTIONS["LongitudinalPersonality"],
buttons=["Aggressive", "Standard", "Relaxed"],
button_width=255,
callback=self._set_longitudinal_personality,
selected_index=int(self._params.get("LongitudinalPersonality") or 0),
icon="speed_limit.png"
),
toggle_item( toggle_item(
"Enable Lane Departure Warnings", "Enable Lane Departure Warnings",
DESCRIPTIONS["IsLdwEnabled"], DESCRIPTIONS["IsLdwEnabled"],
@@ -62,7 +79,10 @@ class TogglesLayout:
), ),
] ]
self._list_widget = ListView(items) self._scroller = Scroller(items, line_separator=True, spacing=0)
def render(self, rect): def _render(self, rect):
self._list_widget.render(rect) self._scroller.render(rect)
def _set_longitudinal_personality(self, button_index: int):
self._params.put("LongitudinalPersonality", str(button_index))
+11 -15
View File
@@ -6,6 +6,7 @@ from cereal import log
from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.widget import Widget
SIDEBAR_WIDTH = 300 SIDEBAR_WIDTH = 300
METRIC_HEIGHT = 126 METRIC_HEIGHT = 126
@@ -18,6 +19,7 @@ HOME_BTN = rl.Rectangle(60, 860, 180, 180)
ThermalStatus = log.DeviceState.ThermalStatus ThermalStatus = log.DeviceState.ThermalStatus
NetworkType = log.DeviceState.NetworkType NetworkType = log.DeviceState.NetworkType
# Color scheme # Color scheme
class Colors: class Colors:
SIDEBAR_BG = rl.Color(57, 57, 57, 255) SIDEBAR_BG = rl.Color(57, 57, 57, 255)
@@ -35,6 +37,7 @@ class Colors:
BUTTON_NORMAL = rl.Color(255, 255, 255, 255) BUTTON_NORMAL = rl.Color(255, 255, 255, 255)
BUTTON_PRESSED = rl.Color(255, 255, 255, 166) BUTTON_PRESSED = rl.Color(255, 255, 255, 166)
NETWORK_TYPES = { NETWORK_TYPES = {
NetworkType.none: "Offline", NetworkType.none: "Offline",
NetworkType.wifi: "WiFi", NetworkType.wifi: "WiFi",
@@ -57,8 +60,10 @@ class MetricData:
self.value = value self.value = value
self.color = color self.color = color
class Sidebar:
class Sidebar(Widget):
def __init__(self): def __init__(self):
super().__init__()
self._net_type = NETWORK_TYPES.get(NetworkType.none) self._net_type = NETWORK_TYPES.get(NetworkType.none)
self._net_strength = 0 self._net_strength = 0
@@ -72,7 +77,7 @@ class Sidebar:
self._font_regular = gui_app.font(FontWeight.NORMAL) self._font_regular = gui_app.font(FontWeight.NORMAL)
self._font_bold = gui_app.font(FontWeight.SEMI_BOLD) self._font_bold = gui_app.font(FontWeight.SEMI_BOLD)
# Callbacks # Callbacks
self._on_settings_click: Callable | None = None self._on_settings_click: Callable | None = None
self._on_flag_click: Callable | None = None self._on_flag_click: Callable | None = None
@@ -80,9 +85,7 @@ class Sidebar:
self._on_settings_click = on_settings self._on_settings_click = on_settings
self._on_flag_click = on_flag self._on_flag_click = on_flag
def render(self, rect: rl.Rectangle): def _render(self, rect: rl.Rectangle):
self.update_state()
# Background # Background
rl.draw_rectangle_rec(rect, Colors.SIDEBAR_BG) rl.draw_rectangle_rec(rect, Colors.SIDEBAR_BG)
@@ -90,9 +93,7 @@ class Sidebar:
self._draw_network_indicator(rect) self._draw_network_indicator(rect)
self._draw_metrics(rect) self._draw_metrics(rect)
self._handle_mouse_release() def _update_state(self):
def update_state(self):
sm = ui_state.sm sm = ui_state.sm
if not sm.updated['deviceState']: if not sm.updated['deviceState']:
return return
@@ -134,11 +135,7 @@ class Sidebar:
else: else:
self._panda_status.update("VEHICLE", "ONLINE", Colors.GOOD) self._panda_status.update("VEHICLE", "ONLINE", Colors.GOOD)
def _handle_mouse_release(self): def _handle_mouse_release(self, mouse_pos: rl.Vector2):
if not rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT):
return
mouse_pos = rl.get_mouse_position()
if rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN): if rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN):
if self._on_settings_click: if self._on_settings_click:
self._on_settings_click() self._on_settings_click()
@@ -148,8 +145,7 @@ class Sidebar:
def _draw_buttons(self, rect: rl.Rectangle): def _draw_buttons(self, rect: rl.Rectangle):
mouse_pos = rl.get_mouse_position() mouse_pos = rl.get_mouse_position()
mouse_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) mouse_down = self._is_pressed and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
# Settings button # Settings button
settings_down = mouse_down and rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN) settings_down = mouse_down and rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN)
+99
View File
@@ -0,0 +1,99 @@
from enum import IntEnum
import os
import threading
import time
from openpilot.common.api import Api, api_get
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID
class PrimeType(IntEnum):
UNKNOWN = -2,
UNPAIRED = -1,
NONE = 0,
MAGENTA = 1,
LITE = 2,
BLUE = 3,
MAGENTA_NEW = 4,
PURPLE = 5,
class PrimeState:
FETCH_INTERVAL = 5.0 # seconds between API calls
API_TIMEOUT = 10.0 # seconds for API requests
SLEEP_INTERVAL = 0.5 # seconds to sleep between checks in the worker thread
def __init__(self):
self._params = Params()
self._lock = threading.Lock()
self.prime_type: PrimeType = self._load_initial_state()
self._running = False
self._thread = None
self.start()
def _load_initial_state(self) -> PrimeType:
prime_type_str = os.getenv("PRIME_TYPE") or self._params.get("PrimeType", encoding='utf8')
try:
if prime_type_str is not None:
return PrimeType(int(prime_type_str))
except (ValueError, TypeError):
pass
return PrimeType.UNKNOWN
def _fetch_prime_status(self) -> None:
dongle_id = self._params.get("DongleId", encoding='utf8')
if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID:
return
try:
identity_token = Api(dongle_id).get_token()
response = api_get(f"v1.1/devices/{dongle_id}", timeout=self.API_TIMEOUT, access_token=identity_token)
if response.status_code == 200:
data = response.json()
is_paired = data.get("is_paired", False)
prime_type = data.get("prime_type", 0)
self.set_type(PrimeType(prime_type) if is_paired else PrimeType.UNPAIRED)
except Exception as e:
cloudlog.error(f"Failed to fetch prime status: {e}")
def set_type(self, prime_type: PrimeType) -> None:
with self._lock:
if prime_type != self.prime_type:
self.prime_type = prime_type
self._params.put("PrimeType", str(int(prime_type)))
cloudlog.info(f"Prime type updated to {prime_type}")
def _worker_thread(self) -> None:
while self._running:
self._fetch_prime_status()
for _ in range(int(self.FETCH_INTERVAL / self.SLEEP_INTERVAL)):
if not self._running:
break
time.sleep(self.SLEEP_INTERVAL)
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._running = True
self._thread = threading.Thread(target=self._worker_thread, daemon=True)
self._thread.start()
def stop(self) -> None:
self._running = False
if self._thread and self._thread.is_alive():
self._thread.join(timeout=1.0)
def get_type(self) -> PrimeType:
with self._lock:
return self.prime_type
def is_prime(self) -> bool:
with self._lock:
return bool(self.prime_type > PrimeType.NONE)
def __del__(self):
self.stop()
+21 -18
View File
@@ -6,9 +6,9 @@ from openpilot.system.hardware import TICI
from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_FPS from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_FPS
from openpilot.system.ui.lib.label import gui_text_box from openpilot.system.ui.lib.label import gui_text_box
from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.widget import Widget
from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.ui_state import ui_state
ALERT_MARGIN = 40 ALERT_MARGIN = 40
ALERT_PADDING = 60 ALERT_PADDING = 60
ALERT_LINE_SPACING = 45 ALERT_LINE_SPACING = 45
@@ -21,7 +21,6 @@ ALERT_FONT_BIG = 88
SELFDRIVE_STATE_TIMEOUT = 5 # Seconds SELFDRIVE_STATE_TIMEOUT = 5 # Seconds
SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds
# Constants # Constants
ALERT_COLORS = { ALERT_COLORS = {
log.SelfdriveState.AlertStatus.normal: rl.Color(0, 0, 0, 235), # Black log.SelfdriveState.AlertStatus.normal: rl.Color(0, 0, 0, 235), # Black
@@ -61,8 +60,9 @@ ALERT_CRITICAL_REBOOT = Alert(
) )
class AlertRenderer: class AlertRenderer(Widget):
def __init__(self): def __init__(self):
super().__init__()
self.font_regular: rl.Font = gui_app.font(FontWeight.NORMAL) self.font_regular: rl.Font = gui_app.font(FontWeight.NORMAL)
self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD) self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
@@ -73,18 +73,20 @@ class AlertRenderer:
# Check if selfdriveState messages have stopped arriving # Check if selfdriveState messages have stopped arriving
if not sm.updated['selfdriveState']: if not sm.updated['selfdriveState']:
recv_frame = sm.recv_frame['selfdriveState'] recv_frame = sm.recv_frame['selfdriveState']
if (sm.frame - recv_frame) > 5 * DEFAULT_FPS: time_since_onroad = (sm.frame - ui_state.started_frame) / DEFAULT_FPS
# Check if waiting to start
if recv_frame < ui_state.started_frame:
return ALERT_STARTUP_PENDING
# Handle selfdrive timeout # 1. Never received selfdriveState since going onroad
if TICI: waiting_for_startup = recv_frame < ui_state.started_frame
ss_missing = time.monotonic() - sm.recv_time['selfdriveState'] if waiting_for_startup and time_since_onroad > 5:
if ss_missing > SELFDRIVE_STATE_TIMEOUT: return ALERT_STARTUP_PENDING
if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT:
return ALERT_CRITICAL_TIMEOUT # 2. Lost communication with selfdriveState after receiving it
return ALERT_CRITICAL_REBOOT if TICI and not waiting_for_startup:
ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
if ss_missing > SELFDRIVE_STATE_TIMEOUT:
if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT:
return ALERT_CRITICAL_TIMEOUT
return ALERT_CRITICAL_REBOOT
# No alert if size is none # No alert if size is none
if ss.alertSize == 0: if ss.alertSize == 0:
@@ -93,10 +95,10 @@ class AlertRenderer:
# Return current alert # Return current alert
return Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize, status=ss.alertStatus) return Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize, status=ss.alertStatus)
def draw(self, rect: rl.Rectangle, sm: messaging.SubMaster) -> None: def _render(self, rect: rl.Rectangle) -> bool:
alert = self.get_alert(sm) alert = self.get_alert(ui_state.sm)
if not alert: if not alert:
return return False
alert_rect = self._get_alert_rect(rect, alert.size) alert_rect = self._get_alert_rect(rect, alert.size)
self._draw_background(alert_rect, alert) self._draw_background(alert_rect, alert)
@@ -108,13 +110,14 @@ class AlertRenderer:
alert_rect.height - 2 * ALERT_PADDING alert_rect.height - 2 * ALERT_PADDING
) )
self._draw_text(text_rect, alert) self._draw_text(text_rect, alert)
return True
def _get_alert_rect(self, rect: rl.Rectangle, size: int) -> rl.Rectangle: def _get_alert_rect(self, rect: rl.Rectangle, size: int) -> rl.Rectangle:
if size == log.SelfdriveState.AlertSize.full: if size == log.SelfdriveState.AlertSize.full:
return rect return rect
height = (ALERT_FONT_MEDIUM + 2 * ALERT_PADDING if size == log.SelfdriveState.AlertSize.small else height = (ALERT_FONT_MEDIUM + 2 * ALERT_PADDING if size == log.SelfdriveState.AlertSize.small else
ALERT_FONT_BIG + ALERT_LINE_SPACING + ALERT_FONT_SMALL + 2 * ALERT_PADDING) ALERT_FONT_BIG + ALERT_LINE_SPACING + ALERT_FONT_SMALL + 2 * ALERT_PADDING)
return rl.Rectangle( return rl.Rectangle(
rect.x + ALERT_MARGIN, rect.x + ALERT_MARGIN,
+44 -12
View File
@@ -1,6 +1,6 @@
import numpy as np import numpy as np
import pyray as rl import pyray as rl
from collections.abc import Callable
from cereal import log from cereal import log
from msgq.visionipc import VisionStreamType from msgq.visionipc import VisionStreamType
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus, UI_BORDER_SIZE from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus, UI_BORDER_SIZE
@@ -13,7 +13,6 @@ from openpilot.system.ui.lib.application import gui_app
from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame
from openpilot.common.transformations.orientation import rot_from_euler from openpilot.common.transformations.orientation import rot_from_euler
OpState = log.SelfdriveState.OpenpilotState OpState = log.SelfdriveState.OpenpilotState
CALIBRATED = log.LiveCalibrationData.Status.calibrated CALIBRATED = log.LiveCalibrationData.Status.calibrated
ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD
@@ -26,6 +25,9 @@ BORDER_COLORS = {
UIStatus.ENGAGED: rl.Color(0x17, 0x86, 0x44, 0xF1), # Green for engaged state UIStatus.ENGAGED: rl.Color(0x17, 0x86, 0x44, 0xF1), # Green for engaged state
} }
WIDE_CAM_MAX_SPEED = 10.0 # m/s (22 mph)
ROAD_CAM_MIN_SPEED = 15.0 # m/s (34 mph)
class AugmentedRoadView(CameraView): class AugmentedRoadView(CameraView):
def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD): def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD):
@@ -47,11 +49,19 @@ class AugmentedRoadView(CameraView):
self.alert_renderer = AlertRenderer() self.alert_renderer = AlertRenderer()
self.driver_state_renderer = DriverStateRenderer() self.driver_state_renderer = DriverStateRenderer()
def render(self, rect): # Callbacks
self._click_callback: Callable | None = None
def set_callbacks(self, on_click: Callable | None = None):
self._click_callback = on_click
def _render(self, rect):
# Only render when system is started to avoid invalid data access # Only render when system is started to avoid invalid data access
if not ui_state.started: if not ui_state.started:
return return
self._switch_stream_if_needed(ui_state.sm)
# Update calibration before rendering # Update calibration before rendering
self._update_calibration() self._update_calibration()
@@ -76,13 +86,13 @@ class AugmentedRoadView(CameraView):
) )
# Render the base camera view # Render the base camera view
super().render(rect) super()._render(rect)
# Draw all UI overlays # Draw all UI overlays
self.model_renderer.draw(self._content_rect, ui_state.sm) self.model_renderer.render(self._content_rect)
self._hud_renderer.draw(self._content_rect, ui_state.sm) self._hud_renderer.render(self._content_rect)
self.alert_renderer.draw(self._content_rect, ui_state.sm) if not self.alert_renderer.render(self._content_rect):
self.driver_state_renderer.draw(self._content_rect, ui_state.sm) self.driver_state_renderer.render(self._content_rect)
# Custom UI extension point - add custom overlays here # Custom UI extension point - add custom overlays here
# Use self._content_rect for positioning within camera bounds # Use self._content_rect for positioning within camera bounds
@@ -90,10 +100,32 @@ class AugmentedRoadView(CameraView):
# End clipping region # End clipping region
rl.end_scissor_mode() rl.end_scissor_mode()
# Handle click events if no HUD interaction occurred
if not self._hud_renderer.handle_mouse_event():
if self._click_callback and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
if rl.check_collision_point_rec(rl.get_mouse_position(), self._content_rect):
self._click_callback()
def _draw_border(self, rect: rl.Rectangle): def _draw_border(self, rect: rl.Rectangle):
border_color = BORDER_COLORS.get(ui_state.status, BORDER_COLORS[UIStatus.DISENGAGED]) border_color = BORDER_COLORS.get(ui_state.status, BORDER_COLORS[UIStatus.DISENGAGED])
rl.draw_rectangle_lines_ex(rect, UI_BORDER_SIZE, border_color) rl.draw_rectangle_lines_ex(rect, UI_BORDER_SIZE, border_color)
def _switch_stream_if_needed(self, sm):
if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams:
v_ego = sm['carState'].vEgo
if v_ego < WIDE_CAM_MAX_SPEED:
target = WIDE_CAM
elif v_ego > ROAD_CAM_MIN_SPEED:
target = ROAD_CAM
else:
# Hysteresis zone - keep current stream
target = self.stream_type
else:
target = ROAD_CAM
if self.stream_type != target:
self.switch_stream(target)
def _update_calibration(self): def _update_calibration(self):
# Update device camera if not already set # Update device camera if not already set
sm = ui_state.sm sm = ui_state.sm
@@ -129,7 +161,7 @@ class AugmentedRoadView(CameraView):
# Get camera configuration # Get camera configuration
device_camera = self.device_camera or DEFAULT_DEVICE_CAMERA device_camera = self.device_camera or DEFAULT_DEVICE_CAMERA
is_wide_camera = self.stream_type == VisionStreamType.VISION_STREAM_WIDE_ROAD is_wide_camera = self.stream_type == WIDE_CAM
intrinsic = device_camera.ecam.intrinsics if is_wide_camera else device_camera.fcam.intrinsics intrinsic = device_camera.ecam.intrinsics if is_wide_camera else device_camera.fcam.intrinsics
calibration = self.view_from_wide_calib if is_wide_camera else self.view_from_calib calibration = self.view_from_wide_calib if is_wide_camera else self.view_from_calib
zoom = 2.0 if is_wide_camera else 1.1 zoom = 2.0 if is_wide_camera else 1.1
@@ -170,9 +202,9 @@ class AugmentedRoadView(CameraView):
]) ])
video_transform = np.array([ video_transform = np.array([
[zoom, 0.0, (w / 2 + x - x_offset) - (cx * zoom)], [zoom, 0.0, (w / 2 + x - x_offset) - (cx * zoom)],
[0.0, zoom, (h / 2 + y - y_offset) - (cy * zoom)], [0.0, zoom, (h / 2 + y - y_offset) - (cy * zoom)],
[0.0, 0.0, 1.0] [0.0, 0.0, 1.0]
]) ])
self.model_renderer.set_transform(video_transform @ calib_transform) self.model_renderer.set_transform(video_transform @ calib_transform)
+39 -30
View File
@@ -1,3 +1,4 @@
import platform
import numpy as np import numpy as np
import pyray as rl import pyray as rl
@@ -6,12 +7,21 @@ from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf
from openpilot.common.swaglog import cloudlog from openpilot.common.swaglog import cloudlog
from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage from openpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage
from openpilot.system.ui.lib.widget import Widget
CONNECTION_RETRY_INTERVAL = 0.2 # seconds between connection attempts CONNECTION_RETRY_INTERVAL = 0.2 # seconds between connection attempts
VERTEX_SHADER = """ VERSION = """
#version 300 es #version 300 es
precision mediump float;
"""
if platform.system() == "Darwin":
VERSION = """
#version 330 core
"""
VERTEX_SHADER = VERSION + """
in vec3 vertexPosition; in vec3 vertexPosition;
in vec2 vertexTexCoord; in vec2 vertexTexCoord;
in vec3 vertexNormal; in vec3 vertexNormal;
@@ -41,9 +51,7 @@ if TICI:
} }
""" """
else: else:
FRAME_FRAGMENT_SHADER = """ FRAME_FRAGMENT_SHADER = VERSION + """
#version 300 es
precision mediump float;
in vec2 fragTexCoord; in vec2 fragTexCoord;
uniform sampler2D texture0; uniform sampler2D texture0;
uniform sampler2D texture1; uniform sampler2D texture1;
@@ -55,8 +63,10 @@ else:
} }
""" """
class CameraView:
class CameraView(Widget):
def __init__(self, name: str, stream_type: VisionStreamType): def __init__(self, name: str, stream_type: VisionStreamType):
super().__init__()
self._name = name self._name = name
# Primary stream # Primary stream
self.client = VisionIpcClient(name, stream_type, conflate=True) self.client = VisionIpcClient(name, stream_type, conflate=True)
@@ -68,7 +78,6 @@ class CameraView:
self._target_stream_type: VisionStreamType | None = None self._target_stream_type: VisionStreamType | None = None
self._switching: bool = False self._switching: bool = False
self._texture_needs_update = True self._texture_needs_update = True
self.last_connection_attempt: float = 0.0 self.last_connection_attempt: float = 0.0
self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER) self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER)
@@ -82,7 +91,7 @@ class CameraView:
self.egl_images: dict[int, EGLImage] = {} self.egl_images: dict[int, EGLImage] = {}
self.egl_texture: rl.Texture | None = None self.egl_texture: rl.Texture | None = None
self._placeholder_color : rl.Color | None = None self._placeholder_color: rl.Color | None = None
# Initialize EGL for zero-copy rendering on TICI # Initialize EGL for zero-copy rendering on TICI
if TICI: if TICI:
@@ -145,12 +154,12 @@ class CameraView:
zy = min(widget_aspect_ratio / frame_aspect_ratio, 1.0) zy = min(widget_aspect_ratio / frame_aspect_ratio, 1.0)
return np.array([ return np.array([
[zx, 0.0, 0.0], [zx, 0.0, 0.0],
[0.0, zy, 0.0], [0.0, zy, 0.0],
[0.0, 0.0, 1.0] [0.0, 0.0, 1.0]
]) ])
def render(self, rect: rl.Rectangle): def _render(self, rect: rl.Rectangle):
if self._switching: if self._switching:
self._handle_switch() self._handle_switch()
@@ -230,7 +239,7 @@ class CameraView:
# Update textures with new frame data # Update textures with new frame data
if self._texture_needs_update: if self._texture_needs_update:
y_data = self.frame.data[: self.frame.uv_offset] y_data = self.frame.data[: self.frame.uv_offset]
uv_data = self.frame.data[self.frame.uv_offset :] uv_data = self.frame.data[self.frame.uv_offset:]
rl.update_texture(self.texture_y, rl.ffi.cast("void *", y_data.ctypes.data)) rl.update_texture(self.texture_y, rl.ffi.cast("void *", y_data.ctypes.data))
rl.update_texture(self.texture_uv, rl.ffi.cast("void *", uv_data.ctypes.data)) rl.update_texture(self.texture_uv, rl.ffi.cast("void *", uv_data.ctypes.data))
@@ -265,7 +274,7 @@ class CameraView:
def _handle_switch(self) -> None: def _handle_switch(self) -> None:
"""Check if target stream is ready and switch immediately.""" """Check if target stream is ready and switch immediately."""
if not self._target_client or not self._switching: if not self._target_client or not self._switching:
return return
# Try to connect target if needed # Try to connect target if needed
if not self._target_client.is_connected(): if not self._target_client.is_connected():
@@ -277,28 +286,28 @@ class CameraView:
# Check if target has frames ready # Check if target has frames ready
target_frame = self._target_client.recv(timeout_ms=0) target_frame = self._target_client.recv(timeout_ms=0)
if target_frame: if target_frame:
self.frame = target_frame # Update current frame to target frame self.frame = target_frame # Update current frame to target frame
self._complete_switch() self._complete_switch()
def _complete_switch(self) -> None: def _complete_switch(self) -> None:
"""Instantly switch to target stream.""" """Instantly switch to target stream."""
cloudlog.debug(f"Switching to {self._target_stream_type}") cloudlog.debug(f"Switching to {self._target_stream_type}")
# Clean up current resources # Clean up current resources
if self.client: if self.client:
del self.client del self.client
# Switch to target # Switch to target
self.client = self._target_client self.client = self._target_client
self._stream_type = self._target_stream_type self._stream_type = self._target_stream_type
self._texture_needs_update = True self._texture_needs_update = True
# Reset state # Reset state
self._target_client = None self._target_client = None
self._target_stream_type = None self._target_stream_type = None
self._switching = False self._switching = False
# Initialize textures for new stream # Initialize textures for new stream
self._initialize_textures() self._initialize_textures()
def _initialize_textures(self): def _initialize_textures(self):
self._clear_textures() self._clear_textures()
@@ -1,20 +1,23 @@
import numpy as np import numpy as np
import pyray as rl import pyray as rl
from cereal import messaging
from msgq.visionipc import VisionStreamType from msgq.visionipc import VisionStreamType
from openpilot.selfdrive.ui.onroad.cameraview import CameraView from openpilot.selfdrive.ui.onroad.cameraview import CameraView
from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.label import gui_label from openpilot.system.ui.lib.label import gui_label
class DriverCameraView(CameraView): class DriverCameraDialog(CameraView):
def __init__(self, stream_type: VisionStreamType): def __init__(self):
super().__init__("camerad", stream_type) super().__init__("camerad", VisionStreamType.VISION_STREAM_DRIVER)
self.driver_state_renderer = DriverStateRenderer() self.driver_state_renderer = DriverStateRenderer()
def render(self, rect, sm): def _render(self, rect):
super().render(rect) super()._render(rect)
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
return 1
if not self.frame: if not self.frame:
gui_label( gui_label(
@@ -24,13 +27,15 @@ class DriverCameraView(CameraView):
font_weight=FontWeight.BOLD, font_weight=FontWeight.BOLD,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
) )
return return -1
self._draw_face_detection(rect, sm) self._draw_face_detection(rect)
self.driver_state_renderer.draw(rect, sm) self.driver_state_renderer.render(rect)
def _draw_face_detection(self, rect: rl.Rectangle, sm) -> None: return -1
driver_state = sm["driverStateV2"]
def _draw_face_detection(self, rect: rl.Rectangle) -> None:
driver_state = ui_state.sm["driverStateV2"]
is_rhd = driver_state.wheelOnRightProb > 0.5 is_rhd = driver_state.wheelOnRightProb > 0.5
driver_data = driver_state.rightDriverData if is_rhd else driver_state.leftDriverData driver_data = driver_state.rightDriverData if is_rhd else driver_state.leftDriverData
face_detect = driver_data.faceProb > 0.7 face_detect = driver_data.faceProb > 0.7
@@ -83,12 +88,11 @@ class DriverCameraView(CameraView):
if __name__ == "__main__": if __name__ == "__main__":
gui_app.init_window("Driver Camera View") gui_app.init_window("Driver Camera View")
sm = messaging.SubMaster(["selfdriveState", "driverStateV2", "driverMonitoringState"])
driver_camera_view = DriverCameraView(VisionStreamType.VISION_STREAM_DRIVER) driver_camera_view = DriverCameraDialog()
try: try:
for _ in gui_app.render(): for _ in gui_app.render():
sm.update() ui_state.update()
driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height), sm) driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
finally: finally:
driver_camera_view.close() driver_camera_view.close()
+33 -40
View File
@@ -3,19 +3,19 @@ import pyray as rl
from dataclasses import dataclass from dataclasses import dataclass
from openpilot.selfdrive.ui.ui_state import ui_state, UI_BORDER_SIZE from openpilot.selfdrive.ui.ui_state import ui_state, UI_BORDER_SIZE
from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.widget import Widget
# Default 3D coordinates for face keypoints as a NumPy array # Default 3D coordinates for face keypoints as a NumPy array
DEFAULT_FACE_KPTS_3D = np.array([ DEFAULT_FACE_KPTS_3D = np.array([
[-5.98, -51.20, 8.00], [-17.64, -49.14, 8.00], [-23.81, -46.40, 8.00], [-29.98, -40.91, 8.00], [-5.98, -51.20, 8.00], [-17.64, -49.14, 8.00], [-23.81, -46.40, 8.00], [-29.98, -40.91, 8.00],
[-32.04, -37.49, 8.00], [-34.10, -32.00, 8.00], [-36.16, -21.03, 8.00], [-36.16, 6.40, 8.00], [-32.04, -37.49, 8.00], [-34.10, -32.00, 8.00], [-36.16, -21.03, 8.00], [-36.16, 6.40, 8.00],
[-35.47, 10.51, 8.00], [-32.73, 19.43, 8.00], [-29.30, 26.29, 8.00], [-24.50, 33.83, 8.00], [-35.47, 10.51, 8.00], [-32.73, 19.43, 8.00], [-29.30, 26.29, 8.00], [-24.50, 33.83, 8.00],
[-19.01, 41.37, 8.00], [-14.21, 46.17, 8.00], [-12.16, 47.54, 8.00], [-4.61, 49.60, 8.00], [-19.01, 41.37, 8.00], [-14.21, 46.17, 8.00], [-12.16, 47.54, 8.00], [-4.61, 49.60, 8.00],
[4.99, 49.60, 8.00], [12.53, 47.54, 8.00], [14.59, 46.17, 8.00], [19.39, 41.37, 8.00], [4.99, 49.60, 8.00], [12.53, 47.54, 8.00], [14.59, 46.17, 8.00], [19.39, 41.37, 8.00],
[24.87, 33.83, 8.00], [29.67, 26.29, 8.00], [33.10, 19.43, 8.00], [35.84, 10.51, 8.00], [24.87, 33.83, 8.00], [29.67, 26.29, 8.00], [33.10, 19.43, 8.00], [35.84, 10.51, 8.00],
[36.53, 6.40, 8.00], [36.53, -21.03, 8.00], [34.47, -32.00, 8.00], [32.42, -37.49, 8.00], [36.53, 6.40, 8.00], [36.53, -21.03, 8.00], [34.47, -32.00, 8.00], [32.42, -37.49, 8.00],
[30.36, -40.91, 8.00], [24.19, -46.40, 8.00], [18.02, -49.14, 8.00], [6.36, -51.20, 8.00], [30.36, -40.91, 8.00], [24.19, -46.40, 8.00], [18.02, -49.14, 8.00], [6.36, -51.20, 8.00],
[-5.98, -51.20, 8.00], [-5.98, -51.20, 8.00],
], dtype=np.float32) ], dtype=np.float32)
# UI constants # UI constants
@@ -31,6 +31,7 @@ SCALES_NEG = np.array([0.7, 0.4, 0.4], dtype=np.float32)
ARC_POINT_COUNT = 37 # Number of points in the arc ARC_POINT_COUNT = 37 # Number of points in the arc
ARC_ANGLES = np.linspace(0.0, np.pi, ARC_POINT_COUNT, dtype=np.float32) ARC_ANGLES = np.linspace(0.0, np.pi, ARC_POINT_COUNT, dtype=np.float32)
@dataclass @dataclass
class ArcData: class ArcData:
"""Data structure for arc rendering parameters.""" """Data structure for arc rendering parameters."""
@@ -40,14 +41,15 @@ class ArcData:
height: float height: float
thickness: float thickness: float
class DriverStateRenderer:
class DriverStateRenderer(Widget):
def __init__(self): def __init__(self):
super().__init__()
# Initial state with NumPy arrays # Initial state with NumPy arrays
self.face_kpts_draw = DEFAULT_FACE_KPTS_3D.copy() self.face_kpts_draw = DEFAULT_FACE_KPTS_3D.copy()
self.is_active = False self.is_active = False
self.is_rhd = False self.is_rhd = False
self.dm_fade_state = 0.0 self.dm_fade_state = 0.0
self.state_updated = False
self.last_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0) self.last_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self.driver_pose_vals = np.zeros(3, dtype=np.float32) self.driver_pose_vals = np.zeros(3, dtype=np.float32)
self.driver_pose_diff = np.zeros(3, dtype=np.float32) self.driver_pose_diff = np.zeros(3, dtype=np.float32)
@@ -73,14 +75,10 @@ class DriverStateRenderer:
self.engaged_color = rl.Color(26, 242, 66, 255) self.engaged_color = rl.Color(26, 242, 66, 255)
self.disengaged_color = rl.Color(139, 139, 139, 255) self.disengaged_color = rl.Color(139, 139, 139, 255)
def draw(self, rect, sm): self.set_visible(lambda: (ui_state.sm.recv_frame['driverStateV2'] > ui_state.started_frame and
if not self._is_visible(sm): ui_state.sm.seen['driverMonitoringState']))
return
self._update_state(sm, rect)
if not self.state_updated:
return
def _render(self, rect):
# Set opacity based on active state # Set opacity based on active state
opacity = 0.65 if self.is_active else 0.2 opacity = 0.65 if self.is_active else 0.2
@@ -105,18 +103,14 @@ class DriverStateRenderer:
if self.v_arc_data: if self.v_arc_data:
rl.draw_spline_linear(self.v_arc_lines, len(self.v_arc_lines), self.v_arc_data.thickness, self.arc_color) rl.draw_spline_linear(self.v_arc_lines, len(self.v_arc_lines), self.v_arc_data.thickness, self.arc_color)
def _is_visible(self, sm): def _update_state(self):
"""Check if the visualization should be rendered."""
return (sm.recv_frame['driverStateV2'] > ui_state.started_frame and
sm.seen['driverMonitoringState'] and
sm['selfdriveState'].alertSize == 0)
def _update_state(self, sm, rect):
"""Update the driver monitoring state based on model data""" """Update the driver monitoring state based on model data"""
if not sm.updated["driverMonitoringState"]: sm = ui_state.sm
if self.state_updated and (rect.x != self.last_rect.x or rect.y != self.last_rect.y or \ if not sm.updated["driverMonitoringState"]:
rect.width != self.last_rect.width or rect.height != self.last_rect.height): if (self._rect.x != self.last_rect.x or self._rect.y != self.last_rect.y or
self._pre_calculate_drawing_elements(rect) self._rect.width != self.last_rect.width or self._rect.height != self.last_rect.height):
self._pre_calculate_drawing_elements()
self.last_rect = self._rect
return return
# Get monitoring state # Get monitoring state
@@ -165,16 +159,15 @@ class DriverStateRenderer:
self.face_keypoints_transformed = self.face_kpts_draw[:, :2] * kp_depth[:, None] self.face_keypoints_transformed = self.face_kpts_draw[:, :2] * kp_depth[:, None]
# Pre-calculate all drawing elements # Pre-calculate all drawing elements
self._pre_calculate_drawing_elements(rect) self._pre_calculate_drawing_elements()
self.state_updated = True
def _pre_calculate_drawing_elements(self, rect): def _pre_calculate_drawing_elements(self):
"""Pre-calculate all drawing elements based on the current rectangle""" """Pre-calculate all drawing elements based on the current rectangle"""
# Calculate icon position (bottom-left or bottom-right) # Calculate icon position (bottom-left or bottom-right)
width, height = rect.width, rect.height width, height = self._rect.width, self._rect.height
offset = UI_BORDER_SIZE + BTN_SIZE // 2 offset = UI_BORDER_SIZE + BTN_SIZE // 2
self.position_x = rect.x + (width - offset if self.is_rhd else offset) self.position_x = self._rect.x + (width - offset if self.is_rhd else offset)
self.position_y = rect.y + height - offset self.position_y = self._rect.y + height - offset
# Pre-calculate the face lines positions # Pre-calculate the face lines positions
positioned_keypoints = self.face_keypoints_transformed + np.array([self.position_x, self.position_y]) positioned_keypoints = self.face_keypoints_transformed + np.array([self.position_x, self.position_y])
@@ -189,15 +182,15 @@ class DriverStateRenderer:
# Horizontal arc # Horizontal arc
h_width = abs(delta_x) h_width = abs(delta_x)
self.h_arc_data = self._calculate_arc_data( self.h_arc_data = self._calculate_arc_data(
delta_x, h_width, self.position_x, self.position_y - ARC_LENGTH / 2, delta_x, h_width, self.position_x, self.position_y - ARC_LENGTH / 2,
self.driver_pose_sins[1], self.driver_pose_diff[1], is_horizontal=True self.driver_pose_sins[1], self.driver_pose_diff[1], is_horizontal=True
) )
# Vertical arc # Vertical arc
v_height = abs(delta_y) v_height = abs(delta_y)
self.v_arc_data = self._calculate_arc_data( self.v_arc_data = self._calculate_arc_data(
delta_y, v_height, self.position_x - ARC_LENGTH / 2, self.position_y, delta_y, v_height, self.position_x - ARC_LENGTH / 2, self.position_y,
self.driver_pose_sins[0], self.driver_pose_diff[0], is_horizontal=False self.driver_pose_sins[0], self.driver_pose_diff[0], is_horizontal=False
) )
def _calculate_arc_data( def _calculate_arc_data(
+78
View File
@@ -0,0 +1,78 @@
import time
import pyray as rl
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.widget import Widget
from openpilot.common.params import Params
class ExpButton(Widget):
def __init__(self, button_size: int, icon_size: int):
super().__init__()
self._params = Params()
self._experimental_mode: bool = False
self._engageable: bool = False
# State hold mechanism
self._hold_duration = 2.0 # seconds
self._held_mode: bool | None = None
self._hold_end_time: float | None = None
self._white_color: rl.Color = rl.Color(255, 255, 255, 255)
self._black_bg: rl.Color = rl.Color(0, 0, 0, 166)
self._txt_wheel: rl.Texture = gui_app.texture('icons/chffr_wheel.png', icon_size, icon_size)
self._txt_exp: rl.Texture = gui_app.texture('icons/experimental.png', icon_size, icon_size)
self._rect = rl.Rectangle(0, 0, button_size, button_size)
def set_rect(self, rect: rl.Rectangle) -> None:
self._rect.x, self._rect.y = rect.x, rect.y
def _update_state(self) -> None:
selfdrive_state = ui_state.sm["selfdriveState"]
self._experimental_mode = selfdrive_state.experimentalMode
self._engageable = selfdrive_state.engageable or selfdrive_state.enabled
def handle_mouse_event(self) -> bool:
if rl.check_collision_point_rec(rl.get_mouse_position(), self._rect):
if (rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and
self._is_toggle_allowed()):
new_mode = not self._experimental_mode
self._params.put_bool("ExperimentalMode", new_mode)
# Hold new state temporarily
self._held_mode = new_mode
self._hold_end_time = time.time() + self._hold_duration
return True
return False
def _render(self, rect: rl.Rectangle) -> None:
center_x = int(self._rect.x + self._rect.width // 2)
center_y = int(self._rect.y + self._rect.height // 2)
mouse_over = rl.check_collision_point_rec(rl.get_mouse_position(), self._rect)
mouse_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._is_pressed
self._white_color.a = 180 if (mouse_down and mouse_over) or not self._engageable else 255
texture = self._txt_exp if self._held_or_actual_mode() else self._txt_wheel
rl.draw_circle(center_x, center_y, self._rect.width / 2, self._black_bg)
rl.draw_texture(texture, center_x - texture.width // 2, center_y - texture.height // 2, self._white_color)
def _held_or_actual_mode(self):
now = time.time()
if self._hold_end_time and now < self._hold_end_time:
return self._held_mode
if self._hold_end_time and now >= self._hold_end_time:
self._hold_end_time = self._held_mode = None
return self._experimental_mode
def _is_toggle_allowed(self):
if not self._params.get_bool("ExperimentalModeConfirmed"):
return False
car_params = ui_state.sm["carParams"]
if car_params.alphaLongitudinalAvailable:
return self._params.get_bool("AlphaLongitudinalEnabled")
else:
return car_params.openpilotLongitudinalControl
+18 -17
View File
@@ -1,10 +1,11 @@
import pyray as rl import pyray as rl
from dataclasses import dataclass from dataclasses import dataclass
from cereal.messaging import SubMaster
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.selfdrive.ui.onroad.exp_button import ExpButton
from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.common.conversions import Conversions as CV from openpilot.common.conversions import Conversions as CV
from openpilot.system.ui.lib.widget import Widget
# Constants # Constants
SET_SPEED_NA = 255 SET_SPEED_NA = 255
@@ -54,21 +55,25 @@ FONT_SIZES = FontSizes()
COLORS = Colors() COLORS = Colors()
class HudRenderer: class HudRenderer(Widget):
def __init__(self): def __init__(self):
super().__init__()
"""Initialize the HUD renderer.""" """Initialize the HUD renderer."""
self.is_cruise_set: bool = False self.is_cruise_set: bool = False
self.is_cruise_available: bool = False self.is_cruise_available: bool = False
self.set_speed: float = SET_SPEED_NA self.set_speed: float = SET_SPEED_NA
self.speed: float = 0.0 self.speed: float = 0.0
self.v_ego_cluster_seen: bool = False self.v_ego_cluster_seen: bool = False
self._wheel_texture: rl.Texture = gui_app.texture('icons/chffr_wheel.png', UI_CONFIG.wheel_icon_size, UI_CONFIG.wheel_icon_size)
self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD) self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD)
self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD) self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM) self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM)
def _update_state(self, sm: SubMaster) -> None: self._exp_button = ExpButton(UI_CONFIG.button_size, UI_CONFIG.wheel_icon_size)
def _update_state(self) -> None:
"""Update HUD state based on car state and controls state.""" """Update HUD state based on car state and controls state."""
sm = ui_state.sm
if sm.recv_frame["carState"] < ui_state.started_frame: if sm.recv_frame["carState"] < ui_state.started_frame:
self.is_cruise_set = False self.is_cruise_set = False
self.set_speed = SET_SPEED_NA self.set_speed = SET_SPEED_NA
@@ -94,9 +99,9 @@ class HudRenderer:
speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
self.speed = max(0.0, v_ego * speed_conversion) self.speed = max(0.0, v_ego * speed_conversion)
def draw(self, rect: rl.Rectangle, sm: SubMaster) -> None: def _render(self, rect: rl.Rectangle) -> None:
"""Render HUD elements to the screen.""" """Render HUD elements to the screen."""
self._update_state(sm) # Draw the header background
rl.draw_rectangle_gradient_v( rl.draw_rectangle_gradient_v(
int(rect.x), int(rect.x),
int(rect.y), int(rect.y),
@@ -110,7 +115,13 @@ class HudRenderer:
self._draw_set_speed(rect) self._draw_set_speed(rect)
self._draw_current_speed(rect) self._draw_current_speed(rect)
self._draw_wheel_icon(rect)
button_x = rect.x + rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size
button_y = rect.y + UI_CONFIG.border_size
self._exp_button.render(rl.Rectangle(button_x, button_y, UI_CONFIG.button_size, UI_CONFIG.button_size))
def handle_mouse_event(self) -> bool:
return bool(self._exp_button.handle_mouse_event())
def _draw_set_speed(self, rect: rl.Rectangle) -> None: def _draw_set_speed(self, rect: rl.Rectangle) -> None:
"""Draw the MAX speed indicator box.""" """Draw the MAX speed indicator box."""
@@ -166,13 +177,3 @@ class HudRenderer:
unit_text_size = measure_text_cached(self._font_medium, unit_text, FONT_SIZES.speed_unit) unit_text_size = measure_text_cached(self._font_medium, unit_text, FONT_SIZES.speed_unit)
unit_pos = rl.Vector2(rect.x + rect.width / 2 - unit_text_size.x / 2, 290 - unit_text_size.y / 2) unit_pos = rl.Vector2(rect.x + rect.width / 2 - unit_text_size.x / 2, 290 - unit_text_size.y / 2)
rl.draw_text_ex(self._font_medium, unit_text, unit_pos, FONT_SIZES.speed_unit, 0, COLORS.white_translucent) rl.draw_text_ex(self._font_medium, unit_text, unit_pos, FONT_SIZES.speed_unit, 0, COLORS.white_translucent)
def _draw_wheel_icon(self, rect: rl.Rectangle) -> None:
"""Draw the steering wheel icon with status-based opacity."""
center_x = int(rect.x + rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size / 2)
center_y = int(rect.y + UI_CONFIG.border_size + UI_CONFIG.button_size / 2)
rl.draw_circle(center_x, center_y, UI_CONFIG.button_size / 2, COLORS.black_translucent)
opacity = 0.7 if ui_state.status == UIStatus.DISENGAGED else 1.0
img_pos = rl.Vector2(center_x - self._wheel_texture.width / 2, center_y - self._wheel_texture.height / 2)
rl.draw_texture_v(self._wheel_texture, img_pos, rl.Color(255, 255, 255, int(255 * opacity)))
+57 -54
View File
@@ -7,9 +7,9 @@ from openpilot.common.params import Params
from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import DEFAULT_FPS from openpilot.system.ui.lib.application import DEFAULT_FPS
from openpilot.system.ui.lib.shader_polygon import draw_polygon from openpilot.system.ui.lib.shader_polygon import draw_polygon
from openpilot.system.ui.lib.widget import Widget
from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT
CLIP_MARGIN = 500 CLIP_MARGIN = 500
MIN_DRAW_DISTANCE = 10.0 MIN_DRAW_DISTANCE = 10.0
MAX_DRAW_DISTANCE = 100.0 MAX_DRAW_DISTANCE = 100.0
@@ -36,6 +36,7 @@ class ModelPoints:
raw_points: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float32)) raw_points: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float32))
projected_points: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32)) projected_points: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32))
@dataclass @dataclass
class LeadVehicle: class LeadVehicle:
glow: list[float] = field(default_factory=list) glow: list[float] = field(default_factory=list)
@@ -43,8 +44,9 @@ class LeadVehicle:
fill_alpha: int = 0 fill_alpha: int = 0
class ModelRenderer: class ModelRenderer(Widget):
def __init__(self): def __init__(self):
super().__init__()
self._longitudinal_control = False self._longitudinal_control = False
self._experimental_mode = False self._experimental_mode = False
self._blend_factor = 1.0 self._blend_factor = 1.0
@@ -64,11 +66,6 @@ class ModelRenderer:
self._car_space_transform = np.zeros((3, 3), dtype=np.float32) self._car_space_transform = np.zeros((3, 3), dtype=np.float32)
self._transform_dirty = True self._transform_dirty = True
self._clip_region = None self._clip_region = None
self._rect = None
# Pre-allocated arrays for polygon conversion
self._temp_points_3d = np.empty((MAX_POINTS * 2, 3), dtype=np.float32)
self._temp_proj = np.empty((3, MAX_POINTS * 2), dtype=np.float32)
self._exp_gradient = { self._exp_gradient = {
'start': (0.0, 1.0), # Bottom of path 'start': (0.0, 1.0), # Bottom of path
@@ -86,14 +83,15 @@ class ModelRenderer:
self._car_space_transform = transform.astype(np.float32) self._car_space_transform = transform.astype(np.float32)
self._transform_dirty = True self._transform_dirty = True
def draw(self, rect: rl.Rectangle, sm: messaging.SubMaster): def _render(self, rect: rl.Rectangle):
sm = ui_state.sm
# Check if data is up-to-date # Check if data is up-to-date
if (sm.recv_frame["liveCalibration"] < ui_state.started_frame or if (sm.recv_frame["liveCalibration"] < ui_state.started_frame or
sm.recv_frame["modelV2"] < ui_state.started_frame): sm.recv_frame["modelV2"] < ui_state.started_frame):
return return
# Set up clipping region # Set up clipping region
self._rect = rect
self._clip_region = rl.Rectangle( self._clip_region = rl.Rectangle(
rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN
) )
@@ -127,7 +125,6 @@ class ModelRenderer:
self._update_leads(radar_state, path_x_array) self._update_leads(radar_state, path_x_array)
self._transform_dirty = False self._transform_dirty = False
# Draw elements # Draw elements
self._draw_lane_lines() self._draw_lane_lines()
self._draw_path(sm) self._draw_path(sm)
@@ -256,7 +253,7 @@ class ModelRenderer:
glow = [(x + (sz * 1.35) + g_xo, y + sz + g_yo), (x, y - g_yo), (x - (sz * 1.35) - g_xo, y + sz + g_yo)] glow = [(x + (sz * 1.35) + g_xo, y + sz + g_yo), (x, y - g_yo), (x - (sz * 1.35) - g_xo, y + sz + g_yo)]
chevron = [(x + (sz * 1.25), y + sz), (x, y), (x - (sz * 1.25), y + sz)] chevron = [(x + (sz * 1.25), y + sz), (x, y), (x - (sz * 1.25), y + sz)]
return LeadVehicle(glow=glow,chevron=chevron, fill_alpha=int(fill_alpha)) return LeadVehicle(glow=glow, chevron=chevron, fill_alpha=int(fill_alpha))
def _draw_lane_lines(self): def _draw_lane_lines(self):
"""Draw lane lines and road edges""" """Draw lane lines and road edges"""
@@ -357,54 +354,60 @@ class ModelRenderer:
if points.shape[0] == 0: if points.shape[0] == 0:
return np.empty((0, 2), dtype=np.float32) return np.empty((0, 2), dtype=np.float32)
# Create left and right 3D points in one array N = points.shape[0]
n_points = points.shape[0] # Generate left and right 3D points in one array using broadcasting
points_3d = self._temp_points_3d[:n_points * 2] offsets = np.array([[0, -y_off, z_off], [0, y_off, z_off]], dtype=np.float32)
points_3d[:n_points, 0] = points_3d[n_points:, 0] = points[:, 0] points_3d = points[None, :, :] + offsets[:, None, :] # Shape: 2xNx3
points_3d[:n_points, 1] = points[:, 1] - y_off points_3d = points_3d.reshape(2 * N, 3) # Shape: (2*N)x3
points_3d[n_points:, 1] = points[:, 1] + y_off
points_3d[:n_points, 2] = points_3d[n_points:, 2] = points[:, 2] + z_off
# Single matrix multiplication for projections # Transform all points to projected space in one operation
proj = np.ascontiguousarray(self._temp_proj[:, :n_points * 2]) # Slice the pre-allocated array proj = self._car_space_transform @ points_3d.T # Shape: 3x(2*N)
np.dot(self._car_space_transform, points_3d.T, out=proj) proj = proj.reshape(3, 2, N)
valid_z = np.abs(proj[2]) > 1e-6 left_proj = proj[:, 0, :]
if not np.any(valid_z): right_proj = proj[:, 1, :]
# Filter points where z is sufficiently large
valid_proj = (np.abs(left_proj[2]) >= 1e-6) & (np.abs(right_proj[2]) >= 1e-6)
if not np.any(valid_proj):
return np.empty((0, 2), dtype=np.float32) return np.empty((0, 2), dtype=np.float32)
# Compute screen coordinates # Compute screen coordinates
screen = proj[:2, valid_z] / proj[2, valid_z][None, :] left_screen = left_proj[:2, valid_proj] / left_proj[2, valid_proj][None, :]
left_screen = screen[:, :n_points].T right_screen = right_proj[:2, valid_proj] / right_proj[2, valid_proj][None, :]
right_screen = screen[:, n_points:].T
# Ensure consistent shapes by re-aligning valid points # Define clip region bounds
valid_points = np.minimum(left_screen.shape[0], right_screen.shape[0]) clip = self._clip_region
if valid_points == 0: x_min, x_max = clip.x, clip.x + clip.width
y_min, y_max = clip.y, clip.y + clip.height
# Filter points within clip region
left_in_clip = (
(left_screen[0] >= x_min) & (left_screen[0] <= x_max) &
(left_screen[1] >= y_min) & (left_screen[1] <= y_max)
)
right_in_clip = (
(right_screen[0] >= x_min) & (right_screen[0] <= x_max) &
(right_screen[1] >= y_min) & (right_screen[1] <= y_max)
)
both_in_clip = left_in_clip & right_in_clip
if not np.any(both_in_clip):
return np.empty((0, 2), dtype=np.float32) return np.empty((0, 2), dtype=np.float32)
left_screen = left_screen[:valid_points]
right_screen = right_screen[:valid_points]
if self._clip_region: # Select valid and clipped points
clip = self._clip_region left_screen = left_screen[:, both_in_clip]
bounds_mask = ( right_screen = right_screen[:, both_in_clip]
(left_screen[:, 0] >= clip.x) & (left_screen[:, 0] <= clip.x + clip.width) &
(left_screen[:, 1] >= clip.y) & (left_screen[:, 1] <= clip.y + clip.height) & # Handle Y-coordinate inversion on hills
(right_screen[:, 0] >= clip.x) & (right_screen[:, 0] <= clip.x + clip.width) & if not allow_invert and left_screen.shape[1] > 1:
(right_screen[:, 1] >= clip.y) & (right_screen[:, 1] <= clip.y + clip.height) y = left_screen[1, :] # y-coordinates
) keep = y == np.minimum.accumulate(y)
if not np.any(bounds_mask): if not np.any(keep):
return np.empty((0, 2), dtype=np.float32) return np.empty((0, 2), dtype=np.float32)
left_screen = left_screen[bounds_mask] left_screen = left_screen[:, keep]
right_screen = right_screen[bounds_mask] right_screen = right_screen[:, keep]
if not allow_invert and left_screen.shape[0] > 1: return np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32)
keep = np.concatenate(([True], np.diff(left_screen[:, 1]) < 0))
left_screen = left_screen[keep]
right_screen = right_screen[keep]
if left_screen.shape[0] == 0:
return np.empty((0, 2), dtype=np.float32)
return np.vstack((left_screen, right_screen[::-1])).astype(np.float32)
@staticmethod @staticmethod
def _map_val(x, x0, x1, y0, y1): def _map_val(x, x0, x1, y0, y1):
@@ -417,10 +420,10 @@ class ModelRenderer:
def _hsla_to_color(h, s, l, a): def _hsla_to_color(h, s, l, a):
rgb = colorsys.hls_to_rgb(h, l, s) rgb = colorsys.hls_to_rgb(h, l, s)
return rl.Color( return rl.Color(
int(rgb[0] * 255), int(rgb[0] * 255),
int(rgb[1] * 255), int(rgb[1] * 255),
int(rgb[2] * 255), int(rgb[2] * 255),
int(a * 255) int(a * 255)
) )
@staticmethod @staticmethod
+1 -1
View File
@@ -22,7 +22,7 @@ FirehosePanel::FirehosePanel(SettingsWindow *parent) : QWidget((QWidget*)parent)
layout->setSpacing(20); layout->setSpacing(20);
// header // header
QLabel *title = new QLabel(tr("🔥 Firehose Mode 🔥")); QLabel *title = new QLabel(tr("Firehose Mode"));
title->setStyleSheet("font-size: 100px; font-weight: 500; font-family: 'Noto Color Emoji';"); title->setStyleSheet("font-size: 100px; font-weight: 500; font-family: 'Noto Color Emoji';");
layout->addWidget(title, 0, Qt::AlignCenter); layout->addWidget(title, 0, Qt::AlignCenter);
+61 -6
View File
@@ -139,6 +139,15 @@ void TogglesPanel::expandToggleDescription(const QString &param) {
toggles[param.toStdString()]->showDescription(); toggles[param.toStdString()]->showDescription();
} }
void TogglesPanel::scrollToToggle(const QString &param) {
if (auto it = toggles.find(param.toStdString()); it != toggles.end()) {
auto scroll_area = qobject_cast<QScrollArea*>(parent()->parent());
if (scroll_area) {
scroll_area->ensureWidgetVisible(it->second);
}
}
}
void TogglesPanel::showEvent(QShowEvent *event) { void TogglesPanel::showEvent(QShowEvent *event) {
updateToggles(); updateToggles();
} }
@@ -222,7 +231,7 @@ DevicePanel::DevicePanel(SettingsWindow *parent) : ListWidget(parent) {
addItem(dcamBtn); addItem(dcamBtn);
#endif #endif
auto resetCalibBtn = new ButtonControl(tr("Reset Calibration"), tr("RESET"), ""); resetCalibBtn = new ButtonControl(tr("Reset Calibration"), tr("RESET"), "");
connect(resetCalibBtn, &ButtonControl::showDescriptionEvent, this, &DevicePanel::updateCalibDescription); connect(resetCalibBtn, &ButtonControl::showDescriptionEvent, this, &DevicePanel::updateCalibDescription);
connect(resetCalibBtn, &ButtonControl::clicked, [&]() { connect(resetCalibBtn, &ButtonControl::clicked, [&]() {
if (!uiState()->engaged()) { if (!uiState()->engaged()) {
@@ -235,6 +244,7 @@ DevicePanel::DevicePanel(SettingsWindow *parent) : ListWidget(parent) {
params.remove("LiveParametersV2"); params.remove("LiveParametersV2");
params.remove("LiveDelay"); params.remove("LiveDelay");
params.putBool("OnroadCycleRequested", true); params.putBool("OnroadCycleRequested", true);
updateCalibDescription();
} }
} }
} else { } else {
@@ -313,9 +323,7 @@ DevicePanel::DevicePanel(SettingsWindow *parent) : ListWidget(parent) {
} }
void DevicePanel::updateCalibDescription() { void DevicePanel::updateCalibDescription() {
QString desc = QString desc = tr("sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down.");
tr("sunnypilot requires the device to be mounted within 4° left or right and "
"within 5° up or 9° down. sunnypilot is continuously calibrating, resetting is rarely required.");
std::string calib_bytes = params.get("CalibrationParams"); std::string calib_bytes = params.get("CalibrationParams");
if (!calib_bytes.empty()) { if (!calib_bytes.empty()) {
try { try {
@@ -333,8 +341,53 @@ void DevicePanel::updateCalibDescription() {
qInfo() << "invalid CalibrationParams"; qInfo() << "invalid CalibrationParams";
} }
} }
desc += tr(" Resetting calibration will restart openpilot if the car is powered on.");
qobject_cast<ButtonControl *>(sender())->setDescription(desc); const bool is_release = params.getBool("IsReleaseBranch");
if (!is_release) {
int lag_perc = 0;
std::string lag_bytes = params.get("LiveDelay");
if (!lag_bytes.empty()) {
try {
AlignedBuffer aligned_buf;
capnp::FlatArrayMessageReader cmsg(aligned_buf.align(lag_bytes.data(), lag_bytes.size()));
lag_perc = cmsg.getRoot<cereal::Event>().getLiveDelay().getCalPerc();
} catch (kj::Exception) {
qInfo() << "invalid LiveDelay";
}
}
desc += "\n\n";
if (lag_perc < 100) {
desc += tr("Steering lag calibration is %1% complete.").arg(lag_perc);
} else {
desc += tr("Steering lag calibration is complete.");
}
}
std::string torque_bytes = params.get("LiveTorqueParameters");
if (!torque_bytes.empty()) {
try {
AlignedBuffer aligned_buf;
capnp::FlatArrayMessageReader cmsg(aligned_buf.align(torque_bytes.data(), torque_bytes.size()));
auto torque = cmsg.getRoot<cereal::Event>().getLiveTorqueParameters();
// don't add for non-torque cars
if (torque.getUseParams()) {
int torque_perc = torque.getCalPerc();
desc += is_release ? "\n\n" : " ";
if (torque_perc < 100) {
desc += tr("Steering torque response calibration is %1% complete.").arg(torque_perc);
} else {
desc += tr("Steering torque response calibration is complete.");
}
}
} catch (kj::Exception) {
qInfo() << "invalid LiveTorqueParameters";
}
}
desc += "\n\n";
desc += tr("openpilot is continuously calibrating, resetting is rarely required. "
"Resetting calibration will restart openpilot if the car is powered on.");
resetCalibBtn->setDescription(desc);
} }
void DevicePanel::reboot() { void DevicePanel::reboot() {
@@ -387,6 +440,7 @@ void SettingsWindow::setCurrentPanel(int index, const QString &param) {
} }
} else { } else {
emit expandToggleDescription(param); emit expandToggleDescription(param);
emit scrollToToggle(param);
} }
} }
@@ -427,6 +481,7 @@ SettingsWindow::SettingsWindow(QWidget *parent) : QFrame(parent) {
TogglesPanel *toggles = new TogglesPanel(this); TogglesPanel *toggles = new TogglesPanel(this);
QObject::connect(this, &SettingsWindow::expandToggleDescription, toggles, &TogglesPanel::expandToggleDescription); QObject::connect(this, &SettingsWindow::expandToggleDescription, toggles, &TogglesPanel::expandToggleDescription);
QObject::connect(this, &SettingsWindow::scrollToToggle, toggles, &TogglesPanel::scrollToToggle);
auto networking = new Networking(this); auto networking = new Networking(this);
QObject::connect(uiState()->prime_state, &PrimeState::changed, networking, &Networking::setPrimeType); QObject::connect(uiState()->prime_state, &PrimeState::changed, networking, &Networking::setPrimeType);
+3
View File
@@ -42,6 +42,7 @@ signals:
void reviewTrainingGuide(); void reviewTrainingGuide();
void showDriverView(); void showDriverView();
void expandToggleDescription(const QString &param); void expandToggleDescription(const QString &param);
void scrollToToggle(const QString &param);
protected: protected:
QPushButton *sidebar_alert_widget; QPushButton *sidebar_alert_widget;
@@ -67,6 +68,7 @@ protected slots:
protected: protected:
Params params; Params params;
ButtonControl *pair_device; ButtonControl *pair_device;
ButtonControl *resetCalibBtn;
}; };
class TogglesPanel : public ListWidget { class TogglesPanel : public ListWidget {
@@ -77,6 +79,7 @@ public:
public slots: public slots:
void expandToggleDescription(const QString &param); void expandToggleDescription(const QString &param);
void scrollToToggle(const QString &param);
protected slots: protected slots:
virtual void updateState(const UIState &s); virtual void updateState(const UIState &s);
+65 -13
View File
@@ -136,6 +136,59 @@ QWidget * Setup::low_voltage() {
return widget; return widget;
} }
QWidget * Setup::custom_software_warning() {
QWidget *widget = new QWidget();
QVBoxLayout *main_layout = new QVBoxLayout(widget);
main_layout->setContentsMargins(55, 0, 55, 55);
main_layout->setSpacing(0);
QVBoxLayout *inner_layout = new QVBoxLayout();
inner_layout->setContentsMargins(110, 110, 300, 0);
main_layout->addLayout(inner_layout);
QLabel *title = new QLabel(tr("WARNING: Custom Software"));
title->setStyleSheet("font-size: 90px; font-weight: 500; color: #FF594F;");
inner_layout->addWidget(title, 0, Qt::AlignTop | Qt::AlignLeft);
inner_layout->addSpacing(25);
QLabel *body = new QLabel(tr("Use caution when installing third-party software. Third-party software has not been tested by comma, and may cause damage to your device and/or vehicle.\n\nIf you'd like to proceed, use https://flash.comma.ai to restore your device to a factory state later."));
body->setWordWrap(true);
body->setAlignment(Qt::AlignTop | Qt::AlignLeft);
body->setStyleSheet("font-size: 65px; font-weight: 300;");
inner_layout->addWidget(body);
inner_layout->addStretch();
QHBoxLayout *blayout = new QHBoxLayout();
blayout->setSpacing(50);
main_layout->addLayout(blayout, 0);
QPushButton *back = new QPushButton(tr("Back"));
back->setObjectName("navBtn");
blayout->addWidget(back);
QObject::connect(back, &QPushButton::clicked, this, &Setup::prevPage);
QPushButton *cont = new QPushButton(tr("Continue"));
cont->setObjectName("navBtn");
blayout->addWidget(cont);
QObject::connect(cont, &QPushButton::clicked, this, [=]() {
QTimer::singleShot(0, [=]() {
setCurrentWidget(downloading_widget);
});
QString url = InputDialog::getText(tr("Enter URL"), this, tr("for Custom Software"));
if (!url.isEmpty()) {
QTimer::singleShot(1000, this, [=]() {
download(url);
});
} else {
setCurrentWidget(software_selection_widget);
}
});
return widget;
}
QWidget * Setup::getting_started() { QWidget * Setup::getting_started() {
QWidget *widget = new QWidget(); QWidget *widget = new QWidget();
@@ -305,20 +358,17 @@ QWidget * Setup::software_selection() {
blayout->addWidget(cont); blayout->addWidget(cont);
QObject::connect(cont, &QPushButton::clicked, [=]() { QObject::connect(cont, &QPushButton::clicked, [=]() {
auto w = currentWidget();
QTimer::singleShot(0, [=]() {
setCurrentWidget(downloading_widget);
});
QString url = OPENPILOT_URL;
if (group->checkedButton() != openpilot) { if (group->checkedButton() != openpilot) {
url = InputDialog::getText(tr("Enter URL"), this, tr("for Custom Software")); QTimer::singleShot(0, [=]() {
} setCurrentWidget(custom_software_warning_widget);
if (!url.isEmpty()) {
QTimer::singleShot(1000, this, [=]() {
download(url);
}); });
} else { } else {
setCurrentWidget(w); QTimer::singleShot(0, [=]() {
setCurrentWidget(downloading_widget);
});
QTimer::singleShot(1000, this, [=]() {
download(OPENPILOT_URL);
});
} }
}); });
@@ -415,8 +465,10 @@ Setup::Setup(QWidget *parent) : QStackedWidget(parent) {
addWidget(getting_started()); addWidget(getting_started());
addWidget(network_setup()); addWidget(network_setup());
addWidget(software_selection()); software_selection_widget = software_selection();
addWidget(software_selection_widget);
custom_software_warning_widget = custom_software_warning();
addWidget(custom_software_warning_widget);
downloading_widget = downloading(); downloading_widget = downloading();
addWidget(downloading_widget); addWidget(downloading_widget);
+3
View File
@@ -15,6 +15,7 @@ public:
private: private:
void selectLanguage(); void selectLanguage();
QWidget *low_voltage(); QWidget *low_voltage();
QWidget *custom_software_warning();
QWidget *getting_started(); QWidget *getting_started();
QWidget *network_setup(); QWidget *network_setup();
QWidget *software_selection(); QWidget *software_selection();
@@ -23,6 +24,8 @@ private:
QWidget *failed_widget; QWidget *failed_widget;
QWidget *downloading_widget; QWidget *downloading_widget;
QWidget *custom_software_warning_widget;
QWidget *software_selection_widget;
QTranslator translator; QTranslator translator;
signals: signals:
@@ -7,21 +7,55 @@
#include <algorithm> #include <algorithm>
#include <QJsonDocument> #include <QJsonDocument>
#include <QStyle>
#include "common/model.h" #include "common/model.h"
#include "selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.h" #include "selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.h"
#include "selfdrive/ui/sunnypilot/qt/widgets/scrollview.h" #include "selfdrive/ui/sunnypilot/qt/widgets/scrollview.h"
static const QString progressStyleActive = "QProgressBar {"
" font-size: 40px;"
" font-weight: 200;"
" padding: 1px;"
" border: 3px solid black;"
" border-radius: 10px;"
"}"
"QProgressBar::chunk {"
" background-color: #1e79e8;"
" border-radius: 10px;"
"}";
static const QString progressStyleInactive = progressStyleActive +
"QProgressBar::chunk {"
" background-color: transparent;"
"}";
static const QString progressStyleDone = progressStyleActive +
"QProgressBar {"
" color: #33ab4c;"
"}"
"QProgressBar::chunk {"
" background-color: transparent;"
"}";
static const QString progressStyleError = progressStyleActive +
"QProgressBar {"
" color: red;"
"}"
"QProgressBar::chunk {"
" background-color: transparent;"
"}";
ModelsPanel::ModelsPanel(QWidget *parent) : QWidget(parent) { ModelsPanel::ModelsPanel(QWidget *parent) : QWidget(parent) {
QVBoxLayout *main_layout = new QVBoxLayout(this); QVBoxLayout *main_layout = new QVBoxLayout(this);
main_layout->setContentsMargins(50, 20, 50, 20); main_layout->setContentsMargins(50, 20, 50, 20);
ListWidgetSP *list = new ListWidgetSP(this); ListWidgetSP *list = new ListWidgetSP(this, false);
ScrollViewSP *scroller = new ScrollViewSP(list, this); ScrollViewSP *scroller = new ScrollViewSP(list, this);
main_layout->addWidget(scroller); main_layout->addWidget(scroller);
const auto current_model = GetActiveModelName(); const auto current_model = GetActiveModelName();
currentModelLblBtn = new ButtonControlSP(tr("Current Model"), tr("SELECT"), current_model); currentModelLblBtn = new ButtonControlSP(tr("Current Model"), tr("SELECT"), "", this);
currentModelLblBtn->setValue(current_model); currentModelLblBtn->setValue(current_model);
connect(currentModelLblBtn, &ButtonControlSP::clicked, this, &ModelsPanel::handleCurrentModelLblBtnClicked); connect(currentModelLblBtn, &ButtonControlSP::clicked, this, &ModelsPanel::handleCurrentModelLblBtnClicked);
@@ -32,6 +66,29 @@ ModelsPanel::ModelsPanel(QWidget *parent) : QWidget(parent) {
connect(uiStateSP(), &UIStateSP::uiUpdate, this, &ModelsPanel::updateLabels); connect(uiStateSP(), &UIStateSP::uiUpdate, this, &ModelsPanel::updateLabels);
list->addItem(currentModelLblBtn); list->addItem(currentModelLblBtn);
// Create progress bars for downloads
supercomboProgressBar = createProgressBar(this);
QString supercomboType = tr("Driving Model");
supercomboFrame = createModelDetailFrame(this, supercomboType, supercomboProgressBar);
list->addItem(supercomboFrame);
navigationProgressBar = createProgressBar(this);
QString navigationType = tr("Navigation Model");
navigationFrame = createModelDetailFrame(this, navigationType, navigationProgressBar);
list->addItem(navigationFrame);
visionProgressBar = createProgressBar(this);
QString visionType = tr("Vision Model");
visionFrame = createModelDetailFrame(this, visionType, visionProgressBar);
list->addItem(visionFrame);
policyProgressBar = createProgressBar(this);
QString policyType = tr("Policy Model");
policyFrame = createModelDetailFrame(this, policyType, policyProgressBar);
list->addItem(policyFrame);
list->addItem(horizontal_line());
// LiveDelay toggle // LiveDelay toggle
list->addItem(new ParamControlSP("LagdToggle", list->addItem(new ParamControlSP("LagdToggle",
tr("Live Learning Steer Delay"), tr("Live Learning Steer Delay"),
@@ -40,15 +97,38 @@ ModelsPanel::ModelsPanel(QWidget *parent) : QWidget(parent) {
"../assets/offroad/icon_shell.png")); "../assets/offroad/icon_shell.png"));
} }
QProgressBar* ModelsPanel::createProgressBar(QWidget *parent) {
QProgressBar *progressBar = new QProgressBar(parent);
progressBar->setRange(0, 100);
progressBar->setValue(0);
progressBar->setTextVisible(true);
progressBar->setAlignment(Qt::AlignVCenter);
return progressBar;
}
QFrame* ModelsPanel::createModelDetailFrame(QWidget *parent, QString &typeName, QProgressBar *progressBar) {
QFrame *frame = new QFrame(parent);
QHBoxLayout *layout = new QHBoxLayout(frame);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(50);
layout->addWidget(new QLabel(typeName));
layout->addWidget(progressBar);
frame->setVisible(false);
return frame;
}
/** /**
* @brief Updates the UI with bundle download progress information * @brief Updates the UI with bundle download progress information
* Reads status from modelManagerSP cereal message and displays status for all models * Reads status from modelManagerSP cereal message and displays status for all models
*/ */
void ModelsPanel::handleBundleDownloadProgress() { void ModelsPanel::handleBundleDownloadProgress() {
supercomboFrame->setVisible(false);
visionFrame->setVisible(false);
policyFrame->setVisible(false);
navigationFrame->setVisible(false);
using DS = cereal::ModelManagerSP::DownloadStatus; using DS = cereal::ModelManagerSP::DownloadStatus;
if (!model_manager.hasSelectedBundle() && !model_manager.hasActiveBundle()) { if (!model_manager.hasSelectedBundle() && !model_manager.hasActiveBundle()) {
currentModelLblBtn->setDescription(tr("No custom model selected!"));
return; return;
} }
@@ -61,21 +141,27 @@ void ModelsPanel::handleBundleDownloadProgress() {
// Get status for each model type in order // Get status for each model type in order
for (const auto &model: models) { for (const auto &model: models) {
QString typeName;
QString modelName = QString::fromStdString(bundle.getDisplayName()); QString modelName = QString::fromStdString(bundle.getDisplayName());
QProgressBar *progressBar = nullptr;
QFrame *modelFrame = nullptr;
switch (model.getType()) { switch (model.getType()) {
case cereal::ModelManagerSP::Model::Type::SUPERCOMBO: case cereal::ModelManagerSP::Model::Type::SUPERCOMBO:
typeName = tr("Driving"); progressBar = supercomboProgressBar;
modelFrame = supercomboFrame;
break; break;
case cereal::ModelManagerSP::Model::Type::NAVIGATION: case cereal::ModelManagerSP::Model::Type::NAVIGATION:
typeName = tr("Navigation"); progressBar = navigationProgressBar;
modelFrame = navigationFrame;
break; break;
case cereal::ModelManagerSP::Model::Type::VISION: case cereal::ModelManagerSP::Model::Type::VISION:
typeName = tr("Vision"); progressBar = visionProgressBar;
modelFrame = visionFrame;
break; break;
case cereal::ModelManagerSP::Model::Type::POLICY: case cereal::ModelManagerSP::Model::Type::POLICY:
typeName = tr("Policy"); progressBar = policyProgressBar;
modelFrame = policyFrame;
break; break;
} }
@@ -83,31 +169,26 @@ void ModelsPanel::handleBundleDownloadProgress() {
QString line; QString line;
if (progress.getStatus() == cereal::ModelManagerSP::DownloadStatus::DOWNLOADING) { if (progress.getStatus() == cereal::ModelManagerSP::DownloadStatus::DOWNLOADING) {
line = tr("Downloading %1 model [%2]... (%3%)").arg(typeName, modelName).arg(progress.getProgress(), 0, 'f', 2); progressBar->setStyleSheet(progressStyleActive);
progressBar->setValue(progress.getProgress());
progressBar->setFormat(QString(" %1% - %2").arg(static_cast<int>(progress.getProgress())).arg(modelName));
device()->resetInteractiveTimeout();
} else if (progress.getStatus() == cereal::ModelManagerSP::DownloadStatus::DOWNLOADED) { } else if (progress.getStatus() == cereal::ModelManagerSP::DownloadStatus::DOWNLOADED) {
line = tr("%1 model [%2] %3").arg(typeName, modelName, download_status_changed ? tr("downloaded") : tr("ready")); progressBar->setStyleSheet(progressStyleDone);
progressBar->setFormat(tr(" %1 - %2").arg(modelName, download_status_changed ? tr("downloaded") : tr("ready")));
} else if (progress.getStatus() == cereal::ModelManagerSP::DownloadStatus::CACHED) { } else if (progress.getStatus() == cereal::ModelManagerSP::DownloadStatus::CACHED) {
line = tr("%1 model [%2] %3").arg(typeName, modelName, download_status_changed ? tr("from cache") : tr("ready")); progressBar->setStyleSheet(progressStyleDone);
progressBar->setFormat(tr(" %1 - %2").arg(modelName, download_status_changed ? tr("from cache") : tr("ready")));
} else if (progress.getStatus() == cereal::ModelManagerSP::DownloadStatus::FAILED) { } else if (progress.getStatus() == cereal::ModelManagerSP::DownloadStatus::FAILED) {
line = tr("%1 model [%2] download failed").arg(typeName, modelName); progressBar->setStyleSheet(progressStyleError);
progressBar->setFormat(tr(" download failed - %1").arg(modelName));
} else { } else {
line = tr("%1 model [%2] pending...").arg(typeName, modelName); progressBar->setStyleSheet(progressStyleInactive);
progressBar->setFormat(tr(" pending - %1").arg(modelName));
} }
status.append(line); // keep navigation hidden for now to avoid confusion
} if (model.getType() != cereal::ModelManagerSP::Model::Type::NAVIGATION) {
modelFrame->setVisible(true);
currentModelLblBtn->setDescription(status.join("\n"));
if (prev_download_status != download_status) {
switch (bundle.getStatus()) {
case cereal::ModelManagerSP::DownloadStatus::DOWNLOADING:
case cereal::ModelManagerSP::DownloadStatus::CACHED:
case cereal::ModelManagerSP::DownloadStatus::DOWNLOADED:
currentModelLblBtn->showDescription();
break;
case cereal::ModelManagerSP::DownloadStatus::FAILED:
default:
break;
} }
} }
prev_download_status = download_status; prev_download_status = download_status;
@@ -125,6 +206,17 @@ QString ModelsPanel::GetActiveModelName() {
return DEFAULT_MODEL; return DEFAULT_MODEL;
} }
/**
* @brief Gets the short name of the currently selected model bundle
* @return Display short name of the selected bundle or default model name
*/
QString ModelsPanel::GetActiveModelInternalName() {
if (model_manager.hasActiveBundle()) {
return QString::fromStdString(model_manager.getActiveBundle().getInternalName());
}
return DEFAULT_MODEL;
}
void ModelsPanel::updateModelManagerState() { void ModelsPanel::updateModelManagerState() {
const SubMaster &sm = *(uiStateSP()->sm); const SubMaster &sm = *(uiStateSP()->sm);
model_manager = sm["modelManagerSP"].getModelManagerSP(); model_manager = sm["modelManagerSP"].getModelManagerSP();
@@ -156,7 +248,7 @@ void ModelsPanel::handleCurrentModelLblBtnClicked() {
bundleNames.append(index_to_bundle[index]); bundleNames.append(index_to_bundle[index]);
} }
currentModelLblBtn->setValue(GetActiveModelName()); currentModelLblBtn->setValue(GetActiveModelInternalName());
const QString selectedBundleName = MultiOptionDialog::getSelection( const QString selectedBundleName = MultiOptionDialog::getSelection(
tr("Select a Model"), bundleNames, GetActiveModelName(), this); tr("Select a Model"), bundleNames, GetActiveModelName(), this);
@@ -197,7 +289,7 @@ void ModelsPanel::updateLabels() {
updateModelManagerState(); updateModelManagerState();
handleBundleDownloadProgress(); handleBundleDownloadProgress();
currentModelLblBtn->setEnabled(!is_onroad && !isDownloading()); currentModelLblBtn->setEnabled(!is_onroad && !isDownloading());
currentModelLblBtn->setValue(GetActiveModelName()); currentModelLblBtn->setValue(GetActiveModelInternalName());
} }
/** /**
@@ -7,6 +7,8 @@
#pragma once #pragma once
#include <QProgressBar>
#include "selfdrive/ui/sunnypilot/qt/offroad/settings/settings.h" #include "selfdrive/ui/sunnypilot/qt/offroad/settings/settings.h"
class ModelsPanel : public QWidget { class ModelsPanel : public QWidget {
@@ -17,6 +19,7 @@ public:
private: private:
QString GetActiveModelName(); QString GetActiveModelName();
QString GetActiveModelInternalName();
void updateModelManagerState(); void updateModelManagerState();
bool isDownloading() const { bool isDownloading() const {
@@ -33,6 +36,8 @@ private:
void handleCurrentModelLblBtnClicked(); void handleCurrentModelLblBtnClicked();
void handleBundleDownloadProgress(); void handleBundleDownloadProgress();
void showResetParamsDialog(); void showResetParamsDialog();
QProgressBar* createProgressBar(QWidget *parent);
QFrame* createModelDetailFrame(QWidget *parent, QString &typeName, QProgressBar *progressBar);
cereal::ModelManagerSP::Reader model_manager; cereal::ModelManagerSP::Reader model_manager;
cereal::ModelManagerSP::DownloadStatus download_status{}; cereal::ModelManagerSP::DownloadStatus download_status{};
cereal::ModelManagerSP::DownloadStatus prev_download_status{}; cereal::ModelManagerSP::DownloadStatus prev_download_status{};
@@ -59,6 +64,14 @@ private:
bool is_onroad = false; bool is_onroad = false;
ButtonControlSP *currentModelLblBtn; ButtonControlSP *currentModelLblBtn;
QProgressBar *supercomboProgressBar;
QFrame *supercomboFrame;
QProgressBar *navigationProgressBar;
QFrame *navigationFrame;
QProgressBar *visionProgressBar;
QFrame *visionFrame;
QProgressBar *policyProgressBar;
QFrame *policyFrame;
Params params; Params params;
}; };
@@ -72,6 +72,7 @@ SettingsWindowSP::SettingsWindowSP(QWidget *parent) : SettingsWindow(parent) {
TogglesPanelSP *toggles = new TogglesPanelSP(this); TogglesPanelSP *toggles = new TogglesPanelSP(this);
QObject::connect(this, &SettingsWindowSP::expandToggleDescription, toggles, &TogglesPanel::expandToggleDescription); QObject::connect(this, &SettingsWindowSP::expandToggleDescription, toggles, &TogglesPanel::expandToggleDescription);
QObject::connect(this, &SettingsWindowSP::scrollToToggle, toggles, &TogglesPanel::scrollToToggle);
auto networking = new NetworkingSP(this); auto networking = new NetworkingSP(this);
QObject::connect(uiState()->prime_state, &PrimeState::changed, networking, &NetworkingSP::setPrimeType); QObject::connect(uiState()->prime_state, &PrimeState::changed, networking, &NetworkingSP::setPrimeType);
+3 -3
View File
@@ -391,9 +391,9 @@ def create_screenshots():
driver_img = frames[2] driver_img = frames[2]
else: else:
with open(frames_cache, 'wb') as f: with open(frames_cache, 'wb') as f:
road_img = FrameReader(route.camera_paths()[segnum]).get(0, pix_fmt="nv12")[0] road_img = FrameReader(route.camera_paths()[segnum], pix_fmt="nv12").get(0)
wide_road_img = FrameReader(route.ecamera_paths()[segnum]).get(0, pix_fmt="nv12")[0] wide_road_img = FrameReader(route.ecamera_paths()[segnum], pix_fmt="nv12").get(0)
driver_img = FrameReader(route.dcamera_paths()[segnum]).get(0, pix_fmt="nv12")[0] driver_img = FrameReader(route.dcamera_paths()[segnum], pix_fmt="nv12").get(0)
pickle.dump([road_img, wide_road_img, driver_img], f) pickle.dump([road_img, wide_road_img, driver_img], f)
STREAMS.append((VisionStreamType.VISION_STREAM_ROAD, cam.fcam, road_img.flatten().tobytes())) STREAMS.append((VisionStreamType.VISION_STREAM_ROAD, cam.fcam, road_img.flatten().tobytes()))
+3 -3
View File
@@ -6,16 +6,16 @@ from openpilot.selfdrive.ui.layouts.main import MainLayout
from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.ui_state import ui_state
def main(): def main():
gui_app.init_window("UI") gui_app.init_window("UI")
main_layout = MainLayout() main_layout = MainLayout()
main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
for _ in gui_app.render(): for _ in gui_app.render():
ui_state.update() ui_state.update()
#TODO handle brigntness and awake state here # TODO handle brigntness and awake state here
main_layout.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) main_layout.render()
kick_watchdog() kick_watchdog()
+43 -1
View File
@@ -1,8 +1,10 @@
import pyray as rl import pyray as rl
import time
from collections.abc import Callable
from enum import Enum from enum import Enum
from cereal import messaging, log from cereal import messaging, log
from openpilot.common.params import Params, UnknownKeyName from openpilot.common.params import Params, UnknownKeyName
from openpilot.selfdrive.ui.lib.prime_state import PrimeState
UI_BORDER_SIZE = 30 UI_BORDER_SIZE = 30
@@ -44,6 +46,8 @@ class UIState:
] ]
) )
self.prime_state = PrimeState()
# UI Status tracking # UI Status tracking
self.status: UIStatus = UIStatus.DISENGAGED self.status: UIStatus = UIStatus.DISENGAGED
self.started_frame: int = 0 self.started_frame: int = 0
@@ -64,10 +68,17 @@ class UIState:
def engaged(self) -> bool: def engaged(self) -> bool:
return self.started and self.sm["selfdriveState"].enabled return self.started and self.sm["selfdriveState"].enabled
def is_onroad(self) -> bool:
return self.started
def is_offroad(self) -> bool:
return not self.started
def update(self) -> None: def update(self) -> None:
self.sm.update(0) self.sm.update(0)
self._update_state() self._update_state()
self._update_status() self._update_status()
device.update()
def _update_state(self) -> None: def _update_state(self) -> None:
# Handle panda states updates # Handle panda states updates
@@ -125,5 +136,36 @@ class UIState:
self.is_metric = False self.is_metric = False
class Device:
def __init__(self):
self._ignition = False
self._interaction_time: float = 0.0
self._interactive_timeout_callbacks: list[Callable] = []
self._prev_timed_out = False
self.reset_interactive_timeout()
def reset_interactive_timeout(self, timeout: int = -1) -> None:
if timeout == -1:
timeout = 10 if ui_state.ignition else 30
self._interaction_time = time.monotonic() + timeout
def add_interactive_timeout_callback(self, callback: Callable):
self._interactive_timeout_callbacks.append(callback)
def update(self):
# Handle interactive timeout
ignition_just_turned_off = not ui_state.ignition and self._ignition
self._ignition = ui_state.ignition
interaction_timeout = time.monotonic() > self._interaction_time
if ignition_just_turned_off or rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT):
self.reset_interactive_timeout()
elif interaction_timeout and not self._prev_timed_out:
for callback in self._interactive_timeout_callbacks:
callback()
self._prev_timed_out = interaction_timeout
# Global instance # Global instance
ui_state = UIState() ui_state = UIState()
device = Device()
View File
+74
View File
@@ -0,0 +1,74 @@
import pyray as rl
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.widget import Widget
class ExperimentalModeButton(Widget):
def __init__(self):
super().__init__()
self.img_width = 80
self.horizontal_padding = 50
self.button_height = 125
self.params = Params()
self.experimental_mode = self.params.get_bool("ExperimentalMode")
self.is_pressed = False
self.chill_pixmap = gui_app.texture("icons/couch.png", self.img_width, self.img_width)
self.experimental_pixmap = gui_app.texture("icons/experimental_grey.png", self.img_width, self.img_width)
def _get_gradient_colors(self):
alpha = 0xCC if self.is_pressed else 0xFF
if self.experimental_mode:
return rl.Color(255, 155, 63, alpha), rl.Color(219, 56, 34, alpha)
else:
return rl.Color(20, 255, 171, alpha), rl.Color(35, 149, 255, alpha)
def _draw_gradient_background(self, rect):
start_color, end_color = self._get_gradient_colors()
rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), int(rect.width), int(rect.height),
start_color, end_color)
def _handle_interaction(self, rect):
mouse_pos = rl.get_mouse_position()
mouse_in_rect = rl.check_collision_point_rec(mouse_pos, rect)
self.is_pressed = mouse_in_rect and rl.is_mouse_button_down(rl.MOUSE_BUTTON_LEFT)
return mouse_in_rect and rl.is_mouse_button_released(rl.MOUSE_BUTTON_LEFT)
def _render(self, rect):
if self._handle_interaction(rect):
self.experimental_mode = not self.experimental_mode
# TODO: Opening settings for ExperimentalMode
self.params.put_bool("ExperimentalMode", self.experimental_mode)
rl.draw_rectangle_rounded(rect, 0.08, 20, rl.Color(255, 255, 255, 255))
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
self._draw_gradient_background(rect)
rl.end_scissor_mode()
# Draw vertical separator line
line_x = rect.x + rect.width - self.img_width - (2 * self.horizontal_padding)
separator_color = rl.Color(0, 0, 0, 77) # 0x4d = 77
rl.draw_line_ex(rl.Vector2(line_x, rect.y), rl.Vector2(line_x, rect.y + rect.height), 3, separator_color)
# Draw text label (left aligned)
text = "EXPERIMENTAL MODE ON" if self.experimental_mode else "CHILL MODE ON"
text_x = rect.x + self.horizontal_padding
text_y = rect.y + rect.height / 2 - 45 // 2 # Center vertically
rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), text, rl.Vector2(int(text_x), int(text_y)), 45, 0, rl.Color(0, 0, 0, 255))
# Draw icon (right aligned)
icon_x = rect.x + rect.width - self.horizontal_padding - self.img_width
icon_y = rect.y + (rect.height - self.img_width) / 2
icon_rect = rl.Rectangle(icon_x, icon_y, self.img_width, self.img_width)
# Draw current mode icon
current_icon = self.experimental_pixmap if self.experimental_mode else self.chill_pixmap
source_rect = rl.Rectangle(0, 0, current_icon.width, current_icon.height)
rl.draw_texture_pro(current_icon, source_rect, icon_rect, rl.Vector2(0, 0), 0, rl.Color(255, 255, 255, 255))
+6 -4
View File
@@ -9,6 +9,8 @@ from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.widget import Widget
class AlertColors: class AlertColors:
HIGH_SEVERITY = rl.Color(226, 44, 44, 255) HIGH_SEVERITY = rl.Color(226, 44, 44, 255)
@@ -40,8 +42,9 @@ class AlertData:
visible: bool = False visible: bool = False
class AbstractAlert(ABC): class AbstractAlert(Widget, ABC):
def __init__(self, has_reboot_btn: bool = False): def __init__(self, has_reboot_btn: bool = False):
super().__init__()
self.params = Params() self.params = Params()
self.has_reboot_btn = has_reboot_btn self.has_reboot_btn = has_reboot_btn
self.dismiss_callback: Callable | None = None self.dismiss_callback: Callable | None = None
@@ -67,8 +70,7 @@ class AbstractAlert(ABC):
pass pass
def handle_input(self, mouse_pos: rl.Vector2, mouse_clicked: bool) -> bool: def handle_input(self, mouse_pos: rl.Vector2, mouse_clicked: bool) -> bool:
# TODO: fix scroll_panel.is_click_valid() if not mouse_clicked or not self.scroll_panel.is_touch_valid():
if not mouse_clicked:
return False return False
if rl.check_collision_point_rec(mouse_pos, self.dismiss_btn_rect): if rl.check_collision_point_rec(mouse_pos, self.dismiss_btn_rect):
@@ -88,7 +90,7 @@ class AbstractAlert(ABC):
return False return False
def render(self, rect: rl.Rectangle): def _render(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(rect, AlertConstants.BORDER_RADIUS / rect.width, 10, AlertColors.BACKGROUND) rl.draw_rectangle_rounded(rect, AlertConstants.BORDER_RADIUS / rect.width, 10, AlertColors.BACKGROUND)
footer_height = AlertConstants.BUTTON_SIZE[1] + AlertConstants.SPACING footer_height = AlertConstants.BUTTON_SIZE[1] + AlertConstants.SPACING
+170
View File
@@ -0,0 +1,170 @@
import pyray as rl
import qrcode
import numpy as np
import time
from openpilot.common.api import Api
from openpilot.common.swaglog import cloudlog
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.lib.text_measure import measure_text_cached
class PairingDialog:
"""Dialog for device pairing with QR code."""
QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds
def __init__(self):
self.params = Params()
self.qr_texture: rl.Texture | None = None
self.last_qr_generation = 0
def _get_pairing_url(self) -> str:
try:
dongle_id = self.params.get("DongleId", encoding='utf8') or ""
token = Api(dongle_id).get_token()
except Exception as e:
cloudlog.warning(f"Failed to get pairing token: {e}")
token = ""
return f"https://connect.comma.ai/setup?token={token}"
def _generate_qr_code(self) -> None:
try:
qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4)
qr.add_data(self._get_pairing_url())
qr.make(fit=True)
pil_img = qr.make_image(fill_color="black", back_color="white").convert('RGBA')
img_array = np.array(pil_img, dtype=np.uint8)
if self.qr_texture and self.qr_texture.id != 0:
rl.unload_texture(self.qr_texture)
rl_image = rl.Image()
rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data)
rl_image.width = pil_img.width
rl_image.height = pil_img.height
rl_image.mipmaps = 1
rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8
self.qr_texture = rl.load_texture_from_image(rl_image)
except Exception as e:
cloudlog.warning(f"QR code generation failed: {e}")
self.qr_texture = None
def _check_qr_refresh(self) -> None:
current_time = time.time()
if current_time - self.last_qr_generation >= self.QR_REFRESH_INTERVAL:
self._generate_qr_code()
self.last_qr_generation = current_time
def render(self, rect: rl.Rectangle) -> int:
rl.clear_background(rl.Color(224, 224, 224, 255))
self._check_qr_refresh()
margin = 70
content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, rect.width - 2 * margin, rect.height - 2 * margin)
y = content_rect.y
# Close button
close_size = 80
close_icon = gui_app.texture("icons/close.png", close_size, close_size)
close_rect = rl.Rectangle(content_rect.x, y, close_size, close_size)
mouse_pos = rl.get_mouse_position()
is_hover = rl.check_collision_point_rec(mouse_pos, close_rect)
is_pressed = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
is_released = rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT)
color = rl.Color(180, 180, 180, 150) if (is_hover and is_pressed) else rl.WHITE
rl.draw_texture(close_icon, int(content_rect.x), int(y), color)
if (is_hover and is_released) or rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
return 1
y += close_size + 40
# Title
title = "Pair your device to your comma account"
title_font = gui_app.font(FontWeight.NORMAL)
left_width = int(content_rect.width * 0.5 - 15)
title_wrapped = wrap_text(title_font, title, 75, left_width)
rl.draw_text_ex(title_font, "\n".join(title_wrapped), rl.Vector2(content_rect.x, y), 75, 0.0, rl.BLACK)
y += len(title_wrapped) * 75 + 60
# Two columns: instructions and QR code
remaining_height = content_rect.height - (y - content_rect.y)
right_width = content_rect.width // 2 - 20
# Instructions
self._render_instructions(rl.Rectangle(content_rect.x, y, left_width, remaining_height))
# QR code
qr_size = min(right_width, content_rect.height) - 40
qr_x = content_rect.x + left_width + 40 + (right_width - qr_size) // 2
qr_y = content_rect.y
self._render_qr_code(rl.Rectangle(qr_x, qr_y, qr_size, qr_size))
return -1
def _render_instructions(self, rect: rl.Rectangle) -> None:
instructions = [
"Go to https://connect.comma.ai on your phone",
"Click \"add new device\" and scan the QR code on the right",
"Bookmark connect.comma.ai to your home screen to use it like an app",
]
font = gui_app.font(FontWeight.BOLD)
y = rect.y
for i, text in enumerate(instructions):
circle_radius = 25
circle_x = rect.x + circle_radius + 15
text_x = rect.x + circle_radius * 2 + 40
text_width = rect.width - (circle_radius * 2 + 40)
wrapped = wrap_text(font, text, 47, int(text_width))
text_height = len(wrapped) * 47
circle_y = y + text_height // 2
# Circle and number
rl.draw_circle(int(circle_x), int(circle_y), circle_radius, rl.Color(70, 70, 70, 255))
number = str(i + 1)
number_width = measure_text_cached(font, number, 30).x
rl.draw_text(number, int(circle_x - number_width // 2), int(circle_y - 15), 30, rl.WHITE)
# Text
rl.draw_text_ex(font, "\n".join(wrapped), rl.Vector2(text_x, y), 47, 0.0, rl.BLACK)
y += text_height + 50
def _render_qr_code(self, rect: rl.Rectangle) -> None:
if not self.qr_texture:
rl.draw_rectangle_rounded(rect, 0.1, 20, rl.Color(240, 240, 240, 255))
error_font = gui_app.font(FontWeight.BOLD)
rl.draw_text_ex(
error_font, "QR Code Error", rl.Vector2(rect.x + 20, rect.y + rect.height // 2 - 15), 30, 0.0, rl.RED
)
return
source = rl.Rectangle(0, 0, self.qr_texture.width, self.qr_texture.height)
rl.draw_texture_pro(self.qr_texture, source, rect, rl.Vector2(0, 0), 0, rl.WHITE)
def __del__(self):
if self.qr_texture and self.qr_texture.id != 0:
rl.unload_texture(self.qr_texture)
if __name__ == "__main__":
gui_app.init_window("pairing device")
pairing = PairingDialog()
try:
for _ in gui_app.render():
result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
if result != -1:
break
finally:
del pairing
+62
View File
@@ -0,0 +1,62 @@
import pyray as rl
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.label import gui_label
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.widget import Widget
class PrimeWidget(Widget):
"""Widget for displaying comma prime subscription status"""
PRIME_BG_COLOR = rl.Color(51, 51, 51, 255)
def _render(self, rect):
if ui_state.prime_state.is_prime():
self._render_for_prime_user(rect)
else:
self._render_for_non_prime_users(rect)
def _render_for_non_prime_users(self, rect: rl.Rectangle):
"""Renders the advertisement for non-Prime users."""
rl.draw_rectangle_rounded(rect, 0.02, 10, self.PRIME_BG_COLOR)
# Layout
x, y = rect.x + 80, rect.y + 90
w = rect.width - 160
# Title
gui_label(rl.Rectangle(x, y, w, 90), "Upgrade Now", 75, font_weight=FontWeight.BOLD)
# Description with wrapping
desc_y = y + 140
font = gui_app.font(FontWeight.LIGHT)
wrapped_text = "\n".join(wrap_text(font, "Become a comma prime member at connect.comma.ai", 56, int(w)))
text_size = measure_text_cached(font, wrapped_text, 56)
rl.draw_text_ex(font, wrapped_text, rl.Vector2(x, desc_y), 56, 0, rl.Color(255, 255, 255, 255))
# Features section
features_y = desc_y + text_size.y + 50
gui_label(rl.Rectangle(x, features_y, w, 50), "PRIME FEATURES:", 41, font_weight=FontWeight.BOLD)
# Feature list
features = ["Remote access", "24/7 LTE connectivity", "1 year of drive storage", "Remote snapshots"]
for i, feature in enumerate(features):
item_y = features_y + 80 + i * 65
gui_label(rl.Rectangle(x, item_y, 50, 60), "", 50, color=rl.Color(70, 91, 234, 255))
gui_label(rl.Rectangle(x + 60, item_y, w - 60, 60), feature, 50)
def _render_for_prime_user(self, rect: rl.Rectangle):
"""Renders the prime user widget with subscription status."""
rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width, 230), 0.02, 10, self.PRIME_BG_COLOR)
x = rect.x + 56
y = rect.y + 40
font = gui_app.font(FontWeight.BOLD)
rl.draw_text_ex(font, "✓ SUBSCRIBED", rl.Vector2(x, y), 41, 0, rl.Color(134, 255, 78, 255))
rl.draw_text_ex(font, "comma prime", rl.Vector2(x, y + 61), 75, 0, rl.WHITE)
+94
View File
@@ -0,0 +1,94 @@
import pyray as rl
from openpilot.selfdrive.ui.lib.prime_state import PrimeType
from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.lib.widget import Widget
class SetupWidget(Widget):
def __init__(self):
super().__init__()
self._open_settings_callback = None
self._pairing_dialog: PairingDialog | None = None
def set_open_settings_callback(self, callback):
self._open_settings_callback = callback
def _render(self, rect: rl.Rectangle):
if ui_state.prime_state.get_type() == PrimeType.UNPAIRED:
self._render_registration(rect)
else:
self._render_firehose_prompt(rect)
def _render_registration(self, rect: rl.Rectangle):
"""Render registration prompt."""
rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width, 590), 0.02, 20, rl.Color(51, 51, 51, 255))
x = rect.x + 64
y = rect.y + 48
w = rect.width - 128
# Title
font = gui_app.font(FontWeight.BOLD)
rl.draw_text_ex(font, "Finish Setup", rl.Vector2(x, y), 75, 0, rl.WHITE)
y += 113 # 75 + 38 spacing
# Description
desc = "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer."
light_font = gui_app.font(FontWeight.LIGHT)
wrapped = wrap_text(light_font, desc, 50, int(w))
for line in wrapped:
rl.draw_text_ex(light_font, line, rl.Vector2(x, y), 50, 0, rl.WHITE)
y += 50
button_rect = rl.Rectangle(x, y + 50, w, 128)
if gui_button(button_rect, "Pair device", button_style=ButtonStyle.PRIMARY):
self._show_pairing()
def _render_firehose_prompt(self, rect: rl.Rectangle):
"""Render firehose prompt widget."""
rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width, 450), 0.02, 20, rl.Color(51, 51, 51, 255))
# Content margins (56, 40, 56, 40)
x = rect.x + 56
y = rect.y + 40
w = rect.width - 112
spacing = 42
# Title with fire emojis
title_font = gui_app.font(FontWeight.MEDIUM)
title_text = "Firehose Mode"
rl.draw_text_ex(title_font, title_text, rl.Vector2(x, y), 64, 0, rl.WHITE)
y += 64 + spacing
# Description
desc_font = gui_app.font(FontWeight.NORMAL)
desc_text = "Maximize your training data uploads to improve openpilot's driving models."
wrapped_desc = wrap_text(desc_font, desc_text, 40, int(w))
for line in wrapped_desc:
rl.draw_text_ex(desc_font, line, rl.Vector2(x, y), 40, 0, rl.WHITE)
y += 40
y += spacing
# Open button
button_height = 48 + 64 # font size + padding
button_rect = rl.Rectangle(x, y, w, button_height)
if gui_button(button_rect, "Open", button_style=ButtonStyle.PRIMARY):
if self._open_settings_callback:
self._open_settings_callback()
def _show_pairing(self):
if not self._pairing_dialog:
self._pairing_dialog = PairingDialog()
gui_app.set_modal_overlay(self._pairing_dialog, lambda result: setattr(self, '_pairing_dialog', None))
def __del__(self):
if self._pairing_dialog:
del self._pairing_dialog
+128
View File
@@ -0,0 +1,128 @@
import pyray as rl
import requests
import threading
import copy
from enum import Enum
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
from openpilot.system.ui.lib.list_view import (
ItemAction,
ListItem,
BUTTON_HEIGHT,
BUTTON_BORDER_RADIUS,
BUTTON_FONT_SIZE,
BUTTON_WIDTH,
)
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.widget import DialogResult
from openpilot.system.ui.widgets.confirm_dialog import alert_dialog
from openpilot.system.ui.widgets.keyboard import Keyboard
class SshKeyActionState(Enum):
LOADING = "LOADING"
ADD = "ADD"
REMOVE = "REMOVE"
class SshKeyAction(ItemAction):
HTTP_TIMEOUT = 15 # seconds
MAX_WIDTH = 500
def __init__(self):
super().__init__(self.MAX_WIDTH, True)
self._keyboard = Keyboard()
self._params = Params()
self._error_message: str = ""
self._text_font = gui_app.font(FontWeight.MEDIUM)
self._refresh_state()
def _refresh_state(self):
self._username = self._params.get("GithubUsername", "")
self._state = SshKeyActionState.REMOVE if self._params.get("GithubSshKeys") else SshKeyActionState.ADD
def _render(self, rect: rl.Rectangle) -> bool:
# Show error dialog if there's an error
if self._error_message:
message = copy.copy(self._error_message)
gui_app.set_modal_overlay(lambda: alert_dialog(message))
self._username = ""
self._error_message = ""
# Draw username if exists
if self._username:
text_size = measure_text_cached(self._text_font, self._username, BUTTON_FONT_SIZE)
rl.draw_text_ex(
self._text_font,
self._username,
(rect.x + rect.width - BUTTON_WIDTH - text_size.x - 30, rect.y + (rect.height - text_size.y) / 2),
BUTTON_FONT_SIZE,
1.0,
rl.WHITE,
)
# Draw button
if gui_button(
rl.Rectangle(
rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT
),
self._state.value,
is_enabled=self._state != SshKeyActionState.LOADING,
border_radius=BUTTON_BORDER_RADIUS,
font_size=BUTTON_FONT_SIZE,
button_style=ButtonStyle.LIST_ACTION,
):
self._handle_button_click()
return True
return False
def _handle_button_click(self):
if self._state == SshKeyActionState.ADD:
self._keyboard.clear()
self._keyboard.set_title("Enter your GitHub username")
gui_app.set_modal_overlay(self._keyboard, callback=self._on_username_submit)
elif self._state == SshKeyActionState.REMOVE:
self._params.remove("GithubUsername")
self._params.remove("GithubSshKeys")
self._refresh_state()
def _on_username_submit(self, result: DialogResult):
if result != DialogResult.CONFIRM:
return
username = self._keyboard.text.strip()
if not username:
return
self._state = SshKeyActionState.LOADING
threading.Thread(target=lambda: self._fetch_ssh_key(username), daemon=True).start()
def _fetch_ssh_key(self, username: str):
try:
url = f"https://github.com/{username}.keys"
response = requests.get(url, timeout=self.HTTP_TIMEOUT)
response.raise_for_status()
keys = response.text.strip()
if not keys:
raise requests.exceptions.HTTPError("No SSH keys found")
# Success - save keys
self._params.put("GithubUsername", username)
self._params.put("GithubSshKeys", keys)
self._state = SshKeyActionState.REMOVE
self._username = username
except requests.exceptions.Timeout:
self._error_message = "Request timed out"
self._state = SshKeyActionState.ADD
except Exception:
self._error_message = f"No SSH keys found for user '{username}'"
self._state = SshKeyActionState.ADD
def ssh_key_item(title: str, description: str):
return ListItem(title=title, description=description, action_item=SshKeyAction())
+1 -1
View File
@@ -18,7 +18,7 @@ from cereal import messaging
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.sunnypilot.mapd.mapd_manager import MAPD_PATH, MAPD_BIN_DIR from openpilot.sunnypilot.mapd.mapd_manager import MAPD_PATH, MAPD_BIN_DIR
from openpilot.system.hardware.hw import Paths from openpilot.system.hardware.hw import Paths
from openpilot.system.ui.spinner import Spinner from openpilot.common.spinner import Spinner
from openpilot.system.version import is_prebuilt from openpilot.system.version import is_prebuilt
import openpilot.system.sentry as sentry import openpilot.system.sentry as sentry
+1 -1
View File
@@ -1 +1 @@
dec34c57c4131f6fca5d1035201d1afbf43e5250cede7bfdc798371af008afad 71979b29c4bab3007de1a4265442d79f44c0eaef066af66086dddfc432709b94
+1 -1
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from datetime import datetime, timedelta, UTC from datetime import datetime, timedelta, UTC
from openpilot.common.api import api_get from openpilot.common.api import api_get
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.common.spinner import Spinner
from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
from openpilot.system.hardware import HARDWARE, PC from openpilot.system.hardware import HARDWARE, PC
from openpilot.system.hardware.hw import Paths from openpilot.system.hardware.hw import Paths
@@ -44,7 +45,6 @@ def register(show_spinner=False) -> str | None:
cloudlog.warning(f"missing public key: {pubkey}") cloudlog.warning(f"missing public key: {pubkey}")
elif dongle_id is None: elif dongle_id is None:
if show_spinner: if show_spinner:
from openpilot.system.ui.spinner import Spinner
spinner = Spinner() spinner = Spinner()
spinner.update("registering device") spinner.update("registering device")
+10 -10
View File
@@ -56,28 +56,28 @@
}, },
{ {
"name": "boot", "name": "boot",
"url": "https://commadist.azureedge.net/agnosupdate/boot-4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42.img.xz", "url": "https://commadist.azureedge.net/agnosupdate/boot-4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef.img.xz",
"hash": "4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42", "hash": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
"hash_raw": "4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42", "hash_raw": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
"size": 18479104, "size": 18479104,
"sparse": false, "sparse": false,
"full_check": true, "full_check": true,
"has_ab": true, "has_ab": true,
"ondevice_hash": "6b7b3371100ad36d8a5a9ff19a1663b9b9e2d5e99cbe3cf9255e9c3017291ce3" "ondevice_hash": "8d7094d774faa4e801e36b403a31b53b913b31d086f4dc682d2f64710c557e8a"
}, },
{ {
"name": "system", "name": "system",
"url": "https://commadist.azureedge.net/agnosupdate/system-c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa.img.xz", "url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img.xz",
"hash": "993d6a1cd2b684e2b1cf6ff840f8996f02a529011372d9c1471e4c80719e7da9", "hash": "cccd7073d067027396f2afd49874729757db0bbbc79853a0bf2938bd356fe164",
"hash_raw": "c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa", "hash_raw": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
"size": 5368709120, "size": 5368709120,
"sparse": true, "sparse": true,
"full_check": false, "full_check": false,
"has_ab": true, "has_ab": true,
"ondevice_hash": "59db25651da977eeb16a1af741fd01fc3d6b50d21544b1a7428b7c86b2cdef2d", "ondevice_hash": "c7707f16ce7d977748677cc354e250943b4ff6c21b9a19a492053d32397cf9ec",
"alt": { "alt": {
"hash": "c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa", "hash": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
"url": "https://commadist.azureedge.net/agnosupdate/system-c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa.img", "url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img",
"size": 5368709120 "size": 5368709120
} }
} }
+22 -22
View File
@@ -339,62 +339,62 @@
}, },
{ {
"name": "boot", "name": "boot",
"url": "https://commadist.azureedge.net/agnosupdate/boot-4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42.img.xz", "url": "https://commadist.azureedge.net/agnosupdate/boot-4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef.img.xz",
"hash": "4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42", "hash": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
"hash_raw": "4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42", "hash_raw": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
"size": 18479104, "size": 18479104,
"sparse": false, "sparse": false,
"full_check": true, "full_check": true,
"has_ab": true, "has_ab": true,
"ondevice_hash": "6b7b3371100ad36d8a5a9ff19a1663b9b9e2d5e99cbe3cf9255e9c3017291ce3" "ondevice_hash": "8d7094d774faa4e801e36b403a31b53b913b31d086f4dc682d2f64710c557e8a"
}, },
{ {
"name": "system", "name": "system",
"url": "https://commadist.azureedge.net/agnosupdate/system-c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa.img.xz", "url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img.xz",
"hash": "993d6a1cd2b684e2b1cf6ff840f8996f02a529011372d9c1471e4c80719e7da9", "hash": "cccd7073d067027396f2afd49874729757db0bbbc79853a0bf2938bd356fe164",
"hash_raw": "c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa", "hash_raw": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
"size": 5368709120, "size": 5368709120,
"sparse": true, "sparse": true,
"full_check": false, "full_check": false,
"has_ab": true, "has_ab": true,
"ondevice_hash": "59db25651da977eeb16a1af741fd01fc3d6b50d21544b1a7428b7c86b2cdef2d", "ondevice_hash": "c7707f16ce7d977748677cc354e250943b4ff6c21b9a19a492053d32397cf9ec",
"alt": { "alt": {
"hash": "c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa", "hash": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
"url": "https://commadist.azureedge.net/agnosupdate/system-c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa.img", "url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img",
"size": 5368709120 "size": 5368709120
} }
}, },
{ {
"name": "userdata_90", "name": "userdata_90",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_90-89a161f17b86637413fe10a641550110b626b699382f5138c02267b7866a8494.img.xz", "url": "https://commadist.azureedge.net/agnosupdate/userdata_90-f0c675e0fae420870c9ba8979fa246b170f4f1a7a04b49609b55b6bdfa8c1b21.img.xz",
"hash": "99d9e6cf6755581c6879bbf442bd62212beb8a04116e965ab987135b8842188b", "hash": "3d8a007bae088c5959eb9b82454013f91868946d78380fecea2b1afdfb575c02",
"hash_raw": "89a161f17b86637413fe10a641550110b626b699382f5138c02267b7866a8494", "hash_raw": "f0c675e0fae420870c9ba8979fa246b170f4f1a7a04b49609b55b6bdfa8c1b21",
"size": 96636764160, "size": 96636764160,
"sparse": true, "sparse": true,
"full_check": true, "full_check": true,
"has_ab": false, "has_ab": false,
"ondevice_hash": "24ea29ab9c4ecec0568a4aa83e38790fedfce694060e90f4bde725931386ff41" "ondevice_hash": "5bfbabb8ff96b149056aa75d5b7e66a7cdd9cb4bcefe23b922c292f7f3a43462"
}, },
{ {
"name": "userdata_89", "name": "userdata_89",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_89-cdd3401168819987c4840765bba1aa2217641b1a6a4165c412f44cac14ccfcbf.img.xz", "url": "https://commadist.azureedge.net/agnosupdate/userdata_89-06fc52be37b42690ed7b4f8c66c4611309a2dea9fca37dd9d27d1eff302eb1bf.img.xz",
"hash": "5fbfa008a7f6b58ab01d4d171f3185924d4c9db69b54f4bfc0f214c6f17c2435", "hash": "443f136484294b210318842d09fb618d5411c8bdbab9f7421d8c89eb291a8d3f",
"hash_raw": "cdd3401168819987c4840765bba1aa2217641b1a6a4165c412f44cac14ccfcbf", "hash_raw": "06fc52be37b42690ed7b4f8c66c4611309a2dea9fca37dd9d27d1eff302eb1bf",
"size": 95563022336, "size": 95563022336,
"sparse": true, "sparse": true,
"full_check": true, "full_check": true,
"has_ab": false, "has_ab": false,
"ondevice_hash": "c07dc2e883a23d4a24d976cdf53a767a2fd699c8eeb476d60cdf18e84b417a52" "ondevice_hash": "67db02b29a7e4435951c64cc962a474d048ed444aa912f3494391417cd51a074"
}, },
{ {
"name": "userdata_30", "name": "userdata_30",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_30-2a8e8278b3bb545e6d7292c2417ccebdca9b47507eb5924f7c1e068737a7edfd.img.xz", "url": "https://commadist.azureedge.net/agnosupdate/userdata_30-06679488f0c5c3fcfd5f351133050751cd189f705e478a979c45fc4a166d18a6.img.xz",
"hash": "b3bc293c9c5e0480ef663e980c8ccb2fb83ffd230c85f8797830fb61b8f59360", "hash": "875b580cb786f290a842e9187fd945657561886123eb3075a26f7995a18068f6",
"hash_raw": "2a8e8278b3bb545e6d7292c2417ccebdca9b47507eb5924f7c1e068737a7edfd", "hash_raw": "06679488f0c5c3fcfd5f351133050751cd189f705e478a979c45fc4a166d18a6",
"size": 32212254720, "size": 32212254720,
"sparse": true, "sparse": true,
"full_check": true, "full_check": true,
"has_ab": false, "has_ab": false,
"ondevice_hash": "8dae1cda089828c750d1d646337774ccd9432f567ecefde19a06dc7feeda9cd3" "ondevice_hash": "16e27ba3c5cf9f0394ce6235ba6021b8a2de293fdb08399f8ca832fa5e4d0b9d"
} }
] ]
+30
View File
@@ -0,0 +1,30 @@
[connection]
id=esim
uuid=fff6553c-3284-4707-a6b1-acc021caaafb
type=gsm
permissions=
autoconnect=true
autoconnect-retries=100
autoconnect-priority=2
metered=1
[gsm]
apn=
home-only=false
auto-config=true
sim-id=
[ipv4]
route-metric=1000
dns-priority=1000
dns-search=
method=auto
[ipv6]
ddr-gen-mode=stable-privacy
dns-search=
route-metric=1000
dns-priority=1000
method=auto
[proxy]
+11 -11
View File
@@ -3,6 +3,7 @@ import math
import os import os
import subprocess import subprocess
import time import time
import tempfile
from enum import IntEnum from enum import IntEnum
from functools import cached_property, lru_cache from functools import cached_property, lru_cache
from pathlib import Path from pathlib import Path
@@ -499,19 +500,18 @@ class Tici(HardwareBase):
except Exception: except Exception:
pass pass
# we use the lte connection built into AGNOS. cleanup esim connection if it exists # eSIM prime
dest = "/etc/NetworkManager/system-connections/esim.nmconnection" dest = "/etc/NetworkManager/system-connections/esim.nmconnection"
if os.path.exists(dest): if sim_id.startswith('8985235') and not os.path.exists(dest):
os.system(f"sudo nmcli con delete {dest}") with open(Path(__file__).parent/'esim.nmconnection') as f, tempfile.NamedTemporaryFile(mode='w') as tf:
self.reboot_modem() dat = f.read()
dat = dat.replace("sim-id=", f"sim-id={sim_id}")
tf.write(dat)
tf.flush()
def reboot_modem(self): # needs to be root
modem = self.get_modem() os.system(f"sudo cp {tf.name} {dest}")
for state in (0, 1): os.system(f"sudo nmcli con load {dest}")
try:
modem.Command(f'AT+CFUN={state}', math.ceil(TIMEOUT), dbus_interface=MM_MODEM, timeout=TIMEOUT)
except Exception:
pass
def get_networks(self): def get_networks(self):
r = {} r = {}
+7 -7
View File
@@ -5,16 +5,16 @@ from pathlib import Path
# NOTE: Do NOT import anything here that needs be built (e.g. params) # NOTE: Do NOT import anything here that needs be built (e.g. params)
from openpilot.common.basedir import BASEDIR from openpilot.common.basedir import BASEDIR
from openpilot.common.spinner import Spinner
from openpilot.common.text_window import TextWindow
from openpilot.common.swaglog import cloudlog, add_file_handler from openpilot.common.swaglog import cloudlog, add_file_handler
from openpilot.system.hardware import HARDWARE, AGNOS from openpilot.system.hardware import HARDWARE, AGNOS
from openpilot.system.ui.spinner import Spinner
from openpilot.system.ui.text import TextWindow
from openpilot.system.version import get_build_metadata from openpilot.system.version import get_build_metadata
MAX_CACHE_SIZE = 4e9 if "CI" in os.environ else 2e9 MAX_CACHE_SIZE = 4e9 if "CI" in os.environ else 2e9
CACHE_DIR = Path("/data/scons_cache" if AGNOS else "/tmp/scons_cache") CACHE_DIR = Path("/data/scons_cache" if AGNOS else "/tmp/scons_cache")
TOTAL_SCONS_NODES = 3765 TOTAL_SCONS_NODES = 3800
MAX_BUILD_PROGRESS = 100 MAX_BUILD_PROGRESS = 100
def build(spinner: Spinner, dirty: bool = False, minimal: bool = False) -> None: def build(spinner: Spinner, dirty: bool = False, minimal: bool = False) -> None:
@@ -88,7 +88,7 @@ def build(spinner: Spinner, dirty: bool = False, minimal: bool = False) -> None:
if __name__ == "__main__": if __name__ == "__main__":
with Spinner() as spinner: spinner = Spinner()
spinner.update_progress(0, 100) spinner.update_progress(0, 100)
build_metadata = get_build_metadata() build_metadata = get_build_metadata()
build(spinner, build_metadata.openpilot.is_dirty, minimal = AGNOS) build(spinner, build_metadata.openpilot.is_dirty, minimal = AGNOS)
+1 -2
View File
@@ -9,6 +9,7 @@ from cereal import log
import cereal.messaging as messaging import cereal.messaging as messaging
import openpilot.system.sentry as sentry import openpilot.system.sentry as sentry
from openpilot.common.params import Params, ParamKeyType from openpilot.common.params import Params, ParamKeyType
from openpilot.common.text_window import TextWindow
from openpilot.system.hardware import HARDWARE from openpilot.system.hardware import HARDWARE
from openpilot.system.manager.helpers import unblock_stdout, write_onroad_params, save_bootlog from openpilot.system.manager.helpers import unblock_stdout, write_onroad_params, save_bootlog
from openpilot.system.manager.process import ensure_running from openpilot.system.manager.process import ensure_running
@@ -234,8 +235,6 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
from openpilot.system.ui.text import TextWindow
unblock_stdout() unblock_stdout()
try: try:
+1 -1
View File
@@ -114,7 +114,7 @@ procs = [
PythonProcess("sensord", "system.sensord.sensord", only_onroad, enabled=not PC), PythonProcess("sensord", "system.sensord.sensord", only_onroad, enabled=not PC),
NativeProcess("ui", "selfdrive/ui", ["./ui"], always_run, watchdog_max_dt=(5 if not PC else None)), NativeProcess("ui", "selfdrive/ui", ["./ui"], always_run, watchdog_max_dt=(5 if not PC else None)),
PythonProcess("soundd", "selfdrive.ui.soundd", and_(only_onroad, not_joystick)), PythonProcess("soundd", "selfdrive.ui.soundd", only_onroad),
PythonProcess("locationd", "selfdrive.locationd.locationd", only_onroad), PythonProcess("locationd", "selfdrive.locationd.locationd", only_onroad),
NativeProcess("_pandad", "selfdrive/pandad", ["./pandad"], always_run, enabled=False), NativeProcess("_pandad", "selfdrive/pandad", ["./pandad"], always_run, enabled=False),
PythonProcess("calibrationd", "selfdrive.locationd.calibrationd", only_onroad), PythonProcess("calibrationd", "selfdrive.locationd.calibrationd", only_onroad),
+52 -7
View File
@@ -1,7 +1,10 @@
import atexit import atexit
import cffi
import os import os
import time import time
import pyray as rl import pyray as rl
from collections.abc import Callable
from dataclasses import dataclass
from enum import IntEnum from enum import IntEnum
from importlib.resources import as_file, files from importlib.resources import as_file, files
from openpilot.common.swaglog import cloudlog from openpilot.common.swaglog import cloudlog
@@ -12,7 +15,7 @@ FPS_LOG_INTERVAL = 5 # Seconds between logging FPS drops
FPS_DROP_THRESHOLD = 0.9 # FPS drop threshold for triggering a warning FPS_DROP_THRESHOLD = 0.9 # FPS drop threshold for triggering a warning
FPS_CRITICAL_THRESHOLD = 0.5 # Critical threshold for triggering strict actions FPS_CRITICAL_THRESHOLD = 0.5 # Critical threshold for triggering strict actions
ENABLE_VSYNC = os.getenv("ENABLE_VSYNC") == "1" ENABLE_VSYNC = os.getenv("ENABLE_VSYNC", "1") == "1"
SHOW_FPS = os.getenv("SHOW_FPS") == '1' SHOW_FPS = os.getenv("SHOW_FPS") == '1'
STRICT_MODE = os.getenv("STRICT_MODE") == '1' STRICT_MODE = os.getenv("STRICT_MODE") == '1'
SCALE = float(os.getenv("SCALE", "1.0")) SCALE = float(os.getenv("SCALE", "1.0"))
@@ -36,6 +39,12 @@ class FontWeight(IntEnum):
BLACK = 8 BLACK = 8
@dataclass
class ModalOverlay:
overlay: object = None
callback: Callable | None = None
class GuiApplication: class GuiApplication:
def __init__(self, width: int, height: int): def __init__(self, width: int, height: int):
self._fonts: dict[FontWeight, rl.Font] = {} self._fonts: dict[FontWeight, rl.Font] = {}
@@ -50,6 +59,7 @@ class GuiApplication:
self._last_fps_log_time: float = time.monotonic() self._last_fps_log_time: float = time.monotonic()
self._window_close_requested = False self._window_close_requested = False
self._trace_log_callback = None self._trace_log_callback = None
self._modal_overlay = ModalOverlay()
def request_close(self): def request_close(self):
self._window_close_requested = True self._window_close_requested = True
@@ -79,6 +89,9 @@ class GuiApplication:
self._set_styles() self._set_styles()
self._load_fonts() self._load_fonts()
def set_modal_overlay(self, overlay, callback: Callable | None = None):
self._modal_overlay = ModalOverlay(overlay=overlay, callback=callback)
def texture(self, asset_path: str, width: int, height: int, alpha_premultiply=False, keep_aspect_ratio=True): def texture(self, asset_path: str, width: int, height: int, alpha_premultiply=False, keep_aspect_ratio=True):
cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}" cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}"
if cache_key in self._textures: if cache_key in self._textures:
@@ -148,7 +161,23 @@ class GuiApplication:
rl.begin_drawing() rl.begin_drawing()
rl.clear_background(rl.BLACK) rl.clear_background(rl.BLACK)
yield # Handle modal overlay rendering and input processing
if self._modal_overlay.overlay:
if hasattr(self._modal_overlay.overlay, 'render'):
result = self._modal_overlay.overlay.render(rl.Rectangle(0, 0, self.width, self.height))
elif callable(self._modal_overlay.overlay):
result = self._modal_overlay.overlay()
else:
raise Exception
if result >= 0:
# Execute callback with the result and clear the overlay
if self._modal_overlay.callback is not None:
self._modal_overlay.callback(result)
self._modal_overlay = ModalOverlay()
else:
yield
if self._render_texture: if self._render_texture:
rl.end_texture_mode() rl.end_texture_mode()
@@ -192,12 +221,11 @@ class GuiApplication:
# Create a character set from our keyboard layouts # Create a character set from our keyboard layouts
from openpilot.system.ui.widgets.keyboard import KEYBOARD_LAYOUTS from openpilot.system.ui.widgets.keyboard import KEYBOARD_LAYOUTS
from openpilot.selfdrive.ui.onroad.hud_renderer import CRUISE_DISABLED_CHAR
all_chars = set() all_chars = set()
for layout in KEYBOARD_LAYOUTS.values(): for layout in KEYBOARD_LAYOUTS.values():
all_chars.update(key for row in layout for key in row) all_chars.update(key for row in layout for key in row)
all_chars = "".join(all_chars) all_chars = "".join(all_chars)
all_chars += CRUISE_DISABLED_CHAR all_chars += "–✓°"
codepoint_count = rl.ffi.new("int *", 1) codepoint_count = rl.ffi.new("int *", 1)
codepoints = rl.load_codepoints(all_chars, codepoint_count) codepoints = rl.load_codepoints(all_chars, codepoint_count)
@@ -219,12 +247,29 @@ class GuiApplication:
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BASE_COLOR_NORMAL, rl.color_to_int(rl.Color(50, 50, 50, 255))) rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BASE_COLOR_NORMAL, rl.color_to_int(rl.Color(50, 50, 50, 255)))
def _set_log_callback(self): def _set_log_callback(self):
ffi_libc = cffi.FFI()
ffi_libc.cdef("""
int vasprintf(char **strp, const char *fmt, void *ap);
void free(void *ptr);
""")
libc = ffi_libc.dlopen(None)
@rl.ffi.callback("void(int, char *, void *)") @rl.ffi.callback("void(int, char *, void *)")
def trace_log_callback(log_level, text, args): def trace_log_callback(log_level, text, args):
try: try:
text_str = rl.ffi.string(text).decode('utf-8') text_addr = int(rl.ffi.cast("uintptr_t", text))
except (TypeError, UnicodeDecodeError): args_addr = int(rl.ffi.cast("uintptr_t", args))
text_str = str(text) text_libc = ffi_libc.cast("char *", text_addr)
args_libc = ffi_libc.cast("void *", args_addr)
out = ffi_libc.new("char **")
if libc.vasprintf(out, text_libc, args_libc) >= 0 and out[0] != ffi_libc.NULL:
text_str = ffi_libc.string(out[0]).decode("utf-8", "replace")
libc.free(out[0])
else:
text_str = rl.ffi.string(text).decode("utf-8", "replace")
except Exception as e:
text_str = f"[Log decode error: {e}]"
if log_level == rl.TraceLogLevel.LOG_ERROR: if log_level == rl.TraceLogLevel.LOG_ERROR:
cloudlog.error(f"raylib: {text_str}") cloudlog.error(f"raylib: {text_str}")
+13 -4
View File
@@ -10,6 +10,7 @@ class ButtonStyle(IntEnum):
DANGER = 2 # For critical actions, like reboot or delete DANGER = 2 # For critical actions, like reboot or delete
TRANSPARENT = 3 # For buttons with transparent background and border TRANSPARENT = 3 # For buttons with transparent background and border
ACTION = 4 ACTION = 4
LIST_ACTION = 5 # For list items with action buttons
class TextAlignment(IntEnum): class TextAlignment(IntEnum):
@@ -20,11 +21,17 @@ class TextAlignment(IntEnum):
ICON_PADDING = 15 ICON_PADDING = 15
DEFAULT_BUTTON_FONT_SIZE = 60 DEFAULT_BUTTON_FONT_SIZE = 60
BUTTON_ENABLED_TEXT_COLOR = rl.Color(228, 228, 228, 255)
BUTTON_DISABLED_TEXT_COLOR = rl.Color(228, 228, 228, 51) BUTTON_DISABLED_TEXT_COLOR = rl.Color(228, 228, 228, 51)
ACTION_BUTTON_FONT_SIZE = 48 ACTION_BUTTON_FONT_SIZE = 48
ACTION_BUTTON_TEXT_COLOR = rl.Color(0, 0, 0, 255)
BUTTON_TEXT_COLOR = {
ButtonStyle.NORMAL: rl.Color(228, 228, 228, 255),
ButtonStyle.PRIMARY: rl.Color(228, 228, 228, 255),
ButtonStyle.DANGER: rl.Color(228, 228, 228, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.ACTION: rl.Color(0, 0, 0, 255),
ButtonStyle.LIST_ACTION: rl.Color(228, 228, 228, 255),
}
BUTTON_BACKGROUND_COLORS = { BUTTON_BACKGROUND_COLORS = {
ButtonStyle.NORMAL: rl.Color(51, 51, 51, 255), ButtonStyle.NORMAL: rl.Color(51, 51, 51, 255),
@@ -32,6 +39,7 @@ BUTTON_BACKGROUND_COLORS = {
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255), ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
ButtonStyle.TRANSPARENT: rl.BLACK, ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.ACTION: rl.Color(189, 189, 189, 255), ButtonStyle.ACTION: rl.Color(189, 189, 189, 255),
ButtonStyle.LIST_ACTION: rl.Color(57, 57, 57, 255),
} }
BUTTON_PRESSED_BACKGROUND_COLORS = { BUTTON_PRESSED_BACKGROUND_COLORS = {
@@ -40,6 +48,7 @@ BUTTON_PRESSED_BACKGROUND_COLORS = {
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255), ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
ButtonStyle.TRANSPARENT: rl.BLACK, ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.ACTION: rl.Color(130, 130, 130, 255), ButtonStyle.ACTION: rl.Color(130, 130, 130, 255),
ButtonStyle.LIST_ACTION: rl.Color(74, 74, 74, 74),
} }
_pressed_buttons: set[str] = set() # Track mouse press state globally _pressed_buttons: set[str] = set() # Track mouse press state globally
@@ -133,7 +142,7 @@ def gui_button(
# Draw the button text if any # Draw the button text if any
if text: if text:
text_color = ACTION_BUTTON_TEXT_COLOR if button_style == ButtonStyle.ACTION else BUTTON_ENABLED_TEXT_COLOR if is_enabled else BUTTON_DISABLED_TEXT_COLOR color = BUTTON_TEXT_COLOR[button_style] if is_enabled else BUTTON_DISABLED_TEXT_COLOR
rl.draw_text_ex(font, text, text_pos, font_size, 0, text_color) rl.draw_text_ex(font, text, text_pos, font_size, 0, color)
return result return result
+1 -1
View File
@@ -4,7 +4,6 @@ from dataclasses import dataclass
from typing import Any from typing import Any
from openpilot.common.swaglog import cloudlog from openpilot.common.swaglog import cloudlog
# EGL constants # EGL constants
EGL_LINUX_DMA_BUF_EXT = 0x3270 EGL_LINUX_DMA_BUF_EXT = 0x3270
EGL_WIDTH = 0x3057 EGL_WIDTH = 0x3057
@@ -23,6 +22,7 @@ GL_TEXTURE_EXTERNAL_OES = 0x8D65
# DRM Format for NV12 # DRM Format for NV12
DRM_FORMAT_NV12 = 842094158 DRM_FORMAT_NV12 = 842094158
@dataclass @dataclass
class EGLImage: class EGLImage:
"""Container for EGL image and associated resources""" """Container for EGL image and associated resources"""
+12 -11
View File
@@ -2,14 +2,15 @@ import pyray as rl
import time import time
from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.widget import Widget
PASSWORD_MASK_CHAR = "" PASSWORD_MASK_CHAR = ""
PASSWORD_MASK_DELAY = 1.5 # Seconds to show character before masking PASSWORD_MASK_DELAY = 1.5 # Seconds to show character before masking
class InputBox: class InputBox(Widget):
def __init__(self, max_text_size=255, password_mode=False): def __init__(self, max_text_size=255, password_mode=False):
super().__init__()
self._max_text_size = max_text_size self._max_text_size = max_text_size
self._input_text = "" self._input_text = ""
self._cursor_position = 0 self._cursor_position = 0
@@ -23,7 +24,7 @@ class InputBox:
self._text_offset = 0 self._text_offset = 0
self._visible_width = 0 self._visible_width = 0
self._last_char_time = 0 # Track when last character was added self._last_char_time = 0 # Track when last character was added
self._masked_length = 0 # How many characters are currently masked self._masked_length = 0 # How many characters are currently masked
@property @property
def text(self): def text(self):
@@ -76,11 +77,11 @@ class InputBox:
def add_char_at_cursor(self, char): def add_char_at_cursor(self, char):
"""Add a character at the current cursor position.""" """Add a character at the current cursor position."""
if len(self._input_text) < self._max_text_size: if len(self._input_text) < self._max_text_size:
self._input_text = self._input_text[: self._cursor_position] + char + self._input_text[self._cursor_position :] self._input_text = self._input_text[: self._cursor_position] + char + self._input_text[self._cursor_position:]
self.set_cursor_position(self._cursor_position + 1) self.set_cursor_position(self._cursor_position + 1)
if self._password_mode: if self._password_mode:
self._last_char_time = time.time() self._last_char_time = time.monotonic()
return True return True
return False return False
@@ -88,7 +89,7 @@ class InputBox:
def delete_char_before_cursor(self): def delete_char_before_cursor(self):
"""Delete the character before the cursor position (backspace).""" """Delete the character before the cursor position (backspace)."""
if self._cursor_position > 0: if self._cursor_position > 0:
self._input_text = self._input_text[: self._cursor_position - 1] + self._input_text[self._cursor_position :] self._input_text = self._input_text[: self._cursor_position - 1] + self._input_text[self._cursor_position:]
self.set_cursor_position(self._cursor_position - 1) self.set_cursor_position(self._cursor_position - 1)
return True return True
return False return False
@@ -96,12 +97,12 @@ class InputBox:
def delete_char_at_cursor(self): def delete_char_at_cursor(self):
"""Delete the character at the cursor position (delete).""" """Delete the character at the cursor position (delete)."""
if self._cursor_position < len(self._input_text): if self._cursor_position < len(self._input_text):
self._input_text = self._input_text[: self._cursor_position] + self._input_text[self._cursor_position + 1 :] self._input_text = self._input_text[: self._cursor_position] + self._input_text[self._cursor_position + 1:]
self.set_cursor_position(self._cursor_position) self.set_cursor_position(self._cursor_position)
return True return True
return False return False
def render(self, rect, color=rl.BLACK, border_color=rl.DARKGRAY, text_color=rl.WHITE, font_size=80): def _render(self, rect, color=rl.BLACK, border_color=rl.DARKGRAY, text_color=rl.WHITE, font_size=80):
# Store dimensions for text offset calculations # Store dimensions for text offset calculations
self._visible_width = rect.width self._visible_width = rect.width
self._font_size = font_size self._font_size = font_size
@@ -160,18 +161,18 @@ class InputBox:
# Show character at last edited position if within delay window # Show character at last edited position if within delay window
masked_text = PASSWORD_MASK_CHAR * len(self._input_text) masked_text = PASSWORD_MASK_CHAR * len(self._input_text)
recent_edit = time.time() - self._last_char_time < PASSWORD_MASK_DELAY recent_edit = time.monotonic() - self._last_char_time < PASSWORD_MASK_DELAY
if recent_edit and self._input_text: if recent_edit and self._input_text:
last_pos = max(0, self._cursor_position - 1) last_pos = max(0, self._cursor_position - 1)
if last_pos < len(self._input_text): if last_pos < len(self._input_text):
return masked_text[:last_pos] + self._input_text[last_pos] + masked_text[last_pos + 1 :] return masked_text[:last_pos] + self._input_text[last_pos] + masked_text[last_pos + 1:]
return masked_text return masked_text
def _handle_mouse_input(self, rect, font_size): def _handle_mouse_input(self, rect, font_size):
"""Handle mouse clicks to position cursor.""" """Handle mouse clicks to position cursor."""
mouse_pos = rl.get_mouse_position() mouse_pos = rl.get_mouse_position()
if rl.is_mouse_button_pressed(rl.MOUSE_LEFT_BUTTON) and rl.check_collision_point_rec(mouse_pos, rect): if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT) and rl.check_collision_point_rec(mouse_pos, rect):
# Calculate cursor position from click # Calculate cursor position from click
if len(self._input_text) > 0: if len(self._input_text) > 0:
font = gui_app.font() font = gui_app.font()
-1
View File
@@ -76,4 +76,3 @@ def gui_text_box(
if font_weight != FontWeight.NORMAL: if font_weight != FontWeight.NORMAL:
rl.gui_set_font(gui_app.font(FontWeight.NORMAL)) rl.gui_set_font(gui_app.font(FontWeight.NORMAL))
+269 -281
View File
@@ -1,27 +1,22 @@
import os import os
import pyray as rl import pyray as rl
from dataclasses import dataclass
from collections.abc import Callable from collections.abc import Callable
from abc import ABC, abstractmethod from abc import ABC
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.lib.button import gui_button from openpilot.system.ui.lib.button import gui_button, ButtonStyle
from openpilot.system.ui.lib.toggle import Toggle from openpilot.system.ui.lib.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT
from openpilot.system.ui.lib.toggle import WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT from openpilot.system.ui.lib.widget import Widget
ITEM_BASE_WIDTH = 600
LINE_PADDING = 40
LINE_COLOR = rl.GRAY
ITEM_PADDING = 20
ITEM_SPACING = 80
ITEM_BASE_HEIGHT = 170 ITEM_BASE_HEIGHT = 170
ITEM_PADDING = 20
ITEM_TEXT_FONT_SIZE = 50 ITEM_TEXT_FONT_SIZE = 50
ITEM_TEXT_COLOR = rl.WHITE ITEM_TEXT_COLOR = rl.WHITE
ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255) ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255)
ITEM_DESC_FONT_SIZE = 40 ITEM_DESC_FONT_SIZE = 40
ITEM_DESC_V_OFFSET = 130 ITEM_DESC_V_OFFSET = 140
RIGHT_ITEM_PADDING = 20 RIGHT_ITEM_PADDING = 20
ICON_SIZE = 80 ICON_SIZE = 80
BUTTON_WIDTH = 250 BUTTON_WIDTH = 250
@@ -30,38 +25,42 @@ BUTTON_BORDER_RADIUS = 50
BUTTON_FONT_SIZE = 35 BUTTON_FONT_SIZE = 35
BUTTON_FONT_WEIGHT = FontWeight.MEDIUM BUTTON_FONT_WEIGHT = FontWeight.MEDIUM
TEXT_PADDING = 20
def _resolve_value(value, default=""):
if callable(value):
return value()
return value if value is not None else default
# Abstract base class for right-side items # Abstract base class for right-side items
class RightItem(ABC): class ItemAction(Widget, ABC):
def __init__(self, width: int = 100): def __init__(self, width: int = BUTTON_HEIGHT, enabled: bool | Callable[[], bool] = True):
self.width = width super().__init__()
self.enabled = True self.set_rect(rl.Rectangle(0, 0, width, 0))
self._enabled_source = enabled
@abstractmethod @property
def draw(self, rect: rl.Rectangle) -> bool: def enabled(self):
pass return _resolve_value(self._enabled_source, False)
@abstractmethod
def get_width(self) -> int:
pass
class ToggleRightItem(RightItem): class ToggleAction(ItemAction):
def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH): def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True):
super().__init__(width) super().__init__(width, enabled)
self.toggle = Toggle(initial_state=initial_state) self.toggle = Toggle(initial_state=initial_state)
self.state = initial_state self.state = initial_state
self.enabled = True
def draw(self, rect: rl.Rectangle) -> bool: def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
if self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self.width, TOGGLE_HEIGHT)): super().set_touch_valid_callback(touch_callback)
self.state = not self.state self.toggle.set_touch_valid_callback(touch_callback)
return True
def _render(self, rect: rl.Rectangle) -> bool:
self.toggle.set_enabled(self.enabled)
self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self._rect.width, TOGGLE_HEIGHT))
return False return False
def get_width(self) -> int:
return self.width
def set_state(self, state: bool): def set_state(self, state: bool):
self.state = state self.state = state
self.toggle.set_state(state) self.toggle.set_state(state)
@@ -69,293 +68,269 @@ class ToggleRightItem(RightItem):
def get_state(self) -> bool: def get_state(self) -> bool:
return self.state return self.state
def set_enabled(self, enabled: bool):
self.enabled = enabled class ButtonAction(ItemAction):
def __init__(self, text: str | Callable[[], str], width: int = BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True):
super().__init__(width, enabled)
self._text_source = text
@property
def text(self):
return _resolve_value(self._text_source, "Error")
def _render(self, rect: rl.Rectangle) -> bool:
return gui_button(
rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT),
self.text,
border_radius=BUTTON_BORDER_RADIUS,
font_weight=BUTTON_FONT_WEIGHT,
font_size=BUTTON_FONT_SIZE,
button_style=ButtonStyle.LIST_ACTION,
is_enabled=self.enabled,
) == 1
class ButtonRightItem(RightItem): class TextAction(ItemAction):
def __init__(self, text: str, width: int = BUTTON_WIDTH): def __init__(self, text: str | Callable[[], str], color: rl.Color = ITEM_TEXT_COLOR, enabled: bool | Callable[[], bool] = True):
super().__init__(width) self._text_source = text
self.text = text
self.enabled = True
def draw(self, rect: rl.Rectangle) -> bool:
return (
gui_button(
rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT),
self.text,
border_radius=BUTTON_BORDER_RADIUS,
font_weight=BUTTON_FONT_WEIGHT,
font_size=BUTTON_FONT_SIZE,
is_enabled=self.enabled,
)
== 1
)
def get_width(self) -> int:
return self.width
def set_enabled(self, enabled: bool):
self.enabled = enabled
class TextRightItem(RightItem):
def __init__(self, text: str, color: rl.Color = ITEM_TEXT_COLOR, font_size: int = ITEM_TEXT_FONT_SIZE):
self.text = text
self.color = color self.color = color
self.font_size = font_size
font = gui_app.font(FontWeight.NORMAL) self._font = gui_app.font(FontWeight.NORMAL)
text_width = measure_text_cached(font, text, font_size).x initial_text = _resolve_value(text, "")
super().__init__(int(text_width + 20)) text_width = measure_text_cached(self._font, initial_text, ITEM_TEXT_FONT_SIZE).x
super().__init__(int(text_width + TEXT_PADDING), enabled)
def draw(self, rect: rl.Rectangle) -> bool: @property
font = gui_app.font(FontWeight.NORMAL) def text(self):
text_size = measure_text_cached(font, self.text, self.font_size) return _resolve_value(self._text_source, "Error")
def _render(self, rect: rl.Rectangle) -> bool:
current_text = self.text
text_size = measure_text_cached(self._font, current_text, ITEM_TEXT_FONT_SIZE)
# Center the text in the allocated rectangle
text_x = rect.x + (rect.width - text_size.x) / 2 text_x = rect.x + (rect.width - text_size.x) / 2
text_y = rect.y + (rect.height - text_size.y) / 2 text_y = rect.y + (rect.height - text_size.y) / 2
rl.draw_text_ex(self._font, current_text, rl.Vector2(text_x, text_y), ITEM_TEXT_FONT_SIZE, 0, self.color)
rl.draw_text_ex(font, self.text, rl.Vector2(text_x, text_y), self.font_size, 0, self.color)
return False return False
def get_width(self) -> int: def get_width(self) -> int:
return self.width text_width = measure_text_cached(self._font, self.text, ITEM_TEXT_FONT_SIZE).x
return int(text_width + TEXT_PADDING)
def set_text(self, text: str):
self.text = text
font = gui_app.font(FontWeight.NORMAL)
text_width = measure_text_cached(font, text, self.font_size).x
self.width = int(text_width + 20)
@dataclass class DualButtonAction(ItemAction):
class ListItem: def __init__(self, left_text: str, right_text: str, left_callback: Callable = None,
title: str right_callback: Callable = None, enabled: bool | Callable[[], bool] = True):
icon: str | None = None super().__init__(width=0, enabled=enabled) # Width 0 means use full width
description: str | None = None self.left_text, self.right_text = left_text, right_text
description_visible: bool = False self.left_callback, self.right_callback = left_callback, right_callback
rect: "rl.Rectangle | None" = None
callback: Callable | None = None
right_item: RightItem | None = None
# Cached properties for performance def _render(self, rect: rl.Rectangle) -> bool:
_wrapped_description: str | None = None button_spacing = 30
_description_height: float = 0 button_height = 120
button_width = (rect.width - button_spacing) / 2
button_y = rect.y + (rect.height - button_height) / 2
def get_right_item(self) -> RightItem | None: left_rect = rl.Rectangle(rect.x, button_y, button_width, button_height)
return self.right_item right_rect = rl.Rectangle(rect.x + button_width + button_spacing, button_y, button_width, button_height)
def get_item_height(self, font: rl.Font, max_width: int) -> float: left_clicked = gui_button(left_rect, self.left_text, button_style=ButtonStyle.LIST_ACTION) == 1
if self.description_visible and self.description: right_clicked = gui_button(right_rect, self.right_text, button_style=ButtonStyle.DANGER) == 1
if not self._wrapped_description:
wrapped_lines = wrap_text(font, self.description, ITEM_DESC_FONT_SIZE, max_width)
self._wrapped_description = "\n".join(wrapped_lines)
self._description_height = len(wrapped_lines) * 20 + 10 # Line height + padding
return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_SPACING
return ITEM_BASE_HEIGHT
def get_content_width(self, total_width: int) -> int: if left_clicked and self.left_callback:
if self.right_item: self.left_callback()
return total_width - self.right_item.get_width() - RIGHT_ITEM_PADDING return True
return total_width if right_clicked and self.right_callback:
self.right_callback()
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle: return True
if not self.right_item: return False
return rl.Rectangle(0, 0, 0, 0)
right_width = self.right_item.get_width()
right_x = item_rect.x + item_rect.width - right_width
right_y = item_rect.y
return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT)
class ListView: class MultipleButtonAction(ItemAction):
def __init__(self, items: list[ListItem]): def __init__(self, buttons: list[str], button_width: int, selected_index: int = 0, callback: Callable = None):
self._items: list[ListItem] = items super().__init__(width=len(buttons) * (button_width + 20), enabled=True)
self._last_dim: tuple[float, float] = (0, 0) self.buttons = buttons
self.scroll_panel = GuiScrollPanel() self.button_width = button_width
self.selected_button = selected_index
self.callback = callback
self._font = gui_app.font(FontWeight.MEDIUM)
self._font_normal = gui_app.font(FontWeight.NORMAL) def _render(self, rect: rl.Rectangle) -> bool:
spacing = 20
button_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2
clicked = -1
# Interaction state for i, text in enumerate(self.buttons):
self._hovered_item: int = -1 button_x = rect.x + i * (self.button_width + spacing)
self._last_mouse_pos = rl.Vector2(0, 0) button_rect = rl.Rectangle(button_x, button_y, self.button_width, BUTTON_HEIGHT)
self._total_height: float = 0 # Check button state
self._visible_range = (0, 0) mouse_pos = rl.get_mouse_position()
is_hovered = rl.check_collision_point_rec(mouse_pos, button_rect)
is_pressed = is_hovered and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._is_pressed
is_selected = i == self.selected_button
def invalid_height_cache(self): # Button colors
self._last_dim = (0, 0) if is_selected:
bg_color = rl.Color(51, 171, 76, 255) # Green
elif is_pressed:
bg_color = rl.Color(74, 74, 74, 255) # Dark gray
else:
bg_color = rl.Color(57, 57, 57, 255) # Gray
def render(self, rect: rl.Rectangle): # Draw button
if self._last_dim != (rect.width, rect.height): rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color)
self._update_item_rects(rect)
self._last_dim = (rect.width, rect.height)
# Update layout and handle scrolling # Draw text
content_rect = rl.Rectangle(rect.x, rect.y, rect.width, self._total_height) text_size = measure_text_cached(self._font, text, 40)
scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect) text_x = button_x + (self.button_width - text_size.x) / 2
text_y = button_y + (BUTTON_HEIGHT - text_size.y) / 2
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, rl.Color(228, 228, 228, 255))
# Handle mouse interaction # Handle click
if self.scroll_panel.is_click_valid(): if is_hovered and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._is_pressed:
self._handle_mouse_interaction(rect, scroll_offset) clicked = i
# Set scissor mode for clipping if clicked >= 0:
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) self.selected_button = clicked
if self.callback:
self.callback(clicked)
return True
return False
# Calculate visible range for performance
self._calculate_visible_range(rect, -scroll_offset.y)
# Render only visible items class ListItem(Widget):
for i in range(self._visible_range[0], min(self._visible_range[1], len(self._items))): def __init__(self, title: str = "", icon: str | None = None, description: str | Callable[[], str] | None = None,
item = self._items[i] description_visible: bool = False, callback: Callable | None = None,
if item.rect: action_item: ItemAction | None = None):
adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height) super().__init__()
self._render_item(item, adjusted_rect, i) self.title = title
self.icon = icon
self.description = description
self.description_visible = description_visible
self.callback = callback
self.action_item = action_item
if i != len(self._items) - 1: self.set_rect(rl.Rectangle(0, 0, ITEM_BASE_WIDTH, ITEM_BASE_HEIGHT))
rl.draw_line_ex( self._font = gui_app.font(FontWeight.NORMAL)
rl.Vector2(adjusted_rect.x + LINE_PADDING, adjusted_rect.y + adjusted_rect.height - 1),
rl.Vector2(
adjusted_rect.x + adjusted_rect.width - LINE_PADDING * 2, adjusted_rect.y + adjusted_rect.height - 1
),
1.0,
LINE_COLOR,
)
rl.end_scissor_mode()
def _render_item(self, item: ListItem, rect: rl.Rectangle, index: int): # Cached properties for performance
content_x = rect.x + ITEM_PADDING self._prev_max_width: int = 0
self._wrapped_description: str | None = None
self._prev_description: str | None = None
self._description_height: float = 0
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
if self.action_item:
self.action_item.set_touch_valid_callback(touch_callback)
def set_parent_rect(self, parent_rect: rl.Rectangle):
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _handle_mouse_release(self, mouse_pos: rl.Vector2):
if not self.is_visible:
return
# Check not in action rect
if self.action_item:
action_rect = self.get_right_item_rect(self._rect)
if rl.check_collision_point_rec(mouse_pos, action_rect):
# Click was on right item, don't toggle description
return
if self.description:
self.description_visible = not self.description_visible
content_width = self.get_content_width(int(self._rect.width - ITEM_PADDING * 2))
self._rect.height = self.get_item_height(self._font, content_width)
def _render(self, _):
if not self.is_visible:
return
# Don't draw items that are not in parent's viewport
if ((self._rect.y + self.rect.height) <= self._parent_rect.y or
self._rect.y >= (self._parent_rect.y + self._parent_rect.height)):
return
content_x = self._rect.x + ITEM_PADDING
text_x = content_x text_x = content_x
# Calculate available width for main content # Only draw title and icon for items that have them
content_width = item.get_content_width(int(rect.width - ITEM_PADDING * 2)) if self.title:
# Draw icon if present
if self.icon:
icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE)
rl.draw_texture(icon_texture, int(content_x), int(self._rect.y + (ITEM_BASE_HEIGHT - icon_texture.width) // 2), rl.WHITE)
text_x += ICON_SIZE + ITEM_PADDING
# Draw icon if present # Draw main text
if item.icon: text_size = measure_text_cached(self._font, self.title, ITEM_TEXT_FONT_SIZE)
icon_texture = gui_app.texture(os.path.join("icons", item.icon), ICON_SIZE, ICON_SIZE) item_y = self._rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2
rl.draw_texture( rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR)
icon_texture, int(content_x), int(rect.y + (ITEM_BASE_HEIGHT - icon_texture.width) // 2), rl.WHITE
)
text_x += ICON_SIZE + ITEM_PADDING
# Draw main text
text_size = measure_text_cached(self._font_normal, item.title, ITEM_TEXT_FONT_SIZE)
item_y = rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2
rl.draw_text_ex(self._font_normal, item.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR)
# Draw description if visible (adjust width for right item)
if item.description_visible and item._wrapped_description:
desc_y = rect.y + ITEM_DESC_V_OFFSET
desc_max_width = int(content_width - (text_x - content_x))
# Re-wrap description if needed due to right item
if (item.right_item and item.description) and not item._wrapped_description:
wrapped_lines = wrap_text(self._font_normal, item.description, ITEM_DESC_FONT_SIZE, desc_max_width)
item._wrapped_description = "\n".join(wrapped_lines)
# Draw description if visible
current_description = self.get_description()
if self.description_visible and current_description and self._wrapped_description:
rl.draw_text_ex( rl.draw_text_ex(
self._font_normal, self._font,
item._wrapped_description, self._wrapped_description,
rl.Vector2(text_x, desc_y), rl.Vector2(text_x, self._rect.y + ITEM_DESC_V_OFFSET),
ITEM_DESC_FONT_SIZE, ITEM_DESC_FONT_SIZE,
0, 0,
ITEM_DESC_TEXT_COLOR, ITEM_DESC_TEXT_COLOR,
) )
# Draw right item if present # Draw right item if present
if item.right_item: if self.action_item:
right_rect = item.get_right_item_rect(rect) right_rect = self.get_right_item_rect(self._rect)
# Adjust for scroll offset right_rect.y = self._rect.y
right_rect.y = right_rect.y if self.action_item.render(right_rect) and self.action_item.enabled:
if item.right_item.draw(right_rect):
# Right item was clicked/activated # Right item was clicked/activated
if item.callback: if self.callback:
item.callback() self.callback()
def _update_item_rects(self, container_rect: rl.Rectangle) -> None: def get_description(self):
current_y: float = 0.0 return _resolve_value(self.description, None)
self._total_height = 0
for item in self._items: def get_item_height(self, font: rl.Font, max_width: int) -> float:
content_width = item.get_content_width(int(container_rect.width - ITEM_PADDING * 2)) if not self.is_visible:
item_height = item.get_item_height(self._font_normal, content_width) return 0
item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, item_height)
current_y += item_height
self._total_height += item_height
def _calculate_visible_range(self, rect: rl.Rectangle, scroll_offset: float): current_description = self.get_description()
if not self._items: if self.description_visible and current_description:
self._visible_range = (0, 0) if (
return not self._wrapped_description
or current_description != self._prev_description
or max_width != self._prev_max_width
):
self._prev_max_width = max_width
self._prev_description = current_description
visible_top = scroll_offset wrapped_lines = wrap_text(font, current_description, ITEM_DESC_FONT_SIZE, max_width)
visible_bottom = scroll_offset + rect.height self._wrapped_description = "\n".join(wrapped_lines)
self._description_height = len(wrapped_lines) * ITEM_DESC_FONT_SIZE + 10
return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING
return ITEM_BASE_HEIGHT
start_idx = 0 def get_content_width(self, total_width: int) -> int:
end_idx = len(self._items) if self.action_item and self.action_item.rect.width > 0:
return total_width - int(self.action_item.rect.width) - RIGHT_ITEM_PADDING
return total_width
# Find first visible item def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
for i, item in enumerate(self._items): if not self.action_item:
if item.rect and item.rect.y + item.rect.height >= visible_top: return rl.Rectangle(0, 0, 0, 0)
start_idx = max(0, i - 1)
break
# Find last visible item right_width = self.action_item.rect.width
for i in range(start_idx, len(self._items)): if right_width == 0: # Full width action (like DualButtonAction)
item = self._items[i] return rl.Rectangle(item_rect.x + ITEM_PADDING, item_rect.y,
if item.rect and item.rect.y > visible_bottom: item_rect.width - (ITEM_PADDING * 2), ITEM_BASE_HEIGHT)
end_idx = min(len(self._items), i + 2)
break
self._visible_range = (start_idx, end_idx) right_x = item_rect.x + item_rect.width - right_width
right_y = item_rect.y
def _handle_mouse_interaction(self, rect: rl.Rectangle, scroll_offset: rl.Vector2): return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT)
mouse_pos = rl.get_mouse_position()
self._hovered_item = -1
if not rl.check_collision_point_rec(mouse_pos, rect):
return
content_mouse_y = mouse_pos.y - rect.y - scroll_offset.y
for i, item in enumerate(self._items):
if item.rect:
# Check if mouse is within this item's bounds in content space
if (
mouse_pos.x >= rect.x
and mouse_pos.x <= rect.x + rect.width
and content_mouse_y >= item.rect.y
and content_mouse_y <= item.rect.y + item.rect.height
):
item_screen_y = item.rect.y + scroll_offset.y
if item_screen_y < rect.height and item_screen_y + item.rect.height > 0:
self._hovered_item = i
break
# Handle click on main item (not right item)
if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._hovered_item >= 0:
item = self._items[self._hovered_item]
# Check if click was on right item area
if item.right_item and item.rect:
adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height)
right_rect = item.get_right_item_rect(adjusted_rect)
if rl.check_collision_point_rec(mouse_pos, right_rect):
# Click was handled by right item, don't process main item click
return
# Toggle description visibility if item has description
if item.description:
item.description_visible = not item.description_visible
# Force layout update when description visibility changes
self._last_dim = (0, 0)
# Call item callback
if item.callback:
item.callback()
# Factory functions # Factory functions
@@ -363,18 +338,31 @@ def simple_item(title: str, callback: Callable | None = None) -> ListItem:
return ListItem(title=title, callback=callback) return ListItem(title=title, callback=callback)
def toggle_item( def toggle_item(title: str, description: str | Callable[[], str] | None = None, initial_state: bool = False,
title: str, description: str = None, initial_state: bool = False, callback: Callable | None = None, icon: str = "" callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True) -> ListItem:
) -> ListItem: action = ToggleAction(initial_state=initial_state, enabled=enabled)
toggle = ToggleRightItem(initial_state=initial_state) return ListItem(title=title, description=description, action_item=action, icon=icon, callback=callback)
return ListItem(title=title, description=description, right_item=toggle, icon=icon, callback=callback)
def button_item(title: str, button_text: str, description: str = None, callback: Callable | None = None) -> ListItem: def button_item(title: str, button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
button = ButtonRightItem(text=button_text) callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
return ListItem(title=title, description=description, right_item=button, callback=callback) action = ButtonAction(text=button_text, enabled=enabled)
return ListItem(title=title, description=description, action_item=action, callback=callback)
def text_item(title: str, value: str, description: str = None, callback: Callable | None = None) -> ListItem: def text_item(title: str, value: str | Callable[[], str], description: str | Callable[[], str] | None = None,
text_item = TextRightItem(text=value, color=rl.Color(170, 170, 170, 255)) callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
return ListItem(title=title, description=description, right_item=text_item, callback=callback) action = TextAction(text=value, color=rl.Color(170, 170, 170, 255), enabled=enabled)
return ListItem(title=title, description=description, action_item=action, callback=callback)
def dual_button_item(left_text: str, right_text: str, left_callback: Callable = None, right_callback: Callable = None,
description: str | Callable[[], str] | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
action = DualButtonAction(left_text, right_text, left_callback, right_callback, enabled)
return ListItem(title="", description=description, action_item=action)
def multiple_button_item(title: str, description: str, buttons: list[str], selected_index: int,
button_width: int = BUTTON_WIDTH, callback: Callable = None, icon: str = ""):
action = MultipleButtonAction(buttons, button_width, selected_index, callback=callback)
return ListItem(title=title, description=description, icon=icon, action_item=action)
+9 -20
View File
@@ -1,11 +1,12 @@
import pyray as rl import pyray as rl
from collections import deque
from enum import IntEnum from enum import IntEnum
# Scroll constants for smooth scrolling behavior # Scroll constants for smooth scrolling behavior
MOUSE_WHEEL_SCROLL_SPEED = 30 MOUSE_WHEEL_SCROLL_SPEED = 30
INERTIA_FRICTION = 0.92 # The rate at which the inertia slows down INERTIA_FRICTION = 0.92 # The rate at which the inertia slows down
MIN_VELOCITY = 0.5 # Minimum velocity before stopping the inertia MIN_VELOCITY = 0.5 # Minimum velocity before stopping the inertia
DRAG_THRESHOLD = 5 # Pixels of movement to consider it a drag, not a click DRAG_THRESHOLD = 12 # Pixels of movement to consider it a drag, not a click
BOUNCE_FACTOR = 0.2 # Elastic bounce when scrolling past boundaries BOUNCE_FACTOR = 0.2 # Elastic bounce when scrolling past boundaries
BOUNCE_RETURN_SPEED = 0.15 # How quickly it returns from the bounce BOUNCE_RETURN_SPEED = 0.15 # How quickly it returns from the bounce
MAX_BOUNCE_DISTANCE = 150 # Maximum distance for bounce effect MAX_BOUNCE_DISTANCE = 150 # Maximum distance for bounce effect
@@ -31,8 +32,7 @@ class GuiScrollPanel:
self._velocity_y = 0.0 # Velocity for inertia self._velocity_y = 0.0 # Velocity for inertia
self._is_dragging: bool = False self._is_dragging: bool = False
self._bounce_offset: float = 0.0 self._bounce_offset: float = 0.0
self._last_frame_time = rl.get_time() self._velocity_history: deque[float] = deque(maxlen=VELOCITY_HISTORY_SIZE)
self._velocity_history: list[float] = []
self._last_drag_time: float = 0.0 self._last_drag_time: float = 0.0
self._content_rect: rl.Rectangle | None = None self._content_rect: rl.Rectangle | None = None
self._bounds_rect: rl.Rectangle | None = None self._bounds_rect: rl.Rectangle | None = None
@@ -44,11 +44,6 @@ class GuiScrollPanel:
# Calculate time delta # Calculate time delta
current_time = rl.get_time() current_time = rl.get_time()
delta_time = current_time - self._last_frame_time
self._last_frame_time = current_time
# Prevent large jumps
delta_time = min(delta_time, 0.05)
mouse_pos = rl.get_mouse_position() mouse_pos = rl.get_mouse_position()
max_scroll_y = max(content.height - bounds.height, 0) max_scroll_y = max(content.height - bounds.height, 0)
@@ -63,13 +58,15 @@ class GuiScrollPanel:
if mouse_pos.x >= scrollbar_x: if mouse_pos.x >= scrollbar_x:
self._scroll_state = ScrollState.DRAGGING_SCROLLBAR self._scroll_state = ScrollState.DRAGGING_SCROLLBAR
# TODO: hacky
# when clicking while moving, go straight into dragging
self._is_dragging = abs(self._velocity_y) > MIN_VELOCITY
self._last_mouse_y = mouse_pos.y self._last_mouse_y = mouse_pos.y
self._start_mouse_y = mouse_pos.y self._start_mouse_y = mouse_pos.y
self._last_drag_time = current_time self._last_drag_time = current_time
self._velocity_history = [] self._velocity_history.clear()
self._velocity_y = 0.0 self._velocity_y = 0.0
self._bounce_offset = 0.0 self._bounce_offset = 0.0
self._is_dragging = False
# Handle active dragging # Handle active dragging
if self._scroll_state == ScrollState.DRAGGING_CONTENT or self._scroll_state == ScrollState.DRAGGING_SCROLLBAR: if self._scroll_state == ScrollState.DRAGGING_CONTENT or self._scroll_state == ScrollState.DRAGGING_SCROLLBAR:
@@ -82,9 +79,6 @@ class GuiScrollPanel:
drag_velocity = delta_y / time_since_last_drag / 60.0 drag_velocity = delta_y / time_since_last_drag / 60.0
self._velocity_history.append(drag_velocity) self._velocity_history.append(drag_velocity)
if len(self._velocity_history) > VELOCITY_HISTORY_SIZE:
self._velocity_history.pop(0)
self._last_drag_time = current_time self._last_drag_time = current_time
# Detect actual dragging # Detect actual dragging
@@ -175,13 +169,8 @@ class GuiScrollPanel:
return self._offset return self._offset
def is_click_valid(self) -> bool: def is_touch_valid(self):
# Check if this is a click rather than a drag return not self._is_dragging
return (
self._scroll_state == ScrollState.IDLE
and not self._is_dragging
and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT)
)
def get_normalized_scroll_position(self) -> float: def get_normalized_scroll_position(self) -> float:
"""Returns the current scroll position as a value from 0.0 to 1.0""" """Returns the current scroll position as a value from 0.0 to 1.0"""
+74
View File
@@ -0,0 +1,74 @@
import pyray as rl
from openpilot.system.ui.lib.widget import Widget
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
ITEM_SPACING = 40
LINE_COLOR = rl.GRAY
LINE_PADDING = 40
class LineSeparator(Widget):
def __init__(self, height: int = 1):
super().__init__()
self._rect = rl.Rectangle(0, 0, 0, height)
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _render(self, _):
rl.draw_line(int(self._rect.x) + LINE_PADDING, int(self._rect.y),
int(self._rect.x + self._rect.width) - LINE_PADDING * 2, int(self._rect.y),
LINE_COLOR)
class Scroller(Widget):
def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True):
super().__init__()
self._items: list[Widget] = []
self._spacing = spacing
self._line_separator = line_separator
self._pad_end = pad_end
self.scroll_panel = GuiScrollPanel()
for item in items:
self.add_widget(item)
def add_widget(self, item: Widget) -> None:
if self._line_separator and len(self._items) > 0:
self._items.append(LineSeparator())
self._items.append(item)
item.set_touch_valid_callback(self.scroll_panel.is_touch_valid)
def _render(self, _):
# TODO: don't draw items that are not in the viewport
visible_items = [item for item in self._items if item.is_visible]
content_height = sum(item.rect.height for item in visible_items) + self._spacing * (len(visible_items))
if not self._pad_end:
content_height -= self._spacing
scroll = self.scroll_panel.handle_scroll(self._rect, rl.Rectangle(0, 0, self._rect.width, content_height))
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
int(self._rect.width), int(self._rect.height))
cur_height = 0
for idx, item in enumerate(visible_items):
if not item.is_visible:
continue
# Nicely lay out items vertically
x = self._rect.x
y = self._rect.y + cur_height + self._spacing * (idx != 0)
cur_height += item.rect.height + self._spacing * (idx != 0)
# Consider scroll
x += scroll.x
y += scroll.y
# Update item state
item.set_position(x, y)
item.set_parent_rect(self._rect)
item.render()
rl.end_scissor_mode()
+12 -6
View File
@@ -1,13 +1,20 @@
import platform
import pyray as rl import pyray as rl
import numpy as np import numpy as np
from typing import Any from typing import Any
MAX_GRADIENT_COLORS = 15 MAX_GRADIENT_COLORS = 15
FRAGMENT_SHADER = """ VERSION = """
#version 300 es #version 300 es
precision mediump float; precision highp float;
"""
if platform.system() == "Darwin":
VERSION = """
#version 330 core
"""
FRAGMENT_SHADER = VERSION + """
in vec2 fragTexCoord; in vec2 fragTexCoord;
out vec4 finalColor; out vec4 finalColor;
@@ -105,14 +112,13 @@ void main() {
vec4 color = useGradient == 1 ? getGradientColor(pixel) : fillColor; vec4 color = useGradient == 1 ? getGradientColor(pixel) : fillColor;
finalColor = vec4(color.rgb, color.a * alpha); finalColor = vec4(color.rgb, color.a * alpha);
} else { } else {
finalColor = vec4(0.0); discard;
} }
} }
""" """
# Default vertex shader # Default vertex shader
VERTEX_SHADER = """ VERTEX_SHADER = VERSION + """
#version 300 es
in vec3 vertexPosition; in vec3 vertexPosition;
in vec2 vertexTexCoord; in vec2 vertexTexCoord;
out vec2 fragTexCoord; out vec2 fragTexCoord;
@@ -124,7 +130,6 @@ void main() {
} }
""" """
UNIFORM_INT = rl.ShaderUniformDataType.SHADER_UNIFORM_INT UNIFORM_INT = rl.ShaderUniformDataType.SHADER_UNIFORM_INT
UNIFORM_FLOAT = rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT UNIFORM_FLOAT = rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT
UNIFORM_VEC2 = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC2 UNIFORM_VEC2 = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC2
@@ -244,6 +249,7 @@ def _configure_shader_color(state, color, gradient, clipped_rect, original_rect)
state.fill_color_ptr[0:4] = [color.r / 255.0, color.g / 255.0, color.b / 255.0, color.a / 255.0] state.fill_color_ptr[0:4] = [color.r / 255.0, color.g / 255.0, color.b / 255.0, color.a / 255.0]
rl.set_shader_value(state.shader, state.locations['fillColor'], state.fill_color_ptr, UNIFORM_VEC4) rl.set_shader_value(state.shader, state.locations['fillColor'], state.fill_color_ptr, UNIFORM_VEC4)
def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray, color=None, gradient=None): def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray, color=None, gradient=None):
""" """
Draw a complex polygon using shader-based even-odd fill rule Draw a complex polygon using shader-based even-odd fill rule
+12 -15
View File
@@ -1,4 +1,5 @@
import pyray as rl import pyray as rl
from openpilot.system.ui.lib.widget import Widget
ON_COLOR = rl.Color(51, 171, 76, 255) ON_COLOR = rl.Color(51, 171, 76, 255)
OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255) OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255)
@@ -11,24 +12,23 @@ BG_HEIGHT = 60
ANIMATION_SPEED = 8.0 ANIMATION_SPEED = 8.0
class Toggle: class Toggle(Widget):
def __init__(self, initial_state=False): def __init__(self, initial_state=False):
super().__init__()
self._state = initial_state self._state = initial_state
self._enabled = True self._enabled = True
self._rect = rl.Rectangle(0, 0, WIDTH, HEIGHT)
self._progress = 1.0 if initial_state else 0.0 self._progress = 1.0 if initial_state else 0.0
self._target = self._progress self._target = self._progress
def handle_input(self): def set_rect(self, rect: rl.Rectangle):
if not self._enabled: self._rect = rl.Rectangle(rect.x, rect.y, WIDTH, HEIGHT)
return 0
if rl.is_mouse_button_pressed(rl.MOUSE_LEFT_BUTTON): def _handle_mouse_release(self, mouse_pos: rl.Vector2):
if rl.check_collision_point_rec(rl.get_mouse_position(), self._rect): if not self._enabled:
self._state = not self._state return
self._target = 1.0 if self._state else 0.0
return 1 self._state = not self._state
return 0 self._target = 1.0 if self._state else 0.0
def get_state(self): def get_state(self):
return self._state return self._state
@@ -49,8 +49,7 @@ class Toggle:
self._progress += delta if self._progress < self._target else -delta self._progress += delta if self._progress < self._target else -delta
self._progress = max(0.0, min(1.0, self._progress)) self._progress = max(0.0, min(1.0, self._progress))
def render(self, rect: rl.Rectangle): def _render(self, rect: rl.Rectangle):
self._rect.x, self._rect.y = rect.x, rect.y
self.update() self.update()
if self._enabled: if self._enabled:
@@ -69,7 +68,5 @@ class Toggle:
knob_y = self._rect.y + HEIGHT / 2 knob_y = self._rect.y + HEIGHT / 2
rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, knob_color) rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, knob_color)
return self.handle_input()
def _blend_color(self, c1, c2, t): def _blend_color(self, c1, c2, t):
return rl.Color(int(c1.r + (c2.r - c1.r) * t), int(c1.g + (c2.g - c1.g) * t), int(c1.b + (c2.b - c1.b) * t), 255) return rl.Color(int(c1.r + (c2.r - c1.r) * t), int(c1.g + (c2.g - c1.g) * t), int(c1.b + (c2.b - c1.b) * t), 255)
+96
View File
@@ -0,0 +1,96 @@
import abc
import pyray as rl
from enum import IntEnum
from collections.abc import Callable
class DialogResult(IntEnum):
CANCEL = 0
CONFIRM = 1
NO_ACTION = -1
class Widget(abc.ABC):
def __init__(self):
self._rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._parent_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._is_pressed = False
self._is_visible: bool | Callable[[], bool] = True
self._touch_valid_callback: Callable[[], bool] | None = None
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
"""Set a callback to determine if the widget can be clicked."""
self._touch_valid_callback = touch_callback
def _touch_valid(self) -> bool:
"""Check if the widget can be touched."""
return self._touch_valid_callback() if self._touch_valid_callback else True
@property
def is_visible(self) -> bool:
return self._is_visible() if callable(self._is_visible) else self._is_visible
@property
def rect(self) -> rl.Rectangle:
return self._rect
def set_visible(self, visible: bool | Callable[[], bool]) -> None:
self._is_visible = visible
def set_rect(self, rect: rl.Rectangle) -> None:
changed = (self._rect.x != rect.x or self._rect.y != rect.y or
self._rect.width != rect.width or self._rect.height != rect.height)
self._rect = rect
if changed:
self._update_layout_rects()
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
"""Can be used like size hint in QT"""
self._parent_rect = parent_rect
def set_position(self, x: float, y: float) -> None:
changed = (self._rect.x != x or self._rect.y != y)
self._rect.x, self._rect.y = x, y
if changed:
self._update_layout_rects()
def render(self, rect: rl.Rectangle = None) -> bool | int | None:
if rect is not None:
self.set_rect(rect)
self._update_state()
if not self.is_visible:
return None
ret = self._render(self._rect)
# Keep track of whether mouse down started within the widget's rectangle
mouse_pos = rl.get_mouse_position()
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._touch_valid():
if rl.check_collision_point_rec(mouse_pos, self._rect):
self._is_pressed = True
elif not self._touch_valid():
self._is_pressed = False
elif rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT):
if self._is_pressed and rl.check_collision_point_rec(mouse_pos, self._rect):
self._handle_mouse_release(mouse_pos)
self._is_pressed = False
return ret
@abc.abstractmethod
def _render(self, rect: rl.Rectangle) -> bool | int | None:
"""Render the widget within the given rectangle."""
def _update_state(self):
"""Optionally update the widget's non-layout state. This is called before rendering."""
def _update_layout_rects(self) -> None:
"""Optionally update any layout rects on Widget rect change."""
def _handle_mouse_release(self, mouse_pos: rl.Vector2) -> bool:
"""Optionally handle mouse release events."""
return False
+5 -1
View File
@@ -13,6 +13,7 @@ from dbus_next.aio import MessageBus
from dbus_next import BusType, Variant, Message from dbus_next import BusType, Variant, Message
from dbus_next.errors import DBusError from dbus_next.errors import DBusError
from dbus_next.constants import MessageType from dbus_next.constants import MessageType
try: try:
from openpilot.common.params import Params from openpilot.common.params import Params
except ImportError: except ImportError:
@@ -38,6 +39,7 @@ NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT = 8
TETHERING_IP_ADDRESS = "192.168.43.1" TETHERING_IP_ADDRESS = "192.168.43.1"
DEFAULT_TETHERING_PASSWORD = "12345678" DEFAULT_TETHERING_PASSWORD = "12345678"
# NetworkManager device states # NetworkManager device states
class NMDeviceState(IntEnum): class NMDeviceState(IntEnum):
DISCONNECTED = 30 DISCONNECTED = 30
@@ -46,6 +48,7 @@ class NMDeviceState(IntEnum):
IP_CONFIG = 70 IP_CONFIG = 70
ACTIVATED = 100 ACTIVATED = 100
class SecurityType(IntEnum): class SecurityType(IntEnum):
OPEN = 0 OPEN = 0
WPA = 1 WPA = 1
@@ -53,6 +56,7 @@ class SecurityType(IntEnum):
WPA3 = 3 WPA3 = 3
UNSUPPORTED = 4 UNSUPPORTED = 4
@dataclass @dataclass
class NetworkInfo: class NetworkInfo:
ssid: str ssid: str
@@ -227,7 +231,7 @@ class WifiManager:
except Exception as e: except Exception as e:
self._current_connection_ssid = None self._current_connection_ssid = None
cloudlog.error(f"Error connecting to network: {e}") cloudlog.error(f"Error connecting to network: {e}")
# Notify UI of failure # Notify UI of failure
if self.callbacks.connection_failed: if self.callbacks.connection_failed:
self.callbacks.connection_failed(ssid, str(e)) self.callbacks.connection_failed(ssid, str(e))
-58
View File
@@ -1,58 +0,0 @@
import threading
import time
import os
from typing import Generic, Protocol, TypeVar
from openpilot.common.swaglog import cloudlog
from openpilot.system.ui.lib.application import gui_app
class RendererProtocol(Protocol):
def render(self): ...
R = TypeVar("R", bound=RendererProtocol)
class BaseWindow(Generic[R]):
def __init__(self, title: str):
self._title = title
self._renderer: R | None = None
self._stop_event = threading.Event()
self._thread = threading.Thread(target=self._run)
self._thread.start()
# wait for the renderer to be initialized
while self._renderer is None and self._thread.is_alive():
time.sleep(0.01)
def _create_renderer(self) -> R:
raise NotImplementedError()
def _run(self):
if os.getenv("CI") is not None:
return
gui_app.init_window(self._title)
self._renderer = self._create_renderer()
try:
for _ in gui_app.render():
if self._stop_event.is_set():
break
self._renderer.render()
finally:
gui_app.close()
def __enter__(self):
return self
def close(self):
if self._thread.is_alive():
self._stop_event.set()
self._thread.join(timeout=2.0)
if self._thread.is_alive():
cloudlog.warning(f"Failed to join {self._title} thread")
def __del__(self):
self.close()
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()

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