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

View File

@@ -18,6 +18,19 @@
venv/
.venv/
**/.idea
**/.hypothesis
**/.mypy_cache
**/.venv
**/.venv/
**/.ci_cache
**/*.rlog
**/Dockerfile*
**/dockerfile*
**/build_output
notebooks
phone

View File

@@ -54,8 +54,9 @@ jobs:
git add .
- name: update car docs
run: |
export PYTHONPATH="$PWD"
scons -j$(nproc) --minimal opendbc_repo
PYTHONPATH=. python selfdrive/car/docs.py
python selfdrive/car/docs.py
git add docs/CARS.md
- name: Create Pull Request
uses: peter-evans/create-pull-request@9153d834b60caba6d51c9b9510b087acf9f33f83

View File

@@ -3,7 +3,6 @@ FROM ghcr.io/commaai/openpilot-base:latest
ENV PYTHONUNBUFFERED=1
ENV OPENPILOT_PATH=/home/batman/openpilot
ENV PYTHONPATH=${OPENPILOT_PATH}:${PYTHONPATH}
RUN mkdir -p ${OPENPILOT_PATH}
WORKDIR ${OPENPILOT_PATH}

66
Dockerfile.sunnypilot Normal file
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/" \
]

View File

@@ -2281,6 +2281,7 @@ struct LiveTorqueParametersData {
points @10 :List(List(Float32));
version @11 :Int32;
useParams @12 :Bool;
calPerc @13 :Int8;
}
struct LiveDelayData {
@@ -2291,6 +2292,7 @@ struct LiveDelayData {
lateralDelayEstimate @3 :Float32;
lateralDelayEstimateStd @5 :Float32;
points @4 :List(Float32);
calPerc @6 :Int8;
enum Status {
unestimated @0;

View File

@@ -1 +1 @@
#define DEFAULT_MODEL "Filet o Fish (Default)"
#define DEFAULT_MODEL "Vegetarian Filet o Fish (Default)"

52
common/spinner.py Executable file
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
common/text_window.py Executable file
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")

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 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|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|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>||
@@ -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 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|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 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|
@@ -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 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|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|
|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>|||
@@ -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 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 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 (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 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>|||
@@ -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 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 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 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>||
@@ -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>|||
|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 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 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|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|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>|||
@@ -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|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|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 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|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|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>|||

View File

@@ -7,7 +7,6 @@ Development is coordinated through [Discord](https://discord.comma.ai) and GitHu
### Getting Started
* Setup your [development environment](../tools/)
* Read about the [development workflow](WORKFLOW.md)
* Join our [Discord](https://discord.comma.ai)
* Docs are at https://docs.comma.ai and https://blog.comma.ai

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
```

View File

@@ -7,7 +7,7 @@ export OPENBLAS_NUM_THREADS=1
export VECLIB_MAXIMUM_THREADS=1
if [ -z "$AGNOS_VERSION" ]; then
export AGNOS_VERSION="12.3"
export AGNOS_VERSION="12.4"
fi
export STAGING_ROOT="/data/safe_staging"

2
panda

Submodule panda updated: 5ac4fa5bb0...c33cfa0803

View File

@@ -59,7 +59,7 @@ dependencies = [
"future-fstrings",
# joystickd
"pygame",
"inputs",
# these should be removed
"psutil",
@@ -68,6 +68,9 @@ dependencies = [
# logreader
"zstandard",
# ui
"qrcode",
]
[project.optional-dependencies]
@@ -85,7 +88,8 @@ testing = [
"pytest-cov",
"pytest-cpp",
"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-randomly",
"pytest-asyncio",
@@ -102,10 +106,11 @@ dev = [
"azure-storage-blob",
"dbus-next",
"dictdiffer",
"lru-dict",
"matplotlib",
"opencv-python-headless",
"parameterized >=0.8, <0.9",
"pyautogui",
"pygame",
"pyopencl; platform_machine != 'aarch64'", # broken on arm64
"pytools < 2024.1.11; platform_machine != 'aarch64'", # pyopencl use a broken version
"pywinctl",

View File

@@ -1,36 +1,31 @@
# openpilot releases
```
## release checklist
**Go to `devel-staging`**
- [ ] update RELEASES.md
- [ ] update `devel-staging`: `git reset --hard origin/master-ci`
- [ ] open a pull request from `devel-staging` to `devel`
- [ ] post on Discord
**Go to `devel`**
- [ ] update RELEASES.md
- [ ] close out milestone
- [ ] post on Discord dev channel
- [ ] bump version on master: `common/version.h` and `RELEASES.md`
- [ ] merge the pull request
tests:
- [ ] update from previous release -> new release
- [ ] update from new release -> previous release
- [ ] fresh install with `openpilot-test.comma.ai`
- [ ] drive on fresh install
- [ ] comma body test
- [ ] no submodules or LFS
- [ ] check sentry, MTBF, etc.
- [ ] before merging the pull request
- [ ] update from previous release -> new release
- [ ] update from new release -> previous release
- [ ] fresh install with `openpilot-test.comma.ai`
- [ ] drive on fresh install
- [ ] no submodules or LFS
- [ ] check sentry, MTBF, etc.
**Go to `release3`**
- [ ] publish the blog post
- [ ] `git reset --hard origin/release3-staging`
- [ ] tag the release
```
git tag v0.X.X <commit-hash>
git push origin v0.X.X
```
- [ ] tag the release: `git tag v0.X.X <commit-hash> && git push origin v0.X.X`
- [ ] create GitHub release
- [ ] final test install on `openpilot.comma.ai`
- [ ] update production
- [ ] Post on Discord, X, etc.
- [ ] update factory provisioning
- [ ] close out milestone
- [ ] post on Discord, X, etc.
```

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}"

View File

@@ -12,11 +12,10 @@
<h5>Quectel/EG25-G</h5>
<p>FCC ID: XMR201903EG25G</p>
<p>
This device complies with Part 15 of the FCC Rules.
Operation is subject to the following two conditions:
<p>This device complies with Part 15 of the FCC Rules.</p>
<p>Operation is subject to the following two conditions:</p>
<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>
The following test reports are subject to this declaration:

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)

View File

@@ -13,6 +13,7 @@ from typing import NoReturn
from cereal import log, car
import cereal.messaging as messaging
from openpilot.system.hardware import HARDWARE
from openpilot.common.conversions import Conversions as CV
from openpilot.common.params import Params
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])
HEIGHT_INIT = np.array([1.22])
# These values are needed to accommodate the model frame in the narrow cam of the C3
PITCH_LIMITS = np.array([-0.09074112085129739, 0.17])
# These values are needed to accommodate the model frame in the narrow cam
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])
DEBUG = os.getenv("DEBUG") is not None

View File

@@ -82,6 +82,12 @@ class PointBuckets:
total_points_valid = self.__len__() >= self.min_points_total
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:
return all(len(v) > 0 for v in self.buckets.values())

View File

@@ -229,6 +229,8 @@ class LateralLagEstimator:
liveDelay.lateralDelayEstimateStd = 0.0
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:
liveDelay.points = self.block_avg.values.flatten().tolist()

View File

@@ -94,6 +94,7 @@ class TestLagd:
assert np.allclose(msg.liveDelay.lateralDelay, estimator.initial_lag)
assert np.allclose(msg.liveDelay.lateralDelayEstimate, estimator.initial_lag)
assert msg.liveDelay.validBlocks == 0
assert msg.liveDelay.calPerc == 0
def test_estimator_basics(self, subtests):
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.lateralDelayEstimateStd, 0.0, atol=0.01)
assert msg.liveDelay.validBlocks == BLOCK_NUM_NEEDED
assert msg.liveDelay.calPerc == 100
def test_disabled_estimator(self):
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.lateralDelayEstimateStd, 0.0, atol=0.01)
assert msg.liveDelay.validBlocks == BLOCK_NUM_NEEDED
assert msg.liveDelay.calPerc == 100
def test_estimator_masking(self):
mocked_CP, lag_frames = car.CarParams(steerActuatorDelay=0.8), random.randint(1, 19)
@@ -127,6 +130,7 @@ class TestLagd:
msg = estimator.get_msg(True)
assert np.allclose(msg.liveDelay.lateralDelayEstimate, lag_frames * DT, 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.timeout(60)

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

View File

@@ -233,6 +233,7 @@ class TorqueEstimator(ParameterEstimator):
liveTorqueParameters.latAccelOffsetFiltered = float(self.filtered_params['latAccelOffset'].x)
liveTorqueParameters.frictionCoefficientFiltered = float(self.filtered_params['frictionCoefficient'].x)
liveTorqueParameters.totalBucketPoints = len(self.filtered_points)
liveTorqueParameters.calPerc = self.filtered_points.get_valid_percent()
liveTorqueParameters.decay = self.decay
liveTorqueParameters.maxResets = self.resets
return msg

View File

@@ -60,7 +60,7 @@ import subprocess
from tinygrad import Device
# 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:
del Device
print("USB GPU detected... building")

View File

@@ -86,10 +86,20 @@ class ModelState:
prev_desire: np.ndarray # for tracking the rising edge of the pulse
def __init__(self, context: CLContext):
self.frames = {
'input_imgs': DrivingModelFrame(context, ModelConstants.TEMPORAL_SKIP),
'big_input_imgs': DrivingModelFrame(context, ModelConstants.TEMPORAL_SKIP)
}
with open(VISION_METADATA_PATH, 'rb') as f:
vision_metadata = pickle.load(f)
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.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),
}
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
self.vision_inputs: dict[str, Tensor] = {}
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()}
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:
# Model decides when action is completed, so desire input is just a pulse triggered on rising edge
inputs['desire'][0] = 0
@@ -148,8 +146,7 @@ class ModelState:
self.numpy_inputs['traffic_convention'][:] = inputs['traffic_convention']
self.numpy_inputs['lateral_control_params'][:] = inputs['lateral_control_params']
imgs_cl = {'input_imgs': self.frames['input_imgs'].prepare(buf, transform.flatten()),
'big_input_imgs': self.frames['big_input_imgs'].prepare(wbuf, transform_wide.flatten())}
imgs_cl = {name: self.frames[name].prepare(bufs[name], transforms[name].flatten()) for name in self.vision_input_names}
if TICI and not USBGPU:
# The imgs tensors are backed by opencl memory, only need init once
@@ -328,14 +325,16 @@ def main(demo=False):
if prepare_only:
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] = {
'desire': vec_desire,
'traffic_convention': traffic_convention,
'lateral_control_params': lateral_control_params,
}
}
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()
model_execution_time = mt2 - mt1

View File

@@ -585,11 +585,6 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = {
},
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: {

View File

@@ -382,16 +382,16 @@ class SelfdriveD(CruiseHelper):
if (planner_fcw or model_fcw) and not self.CP.notCar:
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
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:
self.events.add(EventName.modeldLagging)

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

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import os
import pickle
import sys
from collections import defaultdict
from typing import Any
@@ -189,22 +190,44 @@ def model_replay(lr, frs):
print("----------------- Model Timing -----------------")
print("------------------------------------------------")
print(tabulate(rows, header, tablefmt="simple_grid", stralign="center", numalign="center", floatfmt=".4f"))
assert timings_ok
assert timings_ok or PC
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__":
update = "--update" in sys.argv or (os.getenv("GIT_BRANCH", "") == 'master')
replay_dir = os.path.dirname(os.path.abspath(__file__))
# load logs
lr = list(LogReader(get_url(TEST_ROUTE, SEGMENT, "rlog.zst")))
frs = {
'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)
}
frs = get_frames()
log_msgs = []
# run replays

View File

@@ -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.capture import ProcessOutputCapture
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_TOLERANCE = 1e-7
@@ -209,6 +209,7 @@ class ProcessContainer:
streams_metas = available_streams(all_msgs)
for meta in streams_metas:
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)
vipc_server.create_buffers(meta.stream, 2, *frame_size)
vipc_server.start_listener()
@@ -224,7 +225,7 @@ class ProcessContainer:
def start(
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
):
with self.prefix as p:
@@ -266,7 +267,7 @@ class ProcessContainer:
self.prefix.clean_dirs()
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
output_msgs = []
@@ -296,7 +297,7 @@ class ProcessContainer:
camera_state = getattr(m, m.which())
camera_meta = meta_from_camera_state(m.which())
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(),
camera_state.frameId, camera_state.timestampSof, camera_state.timestampEof)
self.msg_queue = []
@@ -655,7 +656,7 @@ def replay_process_with_name(name: str | Iterable[str], lr: LogIterable, *args,
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,
captured_output_store: dict[str, dict[str, str]] = None, disable_progress: bool = False
) -> list[capnp._DynamicStructReader]:
@@ -683,7 +684,7 @@ def replay_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
) -> list[capnp._DynamicStructReader]:
if fingerprint is not None:

View File

@@ -1 +1 @@
9e2fe2942fbf77f24bccdbef15893831f9c0b390
f440c9e0469d32d350aa99ddaa8f44591a2ce690

View File

@@ -3,40 +3,17 @@ import os
import argparse
import time
import capnp
import numpy as np
from typing import Any
from collections.abc import Iterable
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
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.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.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(
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"))
if needs_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:
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."

View File

@@ -19,7 +19,6 @@ from openpilot.tools.lib.logreader import LogReader, save_log
IS_AZURE_TOKEN_DEFINED = os.getenv("AZURE_TOKEN")
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
("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
@@ -44,7 +43,6 @@ source_segments = [
]
segments = [
("BODY", "regen2F3C7259F1B|2025-04-08--23-00-23--0"),
("HYUNDAI", "regenAA0FC4ED71E|2025-04-08--22-57-50--0"),
("HYUNDAI2", "regenAFB9780D823|2025-04-08--23-00-34--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
excluded_interfaces = ["mock", "tesla"]
excluded_interfaces = ["mock", "body"]
BASE_URL = "https://commadataci.blob.core.windows.net/openpilotci/"
REF_COMMIT_FN = os.path.join(PROC_REPLAY_DIR, "ref_commit")
@@ -253,7 +251,7 @@ if __name__ == "__main__":
continue
# 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
cur_log_fn = os.path.join(FAKEDATA, f"{segment}_{cfg.proc_name}_{cur_commit}.zst")

View File

@@ -1,6 +1,6 @@
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.tools.lib.openpilotci import get_url
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"))
frs = {
'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):
frs["wideRoadCameraState"] = FrameReader(get_url(route, sidx, "ecamera.hevc"))

View File

@@ -112,7 +112,7 @@ if GetOption('extras'):
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)
# 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
if arch in ['x86_64', 'aarch64', 'Darwin'] or GetOption('extras'):

View File

@@ -4,10 +4,12 @@ from collections.abc import Callable
from enum import IntEnum
from openpilot.common.params import Params
from openpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAlert
from openpilot.selfdrive.ui.widgets.exp_mode_button import ExperimentalModeButton
from openpilot.selfdrive.ui.widgets.prime import PrimeWidget
from openpilot.selfdrive.ui.widgets.setup import SetupWidget
from openpilot.system.ui.lib.text_measure import measure_text_cached
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.widget import Widget
HEADER_HEIGHT = 80
HEAD_BUTTON_FONT_SIZE = 40
@@ -25,8 +27,9 @@ class HomeLayoutState(IntEnum):
ALERTS = 2
class HomeLayout:
class HomeLayout(Widget):
def __init__(self):
super().__init__()
self.params = Params()
self.update_alert = UpdateAlert()
@@ -47,6 +50,10 @@ class HomeLayout:
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._prime_widget = PrimeWidget()
self._setup_widget = SetupWidget()
self._exp_mode_button = ExperimentalModeButton()
self._setup_callbacks()
def _setup_callbacks(self):
@@ -59,9 +66,7 @@ class HomeLayout:
def _set_state(self, state: HomeLayoutState):
self.current_state = state
def render(self, rect: rl.Rectangle):
self._update_layout_rects(rect)
def _render(self, rect: rl.Rectangle):
current_time = time.time()
if current_time - self.last_refresh >= REFRESH_INTERVAL:
self._refresh()
@@ -78,16 +83,16 @@ class HomeLayout:
elif self.current_state == HomeLayoutState.ALERTS:
self._render_alerts_view()
def _update_layout_rects(self, rect: rl.Rectangle):
def _update_layout_rects(self):
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_height = rect.height - CONTENT_MARGIN - HEADER_HEIGHT - SPACING - CONTENT_MARGIN
content_y = self._rect.y + CONTENT_MARGIN + HEADER_HEIGHT + SPACING
content_height = self._rect.height - CONTENT_MARGIN - HEADER_HEIGHT - SPACING - CONTENT_MARGIN
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
@@ -170,28 +175,25 @@ class HomeLayout:
self.offroad_alert.render(self.content_rect)
def _render_left_column(self):
rl.draw_rectangle_rounded(self.left_column_rect, 0.02, 10, PRIME_BG_COLOR)
gui_label(self.left_column_rect, "Prime Widget", 48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
self._prime_widget.render(self.left_column_rect)
def _render_right_column(self):
widget_height = (self.right_column_rect.height - SPACING) // 2
exp_height = 125
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)
gui_label(exp_rect, "Experimental Mode", 36, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
self._exp_mode_button.render(exp_rect)
setup_rect = rl.Rectangle(
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,
widget_height,
self.right_column_rect.height - exp_height - SPACING,
)
rl.draw_rectangle_rounded(setup_rect, 0.02, 10, PRIME_BG_COLOR)
gui_label(setup_rect, "Setup", 36, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
self._setup_widget.render(setup_rect)
def _refresh(self):
# TODO: implement _update_state with a timer
self.update_available = self.update_alert.refresh()
self.alert_count = self.offroad_alert.refresh()
self._update_state_priority(self.update_available, self.alert_count > 0)

View File

@@ -1,10 +1,12 @@
import pyray as rl
from enum import IntEnum
import cereal.messaging as messaging
from openpilot.selfdrive.ui.layouts.sidebar import Sidebar, SIDEBAR_WIDTH
from openpilot.selfdrive.ui.layouts.home import HomeLayout
from openpilot.selfdrive.ui.layouts.settings.settings import SettingsLayout
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.layouts.settings.settings import SettingsLayout, PanelType
from openpilot.selfdrive.ui.ui_state import device, ui_state
from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView
from openpilot.system.ui.lib.widget import Widget
class MainState(IntEnum):
@@ -13,14 +15,15 @@ class MainState(IntEnum):
ONROAD = 2
class MainLayout:
class MainLayout(Widget):
def __init__(self):
super().__init__()
self._pm = messaging.PubMaster(['userFlag'])
self._sidebar = Sidebar()
self._sidebar_visible = True
self._current_mode = MainState.HOME
self._prev_onroad = False
self._window_rect = None
self._current_callback: callable | None = None
# Initialize layouts
self._layouts = {MainState.HOME: HomeLayout(), MainState.SETTINGS: SettingsLayout(), MainState.ONROAD: AugmentedRoadView()}
@@ -31,32 +34,23 @@ class MainLayout:
# Set callbacks
self._setup_callbacks()
def render(self, rect):
self._current_callback = None
self._update_layout_rects(rect)
def _render(self, _):
self._handle_onroad_transition()
self._render_main_content()
self._handle_input()
if self._current_callback:
self._current_callback()
def _setup_callbacks(self):
self._sidebar.set_callbacks(
on_settings=lambda: setattr(self, '_current_callback', self._on_settings_clicked),
on_flag=lambda: setattr(self, '_current_callback', self._on_flag_clicked),
)
self._layouts[MainState.SETTINGS].set_callbacks(
on_close=lambda: setattr(self, '_current_callback', self._set_mode_for_state)
)
self._sidebar.set_callbacks(on_settings=self._on_settings_clicked,
on_flag=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.ONROAD].set_callbacks(on_click=self._on_onroad_clicked)
device.add_interactive_timeout_callback(self._set_mode_for_state)
def _update_layout_rects(self, rect):
self._window_rect = rect
self._sidebar_rect = rl.Rectangle(rect.x, rect.y, SIDEBAR_WIDTH, rect.height)
def _update_layout_rects(self):
self._sidebar_rect = rl.Rectangle(self._rect.x, self._rect.y, SIDEBAR_WIDTH, self._rect.height)
x_offset = SIDEBAR_WIDTH if self._sidebar_visible else 0
self._content_rect = rl.Rectangle(rect.y + x_offset, rect.y, rect.width - x_offset, rect.height)
x_offset = SIDEBAR_WIDTH if self._sidebar.is_visible else 0
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):
if ui_state.started != self._prev_onroad:
@@ -66,31 +60,34 @@ class MainLayout:
def _set_mode_for_state(self):
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._sidebar_visible = False
else:
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):
self._current_mode = MainState.SETTINGS
self._sidebar_visible = False
self.open_settings(PanelType.DEVICE)
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):
# Render sidebar
if self._sidebar_visible:
if self._sidebar.is_visible:
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)
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

View File

@@ -1,19 +1,17 @@
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.widgets.network import WifiManagerUI
class NetworkLayout:
class NetworkLayout(Widget):
def __init__(self):
super().__init__()
self.wifi_manager = WifiManagerWrapper()
self.wifi_ui = WifiManagerUI(self.wifi_manager)
def render(self, rect: rl.Rectangle):
def _render(self, rect: rl.Rectangle):
self.wifi_ui.render(rect)
@property
def require_full_screen(self):
return self.wifi_ui.require_full_screen
def shutdown(self):
self.wifi_manager.shutdown()

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.selfdrive.ui.widgets.ssh_key import ssh_key_item
# Description constants
DESCRIPTIONS = {
@@ -8,11 +11,16 @@ DESCRIPTIONS = {
"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)",
'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):
super().__init__()
self._params = Params()
items = [
toggle_item(
@@ -21,6 +29,7 @@ class DeveloperLayout:
initial_state=self._params.get_bool("AdbEnabled"),
callback=self._on_enable_adb,
),
ssh_key_item("SSH Key", description=DESCRIPTIONS["ssh_key"]),
toggle_item(
"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):
self._list_widget.render(rect)
def _render(self, rect):
self._scroller.render(rect)
def _on_enable_adb(self): pass
def _on_joystick_debug_mode(self): pass

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.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.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
DESCRIPTIONS = {
'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)",
'reset_calibration': (
"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."
"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."
),
'review_guide': "Review the rules, features, and limitations of openpilot",
}
class DeviceLayout:
class DeviceLayout(Widget):
def __init__(self):
params = Params()
dongle_id = params.get("DongleId", encoding="utf-8") or "N/A"
serial = params.get("HardwareSerial") or "N/A"
super().__init__()
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 = [
text_item("Dongle ID", dongle_id),
text_item("Serial", serial),
button_item("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], self._on_pair_device),
button_item("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], self._on_driver_camera),
button_item("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], self._on_reset_calibration),
button_item("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], callback=self._pair_device),
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'], 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("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:
items.append(button_item("Regulatory", "VIEW", callback=self._on_regulatory))
def _render(self, rect):
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):
self._list_widget.render(rect)
def _handle_language_selection(self, result: int):
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_regulatory(self): pass
def _on_change_language(self): pass

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)

View File

@@ -2,15 +2,15 @@ import pyray as rl
from dataclasses import dataclass
from enum import IntEnum
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.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.toggles import TogglesLayout
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.selfdrive.ui.layouts.network import NetworkLayout
from openpilot.system.ui.lib.widget import Widget
# Import individual panels
@@ -18,9 +18,8 @@ SETTINGS_CLOSE_TEXT = "X"
# Constants
SIDEBAR_WIDTH = 500
CLOSE_BTN_SIZE = 200
NAV_BTN_HEIGHT = 80
NAV_BTN_HEIGHT = 110
PANEL_MARGIN = 50
SCROLL_SPEED = 30
# Colors
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)
TEXT_NORMAL = rl.Color(128, 128, 128, 255)
TEXT_SELECTED = rl.Color(255, 255, 255, 255)
TEXT_PRESSED = rl.Color(173, 173, 173, 255)
class PanelType(IntEnum):
@@ -45,23 +43,22 @@ class PanelType(IntEnum):
class PanelInfo:
name: str
instance: object
button_rect: rl.Rectangle
button_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
class SettingsLayout:
class SettingsLayout(Widget):
def __init__(self):
self._params = Params()
super().__init__()
self._current_panel = PanelType.DEVICE
self._max_scroll = 0.0
# Panel configuration
self._panels = {
PanelType.DEVICE: PanelInfo("Device", DeviceLayout(), rl.Rectangle(0, 0, 0, 0)),
PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayout(), rl.Rectangle(0, 0, 0, 0)),
PanelType.SOFTWARE: PanelInfo("Software", SoftwareLayout(), rl.Rectangle(0, 0, 0, 0)),
PanelType.FIREHOSE: PanelInfo("Firehose", None, rl.Rectangle(0, 0, 0, 0)),
PanelType.NETWORK: PanelInfo("Network", NetworkLayout(), rl.Rectangle(0, 0, 0, 0)),
PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayout(), rl.Rectangle(0, 0, 0, 0)),
PanelType.DEVICE: PanelInfo("Device", DeviceLayout()),
PanelType.NETWORK: PanelInfo("Network", NetworkLayout()),
PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayout()),
PanelType.SOFTWARE: PanelInfo("Software", SoftwareLayout()),
PanelType.FIREHOSE: PanelInfo("Firehose", FirehoseLayout()),
PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayout()),
}
self._font_medium = gui_app.font(FontWeight.MEDIUM)
@@ -73,7 +70,7 @@ class SettingsLayout:
def set_callbacks(self, on_close: Callable):
self._close_callback = on_close
def render(self, rect: rl.Rectangle):
def _render(self, rect: rl.Rectangle):
# Calculate layout
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)
@@ -82,9 +79,6 @@ class SettingsLayout:
self._draw_sidebar(sidebar_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):
rl.draw_rectangle_rec(rect, SIDEBAR_COLOR)
@@ -109,17 +103,9 @@ class SettingsLayout:
self._close_btn_rect = close_btn_rect
# Navigation buttons
nav_start_y = rect.y + 300
button_spacing = 20
i = 0
y = rect.y + 300
for panel_type, panel_info in self._panels.items():
button_rect = rl.Rectangle(
rect.x + 50,
nav_start_y + i * (NAV_BTN_HEIGHT + button_spacing),
rect.width - 150, # Right-aligned with margin
NAV_BTN_HEIGHT,
)
button_rect = rl.Rectangle(rect.x + 50, y, rect.width - 150, NAV_BTN_HEIGHT)
# Button styling
is_selected = panel_type == self._current_panel
@@ -133,7 +119,8 @@ class SettingsLayout:
# Store button rect for click detection
panel_info.button_rect = button_rect
i += 1
y += NAV_BTN_HEIGHT
def _draw_current_panel(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(
@@ -144,17 +131,8 @@ class SettingsLayout:
panel = self._panels[self._current_panel]
if panel.instance:
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
if rl.check_collision_point_rec(mouse_pos, self._close_btn_rect):
if self._close_callback:
@@ -164,20 +142,15 @@ class SettingsLayout:
# Check navigation buttons
for panel_type, panel_info in self._panels.items():
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 False
def _switch_to_panel(self, panel_type: PanelType):
def set_current_panel(self, panel_type: PanelType):
if panel_type != self._current_panel:
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):
if self._close_callback:
self._close_callback()

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):
super().__init__()
self._params = Params()
items = self._init_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
items = [
text_item("Current Version", ""),
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("Uninstall", "UNINSTALL", callback=self._on_uninstall),
]
return items
self._list_widget = ListView(items)
def render(self, rect):
self._list_widget.render(rect)
def _render(self, rect):
self._scroller.render(rect)
def _on_download_update(self): pass
def _on_install_update(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,
)

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
# Description constants
@@ -8,9 +10,14 @@ DESCRIPTIONS = {
"Your attention is required at all times to use this feature."
),
"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": (
"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.",
'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):
super().__init__()
self._params = Params()
items = [
toggle_item(
@@ -39,6 +47,15 @@ class TogglesLayout:
self._params.get_bool("DisengageOnAccelerator"),
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(
"Enable Lane Departure Warnings",
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):
self._list_widget.render(rect)
def _render(self, rect):
self._scroller.render(rect)
def _set_longitudinal_personality(self, button_index: int):
self._params.put("LongitudinalPersonality", str(button_index))

View File

@@ -6,6 +6,7 @@ from cereal import log
from openpilot.selfdrive.ui.ui_state import ui_state
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.widget import Widget
SIDEBAR_WIDTH = 300
METRIC_HEIGHT = 126
@@ -18,6 +19,7 @@ HOME_BTN = rl.Rectangle(60, 860, 180, 180)
ThermalStatus = log.DeviceState.ThermalStatus
NetworkType = log.DeviceState.NetworkType
# Color scheme
class Colors:
SIDEBAR_BG = rl.Color(57, 57, 57, 255)
@@ -35,6 +37,7 @@ class Colors:
BUTTON_NORMAL = rl.Color(255, 255, 255, 255)
BUTTON_PRESSED = rl.Color(255, 255, 255, 166)
NETWORK_TYPES = {
NetworkType.none: "Offline",
NetworkType.wifi: "WiFi",
@@ -57,8 +60,10 @@ class MetricData:
self.value = value
self.color = color
class Sidebar:
class Sidebar(Widget):
def __init__(self):
super().__init__()
self._net_type = NETWORK_TYPES.get(NetworkType.none)
self._net_strength = 0
@@ -72,7 +77,7 @@ class Sidebar:
self._font_regular = gui_app.font(FontWeight.NORMAL)
self._font_bold = gui_app.font(FontWeight.SEMI_BOLD)
# Callbacks
# Callbacks
self._on_settings_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_flag_click = on_flag
def render(self, rect: rl.Rectangle):
self.update_state()
def _render(self, rect: rl.Rectangle):
# Background
rl.draw_rectangle_rec(rect, Colors.SIDEBAR_BG)
@@ -90,9 +93,7 @@ class Sidebar:
self._draw_network_indicator(rect)
self._draw_metrics(rect)
self._handle_mouse_release()
def update_state(self):
def _update_state(self):
sm = ui_state.sm
if not sm.updated['deviceState']:
return
@@ -134,11 +135,7 @@ class Sidebar:
else:
self._panda_status.update("VEHICLE", "ONLINE", Colors.GOOD)
def _handle_mouse_release(self):
if not rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT):
return
mouse_pos = rl.get_mouse_position()
def _handle_mouse_release(self, mouse_pos: rl.Vector2):
if rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN):
if self._on_settings_click:
self._on_settings_click()
@@ -148,8 +145,7 @@ class Sidebar:
def _draw_buttons(self, rect: rl.Rectangle):
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_down = mouse_down and rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN)

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()

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.label import gui_text_box
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
ALERT_MARGIN = 40
ALERT_PADDING = 60
ALERT_LINE_SPACING = 45
@@ -21,7 +21,6 @@ ALERT_FONT_BIG = 88
SELFDRIVE_STATE_TIMEOUT = 5 # Seconds
SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds
# Constants
ALERT_COLORS = {
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):
super().__init__()
self.font_regular: rl.Font = gui_app.font(FontWeight.NORMAL)
self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
@@ -73,18 +73,20 @@ class AlertRenderer:
# Check if selfdriveState messages have stopped arriving
if not sm.updated['selfdriveState']:
recv_frame = sm.recv_frame['selfdriveState']
if (sm.frame - recv_frame) > 5 * DEFAULT_FPS:
# Check if waiting to start
if recv_frame < ui_state.started_frame:
return ALERT_STARTUP_PENDING
time_since_onroad = (sm.frame - ui_state.started_frame) / DEFAULT_FPS
# Handle selfdrive timeout
if TICI:
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
# 1. Never received selfdriveState since going onroad
waiting_for_startup = recv_frame < ui_state.started_frame
if waiting_for_startup and time_since_onroad > 5:
return ALERT_STARTUP_PENDING
# 2. Lost communication with selfdriveState after receiving it
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
if ss.alertSize == 0:
@@ -93,10 +95,10 @@ class AlertRenderer:
# Return current alert
return Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize, status=ss.alertStatus)
def draw(self, rect: rl.Rectangle, sm: messaging.SubMaster) -> None:
alert = self.get_alert(sm)
def _render(self, rect: rl.Rectangle) -> bool:
alert = self.get_alert(ui_state.sm)
if not alert:
return
return False
alert_rect = self._get_alert_rect(rect, alert.size)
self._draw_background(alert_rect, alert)
@@ -108,13 +110,14 @@ class AlertRenderer:
alert_rect.height - 2 * ALERT_PADDING
)
self._draw_text(text_rect, alert)
return True
def _get_alert_rect(self, rect: rl.Rectangle, size: int) -> rl.Rectangle:
if size == log.SelfdriveState.AlertSize.full:
return rect
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(
rect.x + ALERT_MARGIN,

View File

@@ -1,6 +1,6 @@
import numpy as np
import pyray as rl
from collections.abc import Callable
from cereal import log
from msgq.visionipc import VisionStreamType
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.orientation import rot_from_euler
OpState = log.SelfdriveState.OpenpilotState
CALIBRATED = log.LiveCalibrationData.Status.calibrated
ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD
@@ -26,6 +25,9 @@ BORDER_COLORS = {
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):
def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD):
@@ -47,11 +49,19 @@ class AugmentedRoadView(CameraView):
self.alert_renderer = AlertRenderer()
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
if not ui_state.started:
return
self._switch_stream_if_needed(ui_state.sm)
# Update calibration before rendering
self._update_calibration()
@@ -76,13 +86,13 @@ class AugmentedRoadView(CameraView):
)
# Render the base camera view
super().render(rect)
super()._render(rect)
# Draw all UI overlays
self.model_renderer.draw(self._content_rect, ui_state.sm)
self._hud_renderer.draw(self._content_rect, ui_state.sm)
self.alert_renderer.draw(self._content_rect, ui_state.sm)
self.driver_state_renderer.draw(self._content_rect, ui_state.sm)
self.model_renderer.render(self._content_rect)
self._hud_renderer.render(self._content_rect)
if not self.alert_renderer.render(self._content_rect):
self.driver_state_renderer.render(self._content_rect)
# Custom UI extension point - add custom overlays here
# Use self._content_rect for positioning within camera bounds
@@ -90,10 +100,32 @@ class AugmentedRoadView(CameraView):
# End clipping region
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):
border_color = BORDER_COLORS.get(ui_state.status, BORDER_COLORS[UIStatus.DISENGAGED])
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):
# Update device camera if not already set
sm = ui_state.sm
@@ -129,7 +161,7 @@ class AugmentedRoadView(CameraView):
# Get camera configuration
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
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
@@ -170,9 +202,9 @@ class AugmentedRoadView(CameraView):
])
video_transform = np.array([
[zoom, 0.0, (w / 2 + x - x_offset) - (cx * zoom)],
[0.0, zoom, (h / 2 + y - y_offset) - (cy * zoom)],
[0.0, 0.0, 1.0]
[zoom, 0.0, (w / 2 + x - x_offset) - (cx * zoom)],
[0.0, zoom, (h / 2 + y - y_offset) - (cy * zoom)],
[0.0, 0.0, 1.0]
])
self.model_renderer.set_transform(video_transform @ calib_transform)

View File

@@ -1,3 +1,4 @@
import platform
import numpy as np
import pyray as rl
@@ -6,12 +7,21 @@ from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf
from openpilot.common.swaglog import cloudlog
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.widget import Widget
CONNECTION_RETRY_INTERVAL = 0.2 # seconds between connection attempts
VERTEX_SHADER = """
VERSION = """
#version 300 es
precision mediump float;
"""
if platform.system() == "Darwin":
VERSION = """
#version 330 core
"""
VERTEX_SHADER = VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
in vec3 vertexNormal;
@@ -41,9 +51,7 @@ if TICI:
}
"""
else:
FRAME_FRAGMENT_SHADER = """
#version 300 es
precision mediump float;
FRAME_FRAGMENT_SHADER = VERSION + """
in vec2 fragTexCoord;
uniform sampler2D texture0;
uniform sampler2D texture1;
@@ -55,8 +63,10 @@ else:
}
"""
class CameraView:
class CameraView(Widget):
def __init__(self, name: str, stream_type: VisionStreamType):
super().__init__()
self._name = name
# Primary stream
self.client = VisionIpcClient(name, stream_type, conflate=True)
@@ -68,7 +78,6 @@ class CameraView:
self._target_stream_type: VisionStreamType | None = None
self._switching: bool = False
self._texture_needs_update = True
self.last_connection_attempt: float = 0.0
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_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
if TICI:
@@ -145,12 +154,12 @@ class CameraView:
zy = min(widget_aspect_ratio / frame_aspect_ratio, 1.0)
return np.array([
[zx, 0.0, 0.0],
[0.0, zy, 0.0],
[0.0, 0.0, 1.0]
[zx, 0.0, 0.0],
[0.0, zy, 0.0],
[0.0, 0.0, 1.0]
])
def render(self, rect: rl.Rectangle):
def _render(self, rect: rl.Rectangle):
if self._switching:
self._handle_switch()
@@ -230,7 +239,7 @@ class CameraView:
# Update textures with new frame data
if self._texture_needs_update:
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_uv, rl.ffi.cast("void *", uv_data.ctypes.data))
@@ -265,7 +274,7 @@ class CameraView:
def _handle_switch(self) -> None:
"""Check if target stream is ready and switch immediately."""
if not self._target_client or not self._switching:
return
return
# Try to connect target if needed
if not self._target_client.is_connected():
@@ -277,28 +286,28 @@ class CameraView:
# Check if target has frames ready
target_frame = self._target_client.recv(timeout_ms=0)
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()
def _complete_switch(self) -> None:
"""Instantly switch to target stream."""
cloudlog.debug(f"Switching to {self._target_stream_type}")
# Clean up current resources
if self.client:
del self.client
"""Instantly switch to target stream."""
cloudlog.debug(f"Switching to {self._target_stream_type}")
# Clean up current resources
if self.client:
del self.client
# Switch to target
self.client = self._target_client
self._stream_type = self._target_stream_type
self._texture_needs_update = True
# Switch to target
self.client = self._target_client
self._stream_type = self._target_stream_type
self._texture_needs_update = True
# Reset state
self._target_client = None
self._target_stream_type = None
self._switching = False
# Reset state
self._target_client = None
self._target_stream_type = None
self._switching = False
# Initialize textures for new stream
self._initialize_textures()
# Initialize textures for new stream
self._initialize_textures()
def _initialize_textures(self):
self._clear_textures()

View File

@@ -1,20 +1,23 @@
import numpy as np
import pyray as rl
from cereal import messaging
from msgq.visionipc import VisionStreamType
from openpilot.selfdrive.ui.onroad.cameraview import CameraView
from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.label import gui_label
class DriverCameraView(CameraView):
def __init__(self, stream_type: VisionStreamType):
super().__init__("camerad", stream_type)
class DriverCameraDialog(CameraView):
def __init__(self):
super().__init__("camerad", VisionStreamType.VISION_STREAM_DRIVER)
self.driver_state_renderer = DriverStateRenderer()
def render(self, rect, sm):
super().render(rect)
def _render(self, rect):
super()._render(rect)
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
return 1
if not self.frame:
gui_label(
@@ -24,13 +27,15 @@ class DriverCameraView(CameraView):
font_weight=FontWeight.BOLD,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
)
return
return -1
self._draw_face_detection(rect, sm)
self.driver_state_renderer.draw(rect, sm)
self._draw_face_detection(rect)
self.driver_state_renderer.render(rect)
def _draw_face_detection(self, rect: rl.Rectangle, sm) -> None:
driver_state = sm["driverStateV2"]
return -1
def _draw_face_detection(self, rect: rl.Rectangle) -> None:
driver_state = ui_state.sm["driverStateV2"]
is_rhd = driver_state.wheelOnRightProb > 0.5
driver_data = driver_state.rightDriverData if is_rhd else driver_state.leftDriverData
face_detect = driver_data.faceProb > 0.7
@@ -83,12 +88,11 @@ class DriverCameraView(CameraView):
if __name__ == "__main__":
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:
for _ in gui_app.render():
sm.update()
driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height), sm)
ui_state.update()
driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
finally:
driver_camera_view.close()

View File

@@ -3,19 +3,19 @@ import pyray as rl
from dataclasses import dataclass
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.widget import Widget
# Default 3D coordinates for face keypoints as a NumPy 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],
[-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],
[-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],
[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],
[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], [-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],
[-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],
[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],
[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],
[-5.98, -51.20, 8.00],
], dtype=np.float32)
# 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_ANGLES = np.linspace(0.0, np.pi, ARC_POINT_COUNT, dtype=np.float32)
@dataclass
class ArcData:
"""Data structure for arc rendering parameters."""
@@ -40,14 +41,15 @@ class ArcData:
height: float
thickness: float
class DriverStateRenderer:
class DriverStateRenderer(Widget):
def __init__(self):
super().__init__()
# Initial state with NumPy arrays
self.face_kpts_draw = DEFAULT_FACE_KPTS_3D.copy()
self.is_active = False
self.is_rhd = False
self.dm_fade_state = 0.0
self.state_updated = False
self.last_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self.driver_pose_vals = 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.disengaged_color = rl.Color(139, 139, 139, 255)
def draw(self, rect, sm):
if not self._is_visible(sm):
return
self._update_state(sm, rect)
if not self.state_updated:
return
self.set_visible(lambda: (ui_state.sm.recv_frame['driverStateV2'] > ui_state.started_frame and
ui_state.sm.seen['driverMonitoringState']))
def _render(self, rect):
# Set opacity based on active state
opacity = 0.65 if self.is_active else 0.2
@@ -105,18 +103,14 @@ class DriverStateRenderer:
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)
def _is_visible(self, sm):
"""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):
def _update_state(self):
"""Update the driver monitoring state based on model data"""
if not sm.updated["driverMonitoringState"]:
if self.state_updated and (rect.x != self.last_rect.x or rect.y != self.last_rect.y or \
rect.width != self.last_rect.width or rect.height != self.last_rect.height):
self._pre_calculate_drawing_elements(rect)
sm = ui_state.sm
if not sm.updated["driverMonitoringState"]:
if (self._rect.x != self.last_rect.x or self._rect.y != self.last_rect.y or
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
# Get monitoring state
@@ -165,16 +159,15 @@ class DriverStateRenderer:
self.face_keypoints_transformed = self.face_kpts_draw[:, :2] * kp_depth[:, None]
# Pre-calculate all drawing elements
self._pre_calculate_drawing_elements(rect)
self.state_updated = True
self._pre_calculate_drawing_elements()
def _pre_calculate_drawing_elements(self, rect):
def _pre_calculate_drawing_elements(self):
"""Pre-calculate all drawing elements based on the current rectangle"""
# 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
self.position_x = rect.x + (width - offset if self.is_rhd else offset)
self.position_y = rect.y + height - offset
self.position_x = self._rect.x + (width - offset if self.is_rhd else offset)
self.position_y = self._rect.y + height - offset
# Pre-calculate the face lines positions
positioned_keypoints = self.face_keypoints_transformed + np.array([self.position_x, self.position_y])
@@ -189,15 +182,15 @@ class DriverStateRenderer:
# Horizontal arc
h_width = abs(delta_x)
self.h_arc_data = self._calculate_arc_data(
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
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
)
# Vertical arc
v_height = abs(delta_y)
self.v_arc_data = self._calculate_arc_data(
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
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
)
def _calculate_arc_data(

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

View File

@@ -1,10 +1,11 @@
import pyray as rl
from dataclasses import dataclass
from cereal.messaging import SubMaster
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.text_measure import measure_text_cached
from openpilot.common.conversions import Conversions as CV
from openpilot.system.ui.lib.widget import Widget
# Constants
SET_SPEED_NA = 255
@@ -54,21 +55,25 @@ FONT_SIZES = FontSizes()
COLORS = Colors()
class HudRenderer:
class HudRenderer(Widget):
def __init__(self):
super().__init__()
"""Initialize the HUD renderer."""
self.is_cruise_set: bool = False
self.is_cruise_available: bool = False
self.set_speed: float = SET_SPEED_NA
self.speed: float = 0.0
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_bold: rl.Font = gui_app.font(FontWeight.BOLD)
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."""
sm = ui_state.sm
if sm.recv_frame["carState"] < ui_state.started_frame:
self.is_cruise_set = False
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
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."""
self._update_state(sm)
# Draw the header background
rl.draw_rectangle_gradient_v(
int(rect.x),
int(rect.y),
@@ -110,7 +115,13 @@ class HudRenderer:
self._draw_set_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:
"""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_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)
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)))

View File

@@ -7,9 +7,9 @@ from openpilot.common.params import Params
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import DEFAULT_FPS
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
CLIP_MARGIN = 500
MIN_DRAW_DISTANCE = 10.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))
projected_points: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32))
@dataclass
class LeadVehicle:
glow: list[float] = field(default_factory=list)
@@ -43,8 +44,9 @@ class LeadVehicle:
fill_alpha: int = 0
class ModelRenderer:
class ModelRenderer(Widget):
def __init__(self):
super().__init__()
self._longitudinal_control = False
self._experimental_mode = False
self._blend_factor = 1.0
@@ -64,11 +66,6 @@ class ModelRenderer:
self._car_space_transform = np.zeros((3, 3), dtype=np.float32)
self._transform_dirty = True
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 = {
'start': (0.0, 1.0), # Bottom of path
@@ -86,14 +83,15 @@ class ModelRenderer:
self._car_space_transform = transform.astype(np.float32)
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
if (sm.recv_frame["liveCalibration"] < ui_state.started_frame or
sm.recv_frame["modelV2"] < ui_state.started_frame):
return
# Set up clipping region
self._rect = rect
self._clip_region = rl.Rectangle(
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._transform_dirty = False
# Draw elements
self._draw_lane_lines()
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)]
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):
"""Draw lane lines and road edges"""
@@ -357,54 +354,60 @@ class ModelRenderer:
if points.shape[0] == 0:
return np.empty((0, 2), dtype=np.float32)
# Create left and right 3D points in one array
n_points = points.shape[0]
points_3d = self._temp_points_3d[:n_points * 2]
points_3d[:n_points, 0] = points_3d[n_points:, 0] = points[:, 0]
points_3d[:n_points, 1] = points[:, 1] - y_off
points_3d[n_points:, 1] = points[:, 1] + y_off
points_3d[:n_points, 2] = points_3d[n_points:, 2] = points[:, 2] + z_off
N = points.shape[0]
# Generate left and right 3D points in one array using broadcasting
offsets = np.array([[0, -y_off, z_off], [0, y_off, z_off]], dtype=np.float32)
points_3d = points[None, :, :] + offsets[:, None, :] # Shape: 2xNx3
points_3d = points_3d.reshape(2 * N, 3) # Shape: (2*N)x3
# Single matrix multiplication for projections
proj = np.ascontiguousarray(self._temp_proj[:, :n_points * 2]) # Slice the pre-allocated array
np.dot(self._car_space_transform, points_3d.T, out=proj)
valid_z = np.abs(proj[2]) > 1e-6
if not np.any(valid_z):
# Transform all points to projected space in one operation
proj = self._car_space_transform @ points_3d.T # Shape: 3x(2*N)
proj = proj.reshape(3, 2, N)
left_proj = proj[:, 0, :]
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)
# Compute screen coordinates
screen = proj[:2, valid_z] / proj[2, valid_z][None, :]
left_screen = screen[:, :n_points].T
right_screen = screen[:, n_points:].T
left_screen = left_proj[:2, valid_proj] / left_proj[2, valid_proj][None, :]
right_screen = right_proj[:2, valid_proj] / right_proj[2, valid_proj][None, :]
# Ensure consistent shapes by re-aligning valid points
valid_points = np.minimum(left_screen.shape[0], right_screen.shape[0])
if valid_points == 0:
# Define clip region bounds
clip = self._clip_region
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)
left_screen = left_screen[:valid_points]
right_screen = right_screen[:valid_points]
if self._clip_region:
clip = self._clip_region
bounds_mask = (
(left_screen[:, 0] >= clip.x) & (left_screen[:, 0] <= clip.x + clip.width) &
(left_screen[:, 1] >= clip.y) & (left_screen[:, 1] <= clip.y + clip.height) &
(right_screen[:, 0] >= clip.x) & (right_screen[:, 0] <= clip.x + clip.width) &
(right_screen[:, 1] >= clip.y) & (right_screen[:, 1] <= clip.y + clip.height)
)
if not np.any(bounds_mask):
# Select valid and clipped points
left_screen = left_screen[:, both_in_clip]
right_screen = right_screen[:, both_in_clip]
# Handle Y-coordinate inversion on hills
if not allow_invert and left_screen.shape[1] > 1:
y = left_screen[1, :] # y-coordinates
keep = y == np.minimum.accumulate(y)
if not np.any(keep):
return np.empty((0, 2), dtype=np.float32)
left_screen = left_screen[bounds_mask]
right_screen = right_screen[bounds_mask]
left_screen = left_screen[:, keep]
right_screen = right_screen[:, keep]
if not allow_invert and left_screen.shape[0] > 1:
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)
return np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32)
@staticmethod
def _map_val(x, x0, x1, y0, y1):
@@ -417,10 +420,10 @@ class ModelRenderer:
def _hsla_to_color(h, s, l, a):
rgb = colorsys.hls_to_rgb(h, l, s)
return rl.Color(
int(rgb[0] * 255),
int(rgb[1] * 255),
int(rgb[2] * 255),
int(a * 255)
int(rgb[0] * 255),
int(rgb[1] * 255),
int(rgb[2] * 255),
int(a * 255)
)
@staticmethod

View File

@@ -22,7 +22,7 @@ FirehosePanel::FirehosePanel(SettingsWindow *parent) : QWidget((QWidget*)parent)
layout->setSpacing(20);
// 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';");
layout->addWidget(title, 0, Qt::AlignCenter);

View File

@@ -139,6 +139,15 @@ void TogglesPanel::expandToggleDescription(const QString &param) {
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) {
updateToggles();
}
@@ -222,7 +231,7 @@ DevicePanel::DevicePanel(SettingsWindow *parent) : ListWidget(parent) {
addItem(dcamBtn);
#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::clicked, [&]() {
if (!uiState()->engaged()) {
@@ -235,6 +244,7 @@ DevicePanel::DevicePanel(SettingsWindow *parent) : ListWidget(parent) {
params.remove("LiveParametersV2");
params.remove("LiveDelay");
params.putBool("OnroadCycleRequested", true);
updateCalibDescription();
}
}
} else {
@@ -313,9 +323,7 @@ DevicePanel::DevicePanel(SettingsWindow *parent) : ListWidget(parent) {
}
void DevicePanel::updateCalibDescription() {
QString desc =
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.");
QString desc = tr("sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down.");
std::string calib_bytes = params.get("CalibrationParams");
if (!calib_bytes.empty()) {
try {
@@ -333,8 +341,53 @@ void DevicePanel::updateCalibDescription() {
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() {
@@ -387,6 +440,7 @@ void SettingsWindow::setCurrentPanel(int index, const QString &param) {
}
} else {
emit expandToggleDescription(param);
emit scrollToToggle(param);
}
}
@@ -427,6 +481,7 @@ SettingsWindow::SettingsWindow(QWidget *parent) : QFrame(parent) {
TogglesPanel *toggles = new TogglesPanel(this);
QObject::connect(this, &SettingsWindow::expandToggleDescription, toggles, &TogglesPanel::expandToggleDescription);
QObject::connect(this, &SettingsWindow::scrollToToggle, toggles, &TogglesPanel::scrollToToggle);
auto networking = new Networking(this);
QObject::connect(uiState()->prime_state, &PrimeState::changed, networking, &Networking::setPrimeType);

View File

@@ -42,6 +42,7 @@ signals:
void reviewTrainingGuide();
void showDriverView();
void expandToggleDescription(const QString &param);
void scrollToToggle(const QString &param);
protected:
QPushButton *sidebar_alert_widget;
@@ -67,6 +68,7 @@ protected slots:
protected:
Params params;
ButtonControl *pair_device;
ButtonControl *resetCalibBtn;
};
class TogglesPanel : public ListWidget {
@@ -77,6 +79,7 @@ public:
public slots:
void expandToggleDescription(const QString &param);
void scrollToToggle(const QString &param);
protected slots:
virtual void updateState(const UIState &s);

View File

@@ -136,6 +136,59 @@ QWidget * Setup::low_voltage() {
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 *widget = new QWidget();
@@ -305,20 +358,17 @@ QWidget * Setup::software_selection() {
blayout->addWidget(cont);
QObject::connect(cont, &QPushButton::clicked, [=]() {
auto w = currentWidget();
QTimer::singleShot(0, [=]() {
setCurrentWidget(downloading_widget);
});
QString url = OPENPILOT_URL;
if (group->checkedButton() != openpilot) {
url = InputDialog::getText(tr("Enter URL"), this, tr("for Custom Software"));
}
if (!url.isEmpty()) {
QTimer::singleShot(1000, this, [=]() {
download(url);
QTimer::singleShot(0, [=]() {
setCurrentWidget(custom_software_warning_widget);
});
} 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(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();
addWidget(downloading_widget);

View File

@@ -15,6 +15,7 @@ public:
private:
void selectLanguage();
QWidget *low_voltage();
QWidget *custom_software_warning();
QWidget *getting_started();
QWidget *network_setup();
QWidget *software_selection();
@@ -23,6 +24,8 @@ private:
QWidget *failed_widget;
QWidget *downloading_widget;
QWidget *custom_software_warning_widget;
QWidget *software_selection_widget;
QTranslator translator;
signals:

View File

@@ -7,21 +7,55 @@
#include <algorithm>
#include <QJsonDocument>
#include <QStyle>
#include "common/model.h"
#include "selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.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) {
QVBoxLayout *main_layout = new QVBoxLayout(this);
main_layout->setContentsMargins(50, 20, 50, 20);
ListWidgetSP *list = new ListWidgetSP(this);
ListWidgetSP *list = new ListWidgetSP(this, false);
ScrollViewSP *scroller = new ScrollViewSP(list, this);
main_layout->addWidget(scroller);
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);
connect(currentModelLblBtn, &ButtonControlSP::clicked, this, &ModelsPanel::handleCurrentModelLblBtnClicked);
@@ -32,6 +66,29 @@ ModelsPanel::ModelsPanel(QWidget *parent) : QWidget(parent) {
connect(uiStateSP(), &UIStateSP::uiUpdate, this, &ModelsPanel::updateLabels);
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
list->addItem(new ParamControlSP("LagdToggle",
tr("Live Learning Steer Delay"),
@@ -40,15 +97,38 @@ ModelsPanel::ModelsPanel(QWidget *parent) : QWidget(parent) {
"../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
* Reads status from modelManagerSP cereal message and displays status for all models
*/
void ModelsPanel::handleBundleDownloadProgress() {
supercomboFrame->setVisible(false);
visionFrame->setVisible(false);
policyFrame->setVisible(false);
navigationFrame->setVisible(false);
using DS = cereal::ModelManagerSP::DownloadStatus;
if (!model_manager.hasSelectedBundle() && !model_manager.hasActiveBundle()) {
currentModelLblBtn->setDescription(tr("No custom model selected!"));
return;
}
@@ -61,21 +141,27 @@ void ModelsPanel::handleBundleDownloadProgress() {
// Get status for each model type in order
for (const auto &model: models) {
QString typeName;
QString modelName = QString::fromStdString(bundle.getDisplayName());
QProgressBar *progressBar = nullptr;
QFrame *modelFrame = nullptr;
switch (model.getType()) {
case cereal::ModelManagerSP::Model::Type::SUPERCOMBO:
typeName = tr("Driving");
progressBar = supercomboProgressBar;
modelFrame = supercomboFrame;
break;
case cereal::ModelManagerSP::Model::Type::NAVIGATION:
typeName = tr("Navigation");
progressBar = navigationProgressBar;
modelFrame = navigationFrame;
break;
case cereal::ModelManagerSP::Model::Type::VISION:
typeName = tr("Vision");
progressBar = visionProgressBar;
modelFrame = visionFrame;
break;
case cereal::ModelManagerSP::Model::Type::POLICY:
typeName = tr("Policy");
progressBar = policyProgressBar;
modelFrame = policyFrame;
break;
}
@@ -83,31 +169,26 @@ void ModelsPanel::handleBundleDownloadProgress() {
QString line;
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) {
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) {
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) {
line = tr("%1 model [%2] download failed").arg(typeName, modelName);
progressBar->setStyleSheet(progressStyleError);
progressBar->setFormat(tr(" download failed - %1").arg(modelName));
} else {
line = tr("%1 model [%2] pending...").arg(typeName, modelName);
progressBar->setStyleSheet(progressStyleInactive);
progressBar->setFormat(tr(" pending - %1").arg(modelName));
}
status.append(line);
}
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;
// keep navigation hidden for now to avoid confusion
if (model.getType() != cereal::ModelManagerSP::Model::Type::NAVIGATION) {
modelFrame->setVisible(true);
}
}
prev_download_status = download_status;
@@ -125,6 +206,17 @@ QString ModelsPanel::GetActiveModelName() {
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() {
const SubMaster &sm = *(uiStateSP()->sm);
model_manager = sm["modelManagerSP"].getModelManagerSP();
@@ -156,7 +248,7 @@ void ModelsPanel::handleCurrentModelLblBtnClicked() {
bundleNames.append(index_to_bundle[index]);
}
currentModelLblBtn->setValue(GetActiveModelName());
currentModelLblBtn->setValue(GetActiveModelInternalName());
const QString selectedBundleName = MultiOptionDialog::getSelection(
tr("Select a Model"), bundleNames, GetActiveModelName(), this);
@@ -197,7 +289,7 @@ void ModelsPanel::updateLabels() {
updateModelManagerState();
handleBundleDownloadProgress();
currentModelLblBtn->setEnabled(!is_onroad && !isDownloading());
currentModelLblBtn->setValue(GetActiveModelName());
currentModelLblBtn->setValue(GetActiveModelInternalName());
}
/**

View File

@@ -7,6 +7,8 @@
#pragma once
#include <QProgressBar>
#include "selfdrive/ui/sunnypilot/qt/offroad/settings/settings.h"
class ModelsPanel : public QWidget {
@@ -17,6 +19,7 @@ public:
private:
QString GetActiveModelName();
QString GetActiveModelInternalName();
void updateModelManagerState();
bool isDownloading() const {
@@ -33,6 +36,8 @@ private:
void handleCurrentModelLblBtnClicked();
void handleBundleDownloadProgress();
void showResetParamsDialog();
QProgressBar* createProgressBar(QWidget *parent);
QFrame* createModelDetailFrame(QWidget *parent, QString &typeName, QProgressBar *progressBar);
cereal::ModelManagerSP::Reader model_manager;
cereal::ModelManagerSP::DownloadStatus download_status{};
cereal::ModelManagerSP::DownloadStatus prev_download_status{};
@@ -59,6 +64,14 @@ private:
bool is_onroad = false;
ButtonControlSP *currentModelLblBtn;
QProgressBar *supercomboProgressBar;
QFrame *supercomboFrame;
QProgressBar *navigationProgressBar;
QFrame *navigationFrame;
QProgressBar *visionProgressBar;
QFrame *visionFrame;
QProgressBar *policyProgressBar;
QFrame *policyFrame;
Params params;
};

View File

@@ -72,6 +72,7 @@ SettingsWindowSP::SettingsWindowSP(QWidget *parent) : SettingsWindow(parent) {
TogglesPanelSP *toggles = new TogglesPanelSP(this);
QObject::connect(this, &SettingsWindowSP::expandToggleDescription, toggles, &TogglesPanel::expandToggleDescription);
QObject::connect(this, &SettingsWindowSP::scrollToToggle, toggles, &TogglesPanel::scrollToToggle);
auto networking = new NetworkingSP(this);
QObject::connect(uiState()->prime_state, &PrimeState::changed, networking, &NetworkingSP::setPrimeType);

View File

@@ -391,9 +391,9 @@ def create_screenshots():
driver_img = frames[2]
else:
with open(frames_cache, 'wb') as f:
road_img = FrameReader(route.camera_paths()[segnum]).get(0, pix_fmt="nv12")[0]
wide_road_img = FrameReader(route.ecamera_paths()[segnum]).get(0, pix_fmt="nv12")[0]
driver_img = FrameReader(route.dcamera_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], pix_fmt="nv12").get(0)
driver_img = FrameReader(route.dcamera_paths()[segnum], pix_fmt="nv12").get(0)
pickle.dump([road_img, wide_road_img, driver_img], f)
STREAMS.append((VisionStreamType.VISION_STREAM_ROAD, cam.fcam, road_img.flatten().tobytes()))

View File

@@ -6,16 +6,16 @@ from openpilot.selfdrive.ui.layouts.main import MainLayout
from openpilot.selfdrive.ui.ui_state import ui_state
def main():
gui_app.init_window("UI")
main_layout = MainLayout()
main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
for _ in gui_app.render():
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()

View File

@@ -1,8 +1,10 @@
import pyray as rl
import time
from collections.abc import Callable
from enum import Enum
from cereal import messaging, log
from openpilot.common.params import Params, UnknownKeyName
from openpilot.selfdrive.ui.lib.prime_state import PrimeState
UI_BORDER_SIZE = 30
@@ -44,6 +46,8 @@ class UIState:
]
)
self.prime_state = PrimeState()
# UI Status tracking
self.status: UIStatus = UIStatus.DISENGAGED
self.started_frame: int = 0
@@ -64,10 +68,17 @@ class UIState:
def engaged(self) -> bool:
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:
self.sm.update(0)
self._update_state()
self._update_status()
device.update()
def _update_state(self) -> None:
# Handle panda states updates
@@ -125,5 +136,36 @@ class UIState:
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
ui_state = UIState()
device = Device()

View File

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))

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.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.widget import Widget
class AlertColors:
HIGH_SEVERITY = rl.Color(226, 44, 44, 255)
@@ -40,8 +42,9 @@ class AlertData:
visible: bool = False
class AbstractAlert(ABC):
class AbstractAlert(Widget, ABC):
def __init__(self, has_reboot_btn: bool = False):
super().__init__()
self.params = Params()
self.has_reboot_btn = has_reboot_btn
self.dismiss_callback: Callable | None = None
@@ -67,8 +70,7 @@ class AbstractAlert(ABC):
pass
def handle_input(self, mouse_pos: rl.Vector2, mouse_clicked: bool) -> bool:
# TODO: fix scroll_panel.is_click_valid()
if not mouse_clicked:
if not mouse_clicked or not self.scroll_panel.is_touch_valid():
return False
if rl.check_collision_point_rec(mouse_pos, self.dismiss_btn_rect):
@@ -88,7 +90,7 @@ class AbstractAlert(ABC):
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)
footer_height = AlertConstants.BUTTON_SIZE[1] + AlertConstants.SPACING

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

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)

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

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())

View File

@@ -18,7 +18,7 @@ from cereal import messaging
from openpilot.common.params import Params
from openpilot.sunnypilot.mapd.mapd_manager import MAPD_PATH, MAPD_BIN_DIR
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
import openpilot.system.sentry as sentry

View File

@@ -1 +1 @@
dec34c57c4131f6fca5d1035201d1afbf43e5250cede7bfdc798371af008afad
71979b29c4bab3007de1a4265442d79f44c0eaef066af66086dddfc432709b94

View File

@@ -7,6 +7,7 @@ from pathlib import Path
from datetime import datetime, timedelta, UTC
from openpilot.common.api import api_get
from openpilot.common.params import Params
from openpilot.common.spinner import Spinner
from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
from openpilot.system.hardware import HARDWARE, PC
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}")
elif dongle_id is None:
if show_spinner:
from openpilot.system.ui.spinner import Spinner
spinner = Spinner()
spinner.update("registering device")

View File

@@ -56,28 +56,28 @@
},
{
"name": "boot",
"url": "https://commadist.azureedge.net/agnosupdate/boot-4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42.img.xz",
"hash": "4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42",
"hash_raw": "4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42",
"url": "https://commadist.azureedge.net/agnosupdate/boot-4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef.img.xz",
"hash": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
"hash_raw": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
"size": 18479104,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "6b7b3371100ad36d8a5a9ff19a1663b9b9e2d5e99cbe3cf9255e9c3017291ce3"
"ondevice_hash": "8d7094d774faa4e801e36b403a31b53b913b31d086f4dc682d2f64710c557e8a"
},
{
"name": "system",
"url": "https://commadist.azureedge.net/agnosupdate/system-c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa.img.xz",
"hash": "993d6a1cd2b684e2b1cf6ff840f8996f02a529011372d9c1471e4c80719e7da9",
"hash_raw": "c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa",
"url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img.xz",
"hash": "cccd7073d067027396f2afd49874729757db0bbbc79853a0bf2938bd356fe164",
"hash_raw": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
"size": 5368709120,
"sparse": true,
"full_check": false,
"has_ab": true,
"ondevice_hash": "59db25651da977eeb16a1af741fd01fc3d6b50d21544b1a7428b7c86b2cdef2d",
"ondevice_hash": "c7707f16ce7d977748677cc354e250943b4ff6c21b9a19a492053d32397cf9ec",
"alt": {
"hash": "c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa",
"url": "https://commadist.azureedge.net/agnosupdate/system-c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa.img",
"hash": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
"url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img",
"size": 5368709120
}
}

View File

@@ -339,62 +339,62 @@
},
{
"name": "boot",
"url": "https://commadist.azureedge.net/agnosupdate/boot-4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42.img.xz",
"hash": "4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42",
"hash_raw": "4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42",
"url": "https://commadist.azureedge.net/agnosupdate/boot-4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef.img.xz",
"hash": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
"hash_raw": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
"size": 18479104,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "6b7b3371100ad36d8a5a9ff19a1663b9b9e2d5e99cbe3cf9255e9c3017291ce3"
"ondevice_hash": "8d7094d774faa4e801e36b403a31b53b913b31d086f4dc682d2f64710c557e8a"
},
{
"name": "system",
"url": "https://commadist.azureedge.net/agnosupdate/system-c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa.img.xz",
"hash": "993d6a1cd2b684e2b1cf6ff840f8996f02a529011372d9c1471e4c80719e7da9",
"hash_raw": "c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa",
"url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img.xz",
"hash": "cccd7073d067027396f2afd49874729757db0bbbc79853a0bf2938bd356fe164",
"hash_raw": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
"size": 5368709120,
"sparse": true,
"full_check": false,
"has_ab": true,
"ondevice_hash": "59db25651da977eeb16a1af741fd01fc3d6b50d21544b1a7428b7c86b2cdef2d",
"ondevice_hash": "c7707f16ce7d977748677cc354e250943b4ff6c21b9a19a492053d32397cf9ec",
"alt": {
"hash": "c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa",
"url": "https://commadist.azureedge.net/agnosupdate/system-c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa.img",
"hash": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
"url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img",
"size": 5368709120
}
},
{
"name": "userdata_90",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_90-89a161f17b86637413fe10a641550110b626b699382f5138c02267b7866a8494.img.xz",
"hash": "99d9e6cf6755581c6879bbf442bd62212beb8a04116e965ab987135b8842188b",
"hash_raw": "89a161f17b86637413fe10a641550110b626b699382f5138c02267b7866a8494",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_90-f0c675e0fae420870c9ba8979fa246b170f4f1a7a04b49609b55b6bdfa8c1b21.img.xz",
"hash": "3d8a007bae088c5959eb9b82454013f91868946d78380fecea2b1afdfb575c02",
"hash_raw": "f0c675e0fae420870c9ba8979fa246b170f4f1a7a04b49609b55b6bdfa8c1b21",
"size": 96636764160,
"sparse": true,
"full_check": true,
"has_ab": false,
"ondevice_hash": "24ea29ab9c4ecec0568a4aa83e38790fedfce694060e90f4bde725931386ff41"
"ondevice_hash": "5bfbabb8ff96b149056aa75d5b7e66a7cdd9cb4bcefe23b922c292f7f3a43462"
},
{
"name": "userdata_89",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_89-cdd3401168819987c4840765bba1aa2217641b1a6a4165c412f44cac14ccfcbf.img.xz",
"hash": "5fbfa008a7f6b58ab01d4d171f3185924d4c9db69b54f4bfc0f214c6f17c2435",
"hash_raw": "cdd3401168819987c4840765bba1aa2217641b1a6a4165c412f44cac14ccfcbf",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_89-06fc52be37b42690ed7b4f8c66c4611309a2dea9fca37dd9d27d1eff302eb1bf.img.xz",
"hash": "443f136484294b210318842d09fb618d5411c8bdbab9f7421d8c89eb291a8d3f",
"hash_raw": "06fc52be37b42690ed7b4f8c66c4611309a2dea9fca37dd9d27d1eff302eb1bf",
"size": 95563022336,
"sparse": true,
"full_check": true,
"has_ab": false,
"ondevice_hash": "c07dc2e883a23d4a24d976cdf53a767a2fd699c8eeb476d60cdf18e84b417a52"
"ondevice_hash": "67db02b29a7e4435951c64cc962a474d048ed444aa912f3494391417cd51a074"
},
{
"name": "userdata_30",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_30-2a8e8278b3bb545e6d7292c2417ccebdca9b47507eb5924f7c1e068737a7edfd.img.xz",
"hash": "b3bc293c9c5e0480ef663e980c8ccb2fb83ffd230c85f8797830fb61b8f59360",
"hash_raw": "2a8e8278b3bb545e6d7292c2417ccebdca9b47507eb5924f7c1e068737a7edfd",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_30-06679488f0c5c3fcfd5f351133050751cd189f705e478a979c45fc4a166d18a6.img.xz",
"hash": "875b580cb786f290a842e9187fd945657561886123eb3075a26f7995a18068f6",
"hash_raw": "06679488f0c5c3fcfd5f351133050751cd189f705e478a979c45fc4a166d18a6",
"size": 32212254720,
"sparse": true,
"full_check": true,
"has_ab": false,
"ondevice_hash": "8dae1cda089828c750d1d646337774ccd9432f567ecefde19a06dc7feeda9cd3"
"ondevice_hash": "16e27ba3c5cf9f0394ce6235ba6021b8a2de293fdb08399f8ca832fa5e4d0b9d"
}
]

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]

View File

@@ -3,6 +3,7 @@ import math
import os
import subprocess
import time
import tempfile
from enum import IntEnum
from functools import cached_property, lru_cache
from pathlib import Path
@@ -499,19 +500,18 @@ class Tici(HardwareBase):
except Exception:
pass
# we use the lte connection built into AGNOS. cleanup esim connection if it exists
# eSIM prime
dest = "/etc/NetworkManager/system-connections/esim.nmconnection"
if os.path.exists(dest):
os.system(f"sudo nmcli con delete {dest}")
self.reboot_modem()
if sim_id.startswith('8985235') and not os.path.exists(dest):
with open(Path(__file__).parent/'esim.nmconnection') as f, tempfile.NamedTemporaryFile(mode='w') as tf:
dat = f.read()
dat = dat.replace("sim-id=", f"sim-id={sim_id}")
tf.write(dat)
tf.flush()
def reboot_modem(self):
modem = self.get_modem()
for state in (0, 1):
try:
modem.Command(f'AT+CFUN={state}', math.ceil(TIMEOUT), dbus_interface=MM_MODEM, timeout=TIMEOUT)
except Exception:
pass
# needs to be root
os.system(f"sudo cp {tf.name} {dest}")
os.system(f"sudo nmcli con load {dest}")
def get_networks(self):
r = {}

View File

@@ -5,16 +5,16 @@ from pathlib import Path
# NOTE: Do NOT import anything here that needs be built (e.g. params)
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.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
MAX_CACHE_SIZE = 4e9 if "CI" in os.environ else 2e9
CACHE_DIR = Path("/data/scons_cache" if AGNOS else "/tmp/scons_cache")
TOTAL_SCONS_NODES = 3765
TOTAL_SCONS_NODES = 3800
MAX_BUILD_PROGRESS = 100
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__":
with Spinner() as spinner:
spinner.update_progress(0, 100)
build_metadata = get_build_metadata()
build(spinner, build_metadata.openpilot.is_dirty, minimal = AGNOS)
spinner = Spinner()
spinner.update_progress(0, 100)
build_metadata = get_build_metadata()
build(spinner, build_metadata.openpilot.is_dirty, minimal = AGNOS)

View File

@@ -9,6 +9,7 @@ from cereal import log
import cereal.messaging as messaging
import openpilot.system.sentry as sentry
from openpilot.common.params import Params, ParamKeyType
from openpilot.common.text_window import TextWindow
from openpilot.system.hardware import HARDWARE
from openpilot.system.manager.helpers import unblock_stdout, write_onroad_params, save_bootlog
from openpilot.system.manager.process import ensure_running
@@ -234,8 +235,6 @@ def main() -> None:
if __name__ == "__main__":
from openpilot.system.ui.text import TextWindow
unblock_stdout()
try:

View File

@@ -114,7 +114,7 @@ procs = [
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)),
PythonProcess("soundd", "selfdrive.ui.soundd", and_(only_onroad, not_joystick)),
PythonProcess("soundd", "selfdrive.ui.soundd", only_onroad),
PythonProcess("locationd", "selfdrive.locationd.locationd", only_onroad),
NativeProcess("_pandad", "selfdrive/pandad", ["./pandad"], always_run, enabled=False),
PythonProcess("calibrationd", "selfdrive.locationd.calibrationd", only_onroad),

View File

@@ -1,7 +1,10 @@
import atexit
import cffi
import os
import time
import pyray as rl
from collections.abc import Callable
from dataclasses import dataclass
from enum import IntEnum
from importlib.resources import as_file, files
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_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'
STRICT_MODE = os.getenv("STRICT_MODE") == '1'
SCALE = float(os.getenv("SCALE", "1.0"))
@@ -36,6 +39,12 @@ class FontWeight(IntEnum):
BLACK = 8
@dataclass
class ModalOverlay:
overlay: object = None
callback: Callable | None = None
class GuiApplication:
def __init__(self, width: int, height: int):
self._fonts: dict[FontWeight, rl.Font] = {}
@@ -50,6 +59,7 @@ class GuiApplication:
self._last_fps_log_time: float = time.monotonic()
self._window_close_requested = False
self._trace_log_callback = None
self._modal_overlay = ModalOverlay()
def request_close(self):
self._window_close_requested = True
@@ -79,6 +89,9 @@ class GuiApplication:
self._set_styles()
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):
cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}"
if cache_key in self._textures:
@@ -148,7 +161,23 @@ class GuiApplication:
rl.begin_drawing()
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:
rl.end_texture_mode()
@@ -192,12 +221,11 @@ class GuiApplication:
# Create a character set from our 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()
for layout in KEYBOARD_LAYOUTS.values():
all_chars.update(key for row in layout for key in row)
all_chars = "".join(all_chars)
all_chars += CRUISE_DISABLED_CHAR
all_chars += "–✓°"
codepoint_count = rl.ffi.new("int *", 1)
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)))
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 *)")
def trace_log_callback(log_level, text, args):
try:
text_str = rl.ffi.string(text).decode('utf-8')
except (TypeError, UnicodeDecodeError):
text_str = str(text)
text_addr = int(rl.ffi.cast("uintptr_t", text))
args_addr = int(rl.ffi.cast("uintptr_t", args))
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:
cloudlog.error(f"raylib: {text_str}")

View File

@@ -10,6 +10,7 @@ class ButtonStyle(IntEnum):
DANGER = 2 # For critical actions, like reboot or delete
TRANSPARENT = 3 # For buttons with transparent background and border
ACTION = 4
LIST_ACTION = 5 # For list items with action buttons
class TextAlignment(IntEnum):
@@ -20,11 +21,17 @@ class TextAlignment(IntEnum):
ICON_PADDING = 15
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)
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 = {
ButtonStyle.NORMAL: rl.Color(51, 51, 51, 255),
@@ -32,6 +39,7 @@ BUTTON_BACKGROUND_COLORS = {
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.ACTION: rl.Color(189, 189, 189, 255),
ButtonStyle.LIST_ACTION: rl.Color(57, 57, 57, 255),
}
BUTTON_PRESSED_BACKGROUND_COLORS = {
@@ -40,6 +48,7 @@ BUTTON_PRESSED_BACKGROUND_COLORS = {
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
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
@@ -133,7 +142,7 @@ def gui_button(
# Draw the button text if any
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
rl.draw_text_ex(font, text, text_pos, font_size, 0, 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, color)
return result

View File

@@ -4,7 +4,6 @@ from dataclasses import dataclass
from typing import Any
from openpilot.common.swaglog import cloudlog
# EGL constants
EGL_LINUX_DMA_BUF_EXT = 0x3270
EGL_WIDTH = 0x3057
@@ -23,6 +22,7 @@ GL_TEXTURE_EXTERNAL_OES = 0x8D65
# DRM Format for NV12
DRM_FORMAT_NV12 = 842094158
@dataclass
class EGLImage:
"""Container for EGL image and associated resources"""

View File

@@ -2,14 +2,15 @@ import pyray as rl
import time
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.widget import Widget
PASSWORD_MASK_CHAR = ""
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):
super().__init__()
self._max_text_size = max_text_size
self._input_text = ""
self._cursor_position = 0
@@ -23,7 +24,7 @@ class InputBox:
self._text_offset = 0
self._visible_width = 0
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
def text(self):
@@ -76,11 +77,11 @@ class InputBox:
def add_char_at_cursor(self, char):
"""Add a character at the current cursor position."""
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)
if self._password_mode:
self._last_char_time = time.time()
self._last_char_time = time.monotonic()
return True
return False
@@ -88,7 +89,7 @@ class InputBox:
def delete_char_before_cursor(self):
"""Delete the character before the cursor position (backspace)."""
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)
return True
return False
@@ -96,12 +97,12 @@ class InputBox:
def delete_char_at_cursor(self):
"""Delete the character at the cursor position (delete)."""
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)
return True
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
self._visible_width = rect.width
self._font_size = font_size
@@ -160,18 +161,18 @@ class InputBox:
# Show character at last edited position if within delay window
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:
last_pos = max(0, self._cursor_position - 1)
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
def _handle_mouse_input(self, rect, font_size):
"""Handle mouse clicks to position cursor."""
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
if len(self._input_text) > 0:
font = gui_app.font()

View File

@@ -76,4 +76,3 @@ def gui_text_box(
if font_weight != FontWeight.NORMAL:
rl.gui_set_font(gui_app.font(FontWeight.NORMAL))

View File

@@ -1,27 +1,22 @@
import os
import pyray as rl
from dataclasses import dataclass
from collections.abc import Callable
from abc import ABC, abstractmethod
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from abc import ABC
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.wrap_text import wrap_text
from openpilot.system.ui.lib.button import gui_button
from openpilot.system.ui.lib.toggle import Toggle
from openpilot.system.ui.lib.toggle import WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
from openpilot.system.ui.lib.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT
from openpilot.system.ui.lib.widget import Widget
LINE_PADDING = 40
LINE_COLOR = rl.GRAY
ITEM_PADDING = 20
ITEM_SPACING = 80
ITEM_BASE_WIDTH = 600
ITEM_BASE_HEIGHT = 170
ITEM_PADDING = 20
ITEM_TEXT_FONT_SIZE = 50
ITEM_TEXT_COLOR = rl.WHITE
ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255)
ITEM_DESC_FONT_SIZE = 40
ITEM_DESC_V_OFFSET = 130
ITEM_DESC_V_OFFSET = 140
RIGHT_ITEM_PADDING = 20
ICON_SIZE = 80
BUTTON_WIDTH = 250
@@ -30,38 +25,42 @@ BUTTON_BORDER_RADIUS = 50
BUTTON_FONT_SIZE = 35
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
class RightItem(ABC):
def __init__(self, width: int = 100):
self.width = width
self.enabled = True
class ItemAction(Widget, ABC):
def __init__(self, width: int = BUTTON_HEIGHT, enabled: bool | Callable[[], bool] = True):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, width, 0))
self._enabled_source = enabled
@abstractmethod
def draw(self, rect: rl.Rectangle) -> bool:
pass
@abstractmethod
def get_width(self) -> int:
pass
@property
def enabled(self):
return _resolve_value(self._enabled_source, False)
class ToggleRightItem(RightItem):
def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH):
super().__init__(width)
class ToggleAction(ItemAction):
def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True):
super().__init__(width, enabled)
self.toggle = Toggle(initial_state=initial_state)
self.state = initial_state
self.enabled = True
def draw(self, rect: rl.Rectangle) -> bool:
if self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self.width, TOGGLE_HEIGHT)):
self.state = not self.state
return True
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
self.toggle.set_touch_valid_callback(touch_callback)
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
def get_width(self) -> int:
return self.width
def set_state(self, state: bool):
self.state = state
self.toggle.set_state(state)
@@ -69,293 +68,269 @@ class ToggleRightItem(RightItem):
def get_state(self) -> bool:
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):
def __init__(self, text: str, width: int = BUTTON_WIDTH):
super().__init__(width)
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
class TextAction(ItemAction):
def __init__(self, text: str | Callable[[], str], color: rl.Color = ITEM_TEXT_COLOR, enabled: bool | Callable[[], bool] = True):
self._text_source = text
self.color = color
self.font_size = font_size
font = gui_app.font(FontWeight.NORMAL)
text_width = measure_text_cached(font, text, font_size).x
super().__init__(int(text_width + 20))
self._font = gui_app.font(FontWeight.NORMAL)
initial_text = _resolve_value(text, "")
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:
font = gui_app.font(FontWeight.NORMAL)
text_size = measure_text_cached(font, self.text, self.font_size)
@property
def text(self):
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_y = rect.y + (rect.height - text_size.y) / 2
rl.draw_text_ex(font, self.text, rl.Vector2(text_x, text_y), self.font_size, 0, self.color)
rl.draw_text_ex(self._font, current_text, rl.Vector2(text_x, text_y), ITEM_TEXT_FONT_SIZE, 0, self.color)
return False
def get_width(self) -> int:
return self.width
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)
text_width = measure_text_cached(self._font, self.text, ITEM_TEXT_FONT_SIZE).x
return int(text_width + TEXT_PADDING)
@dataclass
class ListItem:
title: str
icon: str | None = None
description: str | None = None
description_visible: bool = False
rect: "rl.Rectangle | None" = None
callback: Callable | None = None
right_item: RightItem | None = None
class DualButtonAction(ItemAction):
def __init__(self, left_text: str, right_text: str, left_callback: Callable = None,
right_callback: Callable = None, enabled: bool | Callable[[], bool] = True):
super().__init__(width=0, enabled=enabled) # Width 0 means use full width
self.left_text, self.right_text = left_text, right_text
self.left_callback, self.right_callback = left_callback, right_callback
# Cached properties for performance
_wrapped_description: str | None = None
_description_height: float = 0
def _render(self, rect: rl.Rectangle) -> bool:
button_spacing = 30
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:
return self.right_item
left_rect = rl.Rectangle(rect.x, button_y, button_width, button_height)
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:
if self.description_visible and self.description:
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
left_clicked = gui_button(left_rect, self.left_text, button_style=ButtonStyle.LIST_ACTION) == 1
right_clicked = gui_button(right_rect, self.right_text, button_style=ButtonStyle.DANGER) == 1
def get_content_width(self, total_width: int) -> int:
if self.right_item:
return total_width - self.right_item.get_width() - RIGHT_ITEM_PADDING
return total_width
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
if not self.right_item:
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)
if left_clicked and self.left_callback:
self.left_callback()
return True
if right_clicked and self.right_callback:
self.right_callback()
return True
return False
class ListView:
def __init__(self, items: list[ListItem]):
self._items: list[ListItem] = items
self._last_dim: tuple[float, float] = (0, 0)
self.scroll_panel = GuiScrollPanel()
class MultipleButtonAction(ItemAction):
def __init__(self, buttons: list[str], button_width: int, selected_index: int = 0, callback: Callable = None):
super().__init__(width=len(buttons) * (button_width + 20), enabled=True)
self.buttons = buttons
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
self._hovered_item: int = -1
self._last_mouse_pos = rl.Vector2(0, 0)
for i, text in enumerate(self.buttons):
button_x = rect.x + i * (self.button_width + spacing)
button_rect = rl.Rectangle(button_x, button_y, self.button_width, BUTTON_HEIGHT)
self._total_height: float = 0
self._visible_range = (0, 0)
# Check button state
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):
self._last_dim = (0, 0)
# Button colors
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):
if self._last_dim != (rect.width, rect.height):
self._update_item_rects(rect)
self._last_dim = (rect.width, rect.height)
# Draw button
rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color)
# Update layout and handle scrolling
content_rect = rl.Rectangle(rect.x, rect.y, rect.width, self._total_height)
scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect)
# Draw text
text_size = measure_text_cached(self._font, text, 40)
text_x = button_x + (self.button_width - text_size.x) / 2
text_y = button_y + (BUTTON_HEIGHT - text_size.y) / 2
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, rl.Color(228, 228, 228, 255))
# Handle mouse interaction
if self.scroll_panel.is_click_valid():
self._handle_mouse_interaction(rect, scroll_offset)
# Handle click
if is_hovered and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._is_pressed:
clicked = i
# Set scissor mode for clipping
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
if clicked >= 0:
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
for i in range(self._visible_range[0], min(self._visible_range[1], len(self._items))):
item = self._items[i]
if item.rect:
adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height)
self._render_item(item, adjusted_rect, i)
class ListItem(Widget):
def __init__(self, title: str = "", icon: str | None = None, description: str | Callable[[], str] | None = None,
description_visible: bool = False, callback: Callable | None = None,
action_item: ItemAction | None = None):
super().__init__()
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:
rl.draw_line_ex(
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()
self.set_rect(rl.Rectangle(0, 0, ITEM_BASE_WIDTH, ITEM_BASE_HEIGHT))
self._font = gui_app.font(FontWeight.NORMAL)
def _render_item(self, item: ListItem, rect: rl.Rectangle, index: int):
content_x = rect.x + ITEM_PADDING
# Cached properties for performance
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
# Calculate available width for main content
content_width = item.get_content_width(int(rect.width - ITEM_PADDING * 2))
# Only draw title and icon for items that have them
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
if item.icon:
icon_texture = gui_app.texture(os.path.join("icons", item.icon), ICON_SIZE, ICON_SIZE)
rl.draw_texture(
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 main text
text_size = measure_text_cached(self._font, self.title, ITEM_TEXT_FONT_SIZE)
item_y = self._rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR)
# Draw description if visible
current_description = self.get_description()
if self.description_visible and current_description and self._wrapped_description:
rl.draw_text_ex(
self._font_normal,
item._wrapped_description,
rl.Vector2(text_x, desc_y),
self._font,
self._wrapped_description,
rl.Vector2(text_x, self._rect.y + ITEM_DESC_V_OFFSET),
ITEM_DESC_FONT_SIZE,
0,
ITEM_DESC_TEXT_COLOR,
)
# Draw right item if present
if item.right_item:
right_rect = item.get_right_item_rect(rect)
# Adjust for scroll offset
right_rect.y = right_rect.y
if item.right_item.draw(right_rect):
if self.action_item:
right_rect = self.get_right_item_rect(self._rect)
right_rect.y = self._rect.y
if self.action_item.render(right_rect) and self.action_item.enabled:
# Right item was clicked/activated
if item.callback:
item.callback()
if self.callback:
self.callback()
def _update_item_rects(self, container_rect: rl.Rectangle) -> None:
current_y: float = 0.0
self._total_height = 0
def get_description(self):
return _resolve_value(self.description, None)
for item in self._items:
content_width = item.get_content_width(int(container_rect.width - ITEM_PADDING * 2))
item_height = item.get_item_height(self._font_normal, content_width)
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 get_item_height(self, font: rl.Font, max_width: int) -> float:
if not self.is_visible:
return 0
def _calculate_visible_range(self, rect: rl.Rectangle, scroll_offset: float):
if not self._items:
self._visible_range = (0, 0)
return
current_description = self.get_description()
if self.description_visible and current_description:
if (
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
visible_bottom = scroll_offset + rect.height
wrapped_lines = wrap_text(font, current_description, ITEM_DESC_FONT_SIZE, max_width)
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
end_idx = len(self._items)
def get_content_width(self, total_width: int) -> int:
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
for i, item in enumerate(self._items):
if item.rect and item.rect.y + item.rect.height >= visible_top:
start_idx = max(0, i - 1)
break
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
if not self.action_item:
return rl.Rectangle(0, 0, 0, 0)
# Find last visible item
for i in range(start_idx, len(self._items)):
item = self._items[i]
if item.rect and item.rect.y > visible_bottom:
end_idx = min(len(self._items), i + 2)
break
right_width = self.action_item.rect.width
if right_width == 0: # Full width action (like DualButtonAction)
return rl.Rectangle(item_rect.x + ITEM_PADDING, item_rect.y,
item_rect.width - (ITEM_PADDING * 2), ITEM_BASE_HEIGHT)
self._visible_range = (start_idx, end_idx)
def _handle_mouse_interaction(self, rect: rl.Rectangle, scroll_offset: rl.Vector2):
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()
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)
# Factory functions
@@ -363,18 +338,31 @@ def simple_item(title: str, callback: Callable | None = None) -> ListItem:
return ListItem(title=title, callback=callback)
def toggle_item(
title: str, description: str = None, initial_state: bool = False, callback: Callable | None = None, icon: str = ""
) -> ListItem:
toggle = ToggleRightItem(initial_state=initial_state)
return ListItem(title=title, description=description, right_item=toggle, icon=icon, callback=callback)
def toggle_item(title: str, description: str | Callable[[], str] | None = None, initial_state: bool = False,
callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True) -> ListItem:
action = ToggleAction(initial_state=initial_state, enabled=enabled)
return ListItem(title=title, description=description, action_item=action, icon=icon, callback=callback)
def button_item(title: str, button_text: str, description: str = None, callback: Callable | None = None) -> ListItem:
button = ButtonRightItem(text=button_text)
return ListItem(title=title, description=description, right_item=button, callback=callback)
def button_item(title: str, button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
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:
text_item = TextRightItem(text=value, color=rl.Color(170, 170, 170, 255))
return ListItem(title=title, description=description, right_item=text_item, callback=callback)
def text_item(title: str, value: str | Callable[[], str], description: str | Callable[[], str] | None = None,
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
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)

View File

@@ -1,11 +1,12 @@
import pyray as rl
from collections import deque
from enum import IntEnum
# Scroll constants for smooth scrolling behavior
MOUSE_WHEEL_SCROLL_SPEED = 30
INERTIA_FRICTION = 0.92 # The rate at which the inertia slows down
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_RETURN_SPEED = 0.15 # How quickly it returns from the bounce
MAX_BOUNCE_DISTANCE = 150 # Maximum distance for bounce effect
@@ -31,8 +32,7 @@ class GuiScrollPanel:
self._velocity_y = 0.0 # Velocity for inertia
self._is_dragging: bool = False
self._bounce_offset: float = 0.0
self._last_frame_time = rl.get_time()
self._velocity_history: list[float] = []
self._velocity_history: deque[float] = deque(maxlen=VELOCITY_HISTORY_SIZE)
self._last_drag_time: float = 0.0
self._content_rect: rl.Rectangle | None = None
self._bounds_rect: rl.Rectangle | None = None
@@ -44,11 +44,6 @@ class GuiScrollPanel:
# Calculate time delta
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()
max_scroll_y = max(content.height - bounds.height, 0)
@@ -63,13 +58,15 @@ class GuiScrollPanel:
if mouse_pos.x >= scrollbar_x:
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._start_mouse_y = mouse_pos.y
self._last_drag_time = current_time
self._velocity_history = []
self._velocity_history.clear()
self._velocity_y = 0.0
self._bounce_offset = 0.0
self._is_dragging = False
# Handle active dragging
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
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
# Detect actual dragging
@@ -175,13 +169,8 @@ class GuiScrollPanel:
return self._offset
def is_click_valid(self) -> bool:
# Check if this is a click rather than a drag
return (
self._scroll_state == ScrollState.IDLE
and not self._is_dragging
and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT)
)
def is_touch_valid(self):
return not self._is_dragging
def get_normalized_scroll_position(self) -> float:
"""Returns the current scroll position as a value from 0.0 to 1.0"""

74
system/ui/lib/scroller.py Normal file
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()

View File

@@ -1,13 +1,20 @@
import platform
import pyray as rl
import numpy as np
from typing import Any
MAX_GRADIENT_COLORS = 15
FRAGMENT_SHADER = """
VERSION = """
#version 300 es
precision mediump float;
precision highp float;
"""
if platform.system() == "Darwin":
VERSION = """
#version 330 core
"""
FRAGMENT_SHADER = VERSION + """
in vec2 fragTexCoord;
out vec4 finalColor;
@@ -105,14 +112,13 @@ void main() {
vec4 color = useGradient == 1 ? getGradientColor(pixel) : fillColor;
finalColor = vec4(color.rgb, color.a * alpha);
} else {
finalColor = vec4(0.0);
discard;
}
}
"""
# Default vertex shader
VERTEX_SHADER = """
#version 300 es
VERTEX_SHADER = VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
out vec2 fragTexCoord;
@@ -124,7 +130,6 @@ void main() {
}
"""
UNIFORM_INT = rl.ShaderUniformDataType.SHADER_UNIFORM_INT
UNIFORM_FLOAT = rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT
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]
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):
"""
Draw a complex polygon using shader-based even-odd fill rule

View File

@@ -1,4 +1,5 @@
import pyray as rl
from openpilot.system.ui.lib.widget import Widget
ON_COLOR = rl.Color(51, 171, 76, 255)
OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255)
@@ -11,24 +12,23 @@ BG_HEIGHT = 60
ANIMATION_SPEED = 8.0
class Toggle:
class Toggle(Widget):
def __init__(self, initial_state=False):
super().__init__()
self._state = initial_state
self._enabled = True
self._rect = rl.Rectangle(0, 0, WIDTH, HEIGHT)
self._progress = 1.0 if initial_state else 0.0
self._target = self._progress
def handle_input(self):
if not self._enabled:
return 0
def set_rect(self, rect: rl.Rectangle):
self._rect = rl.Rectangle(rect.x, rect.y, WIDTH, HEIGHT)
if rl.is_mouse_button_pressed(rl.MOUSE_LEFT_BUTTON):
if rl.check_collision_point_rec(rl.get_mouse_position(), self._rect):
self._state = not self._state
self._target = 1.0 if self._state else 0.0
return 1
return 0
def _handle_mouse_release(self, mouse_pos: rl.Vector2):
if not self._enabled:
return
self._state = not self._state
self._target = 1.0 if self._state else 0.0
def get_state(self):
return self._state
@@ -49,8 +49,7 @@ class Toggle:
self._progress += delta if self._progress < self._target else -delta
self._progress = max(0.0, min(1.0, self._progress))
def render(self, rect: rl.Rectangle):
self._rect.x, self._rect.y = rect.x, rect.y
def _render(self, rect: rl.Rectangle):
self.update()
if self._enabled:
@@ -69,7 +68,5 @@ class Toggle:
knob_y = self._rect.y + HEIGHT / 2
rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, knob_color)
return self.handle_input()
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)

96
system/ui/lib/widget.py Normal file
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

View File

@@ -13,6 +13,7 @@ from dbus_next.aio import MessageBus
from dbus_next import BusType, Variant, Message
from dbus_next.errors import DBusError
from dbus_next.constants import MessageType
try:
from openpilot.common.params import Params
except ImportError:
@@ -38,6 +39,7 @@ NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT = 8
TETHERING_IP_ADDRESS = "192.168.43.1"
DEFAULT_TETHERING_PASSWORD = "12345678"
# NetworkManager device states
class NMDeviceState(IntEnum):
DISCONNECTED = 30
@@ -46,6 +48,7 @@ class NMDeviceState(IntEnum):
IP_CONFIG = 70
ACTIVATED = 100
class SecurityType(IntEnum):
OPEN = 0
WPA = 1
@@ -53,6 +56,7 @@ class SecurityType(IntEnum):
WPA3 = 3
UNSUPPORTED = 4
@dataclass
class NetworkInfo:
ssid: str
@@ -227,7 +231,7 @@ class WifiManager:
except Exception as e:
self._current_connection_ssid = None
cloudlog.error(f"Error connecting to network: {e}")
# Notify UI of failure
# Notify UI of failure
if self.callbacks.connection_failed:
self.callbacks.connection_failed(ssid, str(e))

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