diff --git a/selfdrive/assets/fonts/process.py b/selfdrive/assets/fonts/process.py
index a0d01af14..ddc8b3a86 100755
--- a/selfdrive/assets/fonts/process.py
+++ b/selfdrive/assets/fonts/process.py
@@ -10,7 +10,7 @@ TRANSLATIONS_DIR = SELFDRIVE_DIR / "ui" / "translations"
LANGUAGES_FILE = TRANSLATIONS_DIR / "languages.json"
GLYPH_PADDING = 6
-EXTRA_CHARS = "–‑✓×°§•€£¥"
+EXTRA_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥"
UNIFONT_LANGUAGES = {"ar", "th", "zh-CHT", "zh-CHS", "ko", "ja"}
@@ -68,6 +68,10 @@ def _glyph_metrics(glyphs, rects, codepoints):
def _write_bmfont(path: Path, font_size: int, face: str, atlas_name: str, line_height: int, base: int, atlas_size, entries):
+ # TODO: why doesn't raylib calculate these metrics correctly?
+ if line_height != font_size:
+ print("using font size for line height", atlas_name)
+ line_height = font_size
lines = [
f"info face=\"{face}\" size=-{font_size} bold=0 italic=0 charset=\"\" unicode=1 stretchH=100 smooth=0 aa=1 padding=0,0,0,0 spacing=0,0 outline=0",
f"common lineHeight={line_height} base={base} scaleW={atlas_size[0]} scaleH={atlas_size[1]} pages=1 packed=0 alphaChnl=0 redChnl=4 greenChnl=4 blueChnl=4",
diff --git a/selfdrive/assets/icons/eyes_crossed.png b/selfdrive/assets/icons/eyes_crossed.png
new file mode 100644
index 000000000..af2122cd9
--- /dev/null
+++ b/selfdrive/assets/icons/eyes_crossed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4def42b5faffc6a8f747c210d24c3a1a8a7f82891738ff7f3317091e63326ba5
+size 1083
diff --git a/selfdrive/assets/icons/eyes_open.png b/selfdrive/assets/icons/eyes_open.png
new file mode 100644
index 000000000..ad9afc3a3
--- /dev/null
+++ b/selfdrive/assets/icons/eyes_open.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:52019b72834e478588114584820313af866d2d7a737591a166c413ccaab6acf5
+size 931
diff --git a/selfdrive/assets/icons_mici/buttons/button_circle.png b/selfdrive/assets/icons_mici/buttons/button_circle.png
new file mode 100644
index 000000000..b6f4cc9d1
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_circle.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f92e5f0b7fc50c3b64bd18ecee8a8d518017b5461104de76dee6feb0f4f0d70d
+size 7496
diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_disabled.png b/selfdrive/assets/icons_mici/buttons/button_circle_disabled.png
new file mode 100644
index 000000000..d2104df4e
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_circle_disabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:947aa3beb7eff6afb44101daf0aeaae7b7f31961c273df00eec0ca8359233c56
+size 5175
diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_hover.png b/selfdrive/assets/icons_mici/buttons/button_circle_hover.png
new file mode 100644
index 000000000..5cae15210
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_circle_hover.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:20024203288f144633014422e16119278477099f24fba5c155a804a1864a26b4
+size 7511
diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_pressed.png b/selfdrive/assets/icons_mici/buttons/button_circle_pressed.png
new file mode 100644
index 000000000..45027a372
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_circle_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d378fdbb9d683d5c94536ebf9c466146721b1f65859eb38667d5c2e9589e54c3
+size 12590
diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_red.png b/selfdrive/assets/icons_mici/buttons/button_circle_red.png
new file mode 100644
index 000000000..68ae400b1
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_circle_red.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b48d8a191979f27dae8a336f99d944008e2536f698c58cffa5f3dddc17429b45
+size 11451
diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_red_hover.png b/selfdrive/assets/icons_mici/buttons/button_circle_red_hover.png
new file mode 100644
index 000000000..3696334d5
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_circle_red_hover.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:279c1d8f95eb9f4a3058dff76b0f316ce9eef7bc8f4296936ad25fd08703ce13
+size 10380
diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_red_pressed.png b/selfdrive/assets/icons_mici/buttons/button_circle_red_pressed.png
new file mode 100644
index 000000000..08b2e318d
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_circle_red_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:19d53ff0cb49ffc43507e5bac11583bc9b3037c2e2ed5e12b96bfd97d71e397f
+size 26113
diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle.png b/selfdrive/assets/icons_mici/buttons/button_rectangle.png
new file mode 100644
index 000000000..230c537d6
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_rectangle.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ffb293236f5f8f7da44b5a3c4c0b72e86c4e1fdb04f89c94507af008ff7de139
+size 8210
diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle_disabled.png b/selfdrive/assets/icons_mici/buttons/button_rectangle_disabled.png
new file mode 100644
index 000000000..76e75d542
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_rectangle_disabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bda53863c9a46c50a1e2920a76c2d2f1fe4df8a94b8d2e26f5d83eef3a9c3bd3
+size 3627
diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle_hover.png b/selfdrive/assets/icons_mici/buttons/button_rectangle_hover.png
new file mode 100644
index 000000000..a9fd28cc3
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_rectangle_hover.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6b55e43c50e805ac5e8357e5943374ed02d756cefa3aaffb58c568a0b125c30b
+size 7750
diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle_pressed.png b/selfdrive/assets/icons_mici/buttons/button_rectangle_pressed.png
new file mode 100644
index 000000000..779c219fc
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_rectangle_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5528e9c041b824f005bf1ef6e49b2dbbc4ba10f994b0726d2a17a4fbf8c80f55
+size 21379
diff --git a/selfdrive/assets/icons_mici/buttons/button_side_back.png b/selfdrive/assets/icons_mici/buttons/button_side_back.png
new file mode 100644
index 000000000..3d648d34f
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_side_back.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9df44871e9f5fa910622b0b92205b92a54d137dbdc3827b92e8622d85ff2e08e
+size 5189
diff --git a/selfdrive/assets/icons_mici/buttons/button_side_back_pressed.png b/selfdrive/assets/icons_mici/buttons/button_side_back_pressed.png
new file mode 100644
index 000000000..e431cb0c7
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_side_back_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:013b368b38b17d9b2ef6aaf0f498f672deed95888084b7287f42bdfba617cbb6
+size 10142
diff --git a/selfdrive/assets/icons_mici/buttons/button_side_check.png b/selfdrive/assets/icons_mici/buttons/button_side_check.png
new file mode 100644
index 000000000..820b23606
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_side_check.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8fd563eec78d5ce4a8204c2f596789e1090cb3e26a35b4ffeacee4ab61968538
+size 8303
diff --git a/selfdrive/assets/icons_mici/buttons/button_side_check_pressed.png b/selfdrive/assets/icons_mici/buttons/button_side_check_pressed.png
new file mode 100644
index 000000000..6c38508af
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_side_check_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0be8d5eddcd9f87acbf1daccf446be6218522120f64aee1ee0a3c0b31560f076
+size 15761
diff --git a/selfdrive/assets/icons_mici/buttons/button_side_home.png b/selfdrive/assets/icons_mici/buttons/button_side_home.png
new file mode 100644
index 000000000..99c5ea650
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_side_home.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3ec30f6ba49e7a7bc89e8369800ad71c8b57950bbf6b3f169fa944626a3ded59
+size 5258
diff --git a/selfdrive/assets/icons_mici/buttons/slider_bg.png b/selfdrive/assets/icons_mici/buttons/slider_bg.png
new file mode 100644
index 000000000..9164f74ba
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/slider_bg.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1ca620a05e9e69351b9bbcfcf021dae11fde26be50d7f1a39257d319f6303616
+size 9779
diff --git a/selfdrive/assets/icons_mici/buttons/toggle_dot_disabled.png b/selfdrive/assets/icons_mici/buttons/toggle_dot_disabled.png
new file mode 100644
index 000000000..0e21bc1b5
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/toggle_dot_disabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:613af9ed79bb26c60fbd19c094214f0881736c0e293f6d000b530cde0478a273
+size 2470
diff --git a/selfdrive/assets/icons_mici/buttons/toggle_dot_enabled.png b/selfdrive/assets/icons_mici/buttons/toggle_dot_enabled.png
new file mode 100644
index 000000000..5bb4d778f
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/toggle_dot_enabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:532bf0e8535e3f9bc13af13029a27d6c14ae788d52224b6c65623334f62fada0
+size 6048
diff --git a/selfdrive/assets/icons_mici/buttons/toggle_dot_orange.png b/selfdrive/assets/icons_mici/buttons/toggle_dot_orange.png
new file mode 100644
index 000000000..bf8559ec8
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/toggle_dot_orange.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1ca418dab8eab77569e3cc446deccdc5b468d79159711e6629d704eb531009d9
+size 6191
diff --git a/selfdrive/assets/icons_mici/buttons/toggle_pill_disabled.png b/selfdrive/assets/icons_mici/buttons/toggle_pill_disabled.png
new file mode 100644
index 000000000..555c16e09
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/toggle_pill_disabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7891a628bd9cedc1097114e89fcec4a50a88021c7d6c63f1329d087be9e1783e
+size 3065
diff --git a/selfdrive/assets/icons_mici/buttons/toggle_pill_enabled.png b/selfdrive/assets/icons_mici/buttons/toggle_pill_enabled.png
new file mode 100644
index 000000000..d95039da9
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/toggle_pill_enabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ad31da78544edd18d0ac154670f22d1cd1ac57f50576002f04701d22c59502a8
+size 8257
diff --git a/selfdrive/assets/icons_mici/exclamation_point.png b/selfdrive/assets/icons_mici/exclamation_point.png
new file mode 100644
index 000000000..246fc015e
--- /dev/null
+++ b/selfdrive/assets/icons_mici/exclamation_point.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b77579c099c688d1a27f356197fba9c2c8efcf4d391af580b4b29f0e70587919
+size 2086
diff --git a/selfdrive/assets/icons_mici/experimental_mode.png b/selfdrive/assets/icons_mici/experimental_mode.png
new file mode 100644
index 000000000..e0138bfd6
--- /dev/null
+++ b/selfdrive/assets/icons_mici/experimental_mode.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:eb42b8d6259238beb26f286dc28fb2dc8d91b00fec1f7a7655296b5769439a15
+size 15690
diff --git a/selfdrive/assets/icons_mici/eye.png b/selfdrive/assets/icons_mici/eye.png
new file mode 100644
index 000000000..db2953b69
--- /dev/null
+++ b/selfdrive/assets/icons_mici/eye.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c9c7f03c2784eac6882eb59d5f1c547008c12f19a861d04e9ca3549edb41218c
+size 1988
diff --git a/selfdrive/assets/icons_mici/eye_crossed.png b/selfdrive/assets/icons_mici/eye_crossed.png
new file mode 100644
index 000000000..11197fcf5
--- /dev/null
+++ b/selfdrive/assets/icons_mici/eye_crossed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5f54bdb8dbb94682ff474174b8e3c605dba867f1b6613a3efcc5fbbedc462a95
+size 1811
diff --git a/selfdrive/assets/icons_mici/microphone.png b/selfdrive/assets/icons_mici/microphone.png
new file mode 100644
index 000000000..9718a6b13
--- /dev/null
+++ b/selfdrive/assets/icons_mici/microphone.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:17b6fe530598cbad34bcf31d4f21f929b792aacedef51b3ffef1941c86017811
+size 7331
diff --git a/selfdrive/assets/icons_mici/notifications/blue_large.png b/selfdrive/assets/icons_mici/notifications/blue_large.png
new file mode 100644
index 000000000..e4aa33a13
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/blue_large.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2566b38173e6048f54ef62dd68a158b07747638f74f2724406cfef02ae38022b
+size 14052
diff --git a/selfdrive/assets/icons_mici/notifications/blue_small.png b/selfdrive/assets/icons_mici/notifications/blue_small.png
new file mode 100644
index 000000000..500f48e36
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/blue_small.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7020a7ec5a1a31320a0cc2a381a79ed0d650bc220a9c60ae334c646868e12295
+size 10662
diff --git a/selfdrive/assets/icons_mici/notifications/icons/cable.png b/selfdrive/assets/icons_mici/notifications/icons/cable.png
new file mode 100644
index 000000000..72b64f073
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/icons/cable.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:028bb5611f41b0da7944f6bed42962cd894a57706a9940f124de7488a61b9b60
+size 1171
diff --git a/selfdrive/assets/icons_mici/notifications/icons/camera.png b/selfdrive/assets/icons_mici/notifications/icons/camera.png
new file mode 100644
index 000000000..fbe70ee82
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/icons/camera.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8667594a61ed4680b55ef981085cb84db7d1c86dd28ed998a7e8441f03b09193
+size 2000
diff --git a/selfdrive/assets/icons_mici/notifications/icons/close.png b/selfdrive/assets/icons_mici/notifications/icons/close.png
new file mode 100644
index 000000000..e0f061a01
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/icons/close.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:064cf235bfc30a08863a29ec7f94aa1f8cf7b6b68ee5eaad0a0c48740e9a0ce1
+size 2371
diff --git a/selfdrive/assets/icons_mici/notifications/icons/critical.png b/selfdrive/assets/icons_mici/notifications/icons/critical.png
new file mode 100644
index 000000000..8acab1854
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/icons/critical.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:77d7945ba3af1d0ecb4c52600fcfee80f9f8069f34dea4bce95fd0495ac6f80c
+size 2596
diff --git a/selfdrive/assets/icons_mici/notifications/icons/eye.png b/selfdrive/assets/icons_mici/notifications/icons/eye.png
new file mode 100644
index 000000000..11197fcf5
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/icons/eye.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5f54bdb8dbb94682ff474174b8e3c605dba867f1b6613a3efcc5fbbedc462a95
+size 1811
diff --git a/selfdrive/assets/icons_mici/notifications/icons/fan.png b/selfdrive/assets/icons_mici/notifications/icons/fan.png
new file mode 100644
index 000000000..017da6c8c
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/icons/fan.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:40ab18b92dda78353031c5df7b57db64a4a7f99820f696c4a4efe92cfa690109
+size 2465
diff --git a/selfdrive/assets/icons_mici/notifications/icons/temperature.png b/selfdrive/assets/icons_mici/notifications/icons/temperature.png
new file mode 100644
index 000000000..09d0d798d
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/icons/temperature.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3b76a778921377508da133ad105247b238244fae86f9c0791f3ad05dd69802a6
+size 1975
diff --git a/selfdrive/assets/icons_mici/notifications/icons/wheel.png b/selfdrive/assets/icons_mici/notifications/icons/wheel.png
new file mode 100644
index 000000000..ec4741f7f
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/icons/wheel.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:23424ed80a70ebfedb99f14e286a08dcf59167988683b81fa8a56b06e9b2051e
+size 2638
diff --git a/selfdrive/assets/icons_mici/notifications/normal_large.png b/selfdrive/assets/icons_mici/notifications/normal_large.png
new file mode 100644
index 000000000..df2598403
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/normal_large.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b6aa200bbb3381eb0d6a72c412227cd99c8bde5e843f705b218c09ee98576804
+size 9796
diff --git a/selfdrive/assets/icons_mici/notifications/normal_small.png b/selfdrive/assets/icons_mici/notifications/normal_small.png
new file mode 100644
index 000000000..f96de2e30
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/normal_small.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0486257b2e7735ad68c2bde66eb7bc710862c989feef856f06b0bf5d5231e7db
+size 7369
diff --git a/selfdrive/assets/icons_mici/notifications/orange_large.png b/selfdrive/assets/icons_mici/notifications/orange_large.png
new file mode 100644
index 000000000..62f6dac63
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/orange_large.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:32e3d36a95215cff4fdbe4af01c86408e647ff57dcc8724b517fda453b4b01e0
+size 15069
diff --git a/selfdrive/assets/icons_mici/notifications/orange_small.png b/selfdrive/assets/icons_mici/notifications/orange_small.png
new file mode 100644
index 000000000..31fcf11a6
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/orange_small.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0f754f98cd3f7104f3567bf02c4972277a8f5740757e245a2b3a60f9d1f61506
+size 11308
diff --git a/selfdrive/assets/icons_mici/notifications/red_large.png b/selfdrive/assets/icons_mici/notifications/red_large.png
new file mode 100644
index 000000000..81cd5566f
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/red_large.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7f820799a20667a265d5ed9e0ebc6a46a6e898935f2fa920b66d001347b88704
+size 13410
diff --git a/selfdrive/assets/icons_mici/notifications/red_small.png b/selfdrive/assets/icons_mici/notifications/red_small.png
new file mode 100644
index 000000000..00b358c81
--- /dev/null
+++ b/selfdrive/assets/icons_mici/notifications/red_small.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0fb44ce698c02929ca0c4360cc493ad18a99bd8f5d75586a422961e6f17d1371
+size 10169
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/big_alert.png b/selfdrive/assets/icons_mici/offroad_alerts/big_alert.png
new file mode 100644
index 000000000..142367d0e
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/big_alert.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:aeee7f049879caff52320fab5f286cf3fd6a52c820cd8e150ff242e53a14176f
+size 14774
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/big_alert_pressed.png b/selfdrive/assets/icons_mici/offroad_alerts/big_alert_pressed.png
new file mode 100644
index 000000000..2ff01024d
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/big_alert_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0b59ddada9c9e0e7972ead27396ebe6c10fd2352687b18ff6b476f61f74d80bf
+size 46106
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/green_wheel.png b/selfdrive/assets/icons_mici/offroad_alerts/green_wheel.png
new file mode 100644
index 000000000..6a8351f6e
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/green_wheel.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:05f3626e790622a4ad90e982c4aacb612d0785a752339352a3187addf763e2e9
+size 13288
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/medium_alert.png b/selfdrive/assets/icons_mici/offroad_alerts/medium_alert.png
new file mode 100644
index 000000000..91a7b43c5
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/medium_alert.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:46d08a8a08b42d466ff45d8ad6d2578e345dcbb1c06c126ad361873d9d35eaec
+size 12877
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/medium_alert_pressed.png b/selfdrive/assets/icons_mici/offroad_alerts/medium_alert_pressed.png
new file mode 100644
index 000000000..09ca2d08d
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/medium_alert_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:42ea275e5fe0a8a0e2fddb5a4a8487806fb22850115ea3646ee8f213d5fd6bb6
+size 37202
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/orange_warning.png b/selfdrive/assets/icons_mici/offroad_alerts/orange_warning.png
new file mode 100644
index 000000000..13af475c6
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/orange_warning.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a877882a8dccb884bd35918f9f9b427a724a59e90a638e54f6fd5d0680ad173c
+size 12137
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/red_warning.png b/selfdrive/assets/icons_mici/offroad_alerts/red_warning.png
new file mode 100644
index 000000000..83c3595b2
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/red_warning.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ba944b208abed9b8b9752adb8017bd29cd2e98c89fb07ee5d0a595185c7564a5
+size 11898
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/small_alert.png b/selfdrive/assets/icons_mici/offroad_alerts/small_alert.png
new file mode 100644
index 000000000..0a50b6a1c
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/small_alert.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cfbf38672a893fd1d8fadf942354d2511e2436814a9af0e5c188cbb427fc6c70
+size 12281
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/small_alert_pressed.png b/selfdrive/assets/icons_mici/offroad_alerts/small_alert_pressed.png
new file mode 100644
index 000000000..865355ef0
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/small_alert_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a7102850bcfb075a285041cecb546559374a905403ab3b9814fa6097d2d822dd
+size 34680
diff --git a/selfdrive/assets/icons_mici/onroad/blind_spot_left.png b/selfdrive/assets/icons_mici/onroad/blind_spot_left.png
new file mode 100644
index 000000000..5d3b1e5d7
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/blind_spot_left.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a23743d21bc8160e013625210654a55634e4ed58e60057b70e08761bac1c3680
+size 40406
diff --git a/selfdrive/assets/icons_mici/onroad/blind_spot_right.png b/selfdrive/assets/icons_mici/onroad/blind_spot_right.png
new file mode 100644
index 000000000..67216078d
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/blind_spot_right.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:acbfa3e38f0b9f422f5c1335ce20013852df2892b813db176a51918adc83ad58
+size 40979
diff --git a/selfdrive/assets/icons_mici/onroad/bookmark.png b/selfdrive/assets/icons_mici/onroad/bookmark.png
new file mode 100644
index 000000000..207182276
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/bookmark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e0d00d743b01c49c2b739127e9916a229caf8c48346d6d168863b080ddcaa409
+size 11124
diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_background.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_background.png
new file mode 100644
index 000000000..4d83ed5cd
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_background.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f27352a18194a1c819e9eaea89cfc11d2964402df0a28efa3ba60ae2d972fe67
+size 13108
diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_center.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_center.png
new file mode 100644
index 000000000..a8a68b372
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_center.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b5aee9f6cec03f1967014cd2ea2a23982b262e7d86dadca602ecfa8875b38101
+size 5875
diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_cone.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_cone.png
new file mode 100644
index 000000000..ec2f94899
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_cone.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:26b3660dbd1e60b0ba98914afa7cb3a67151bb6990d218f55c901f243e38ff3e
+size 3631
diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_person.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_person.png
new file mode 100644
index 000000000..7aa7f0542
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_person.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:25d66e42a28a3367eb40724d28652889089aa762438b475645269e0319c46009
+size 1431
diff --git a/selfdrive/assets/icons_mici/onroad/eye_fill.png b/selfdrive/assets/icons_mici/onroad/eye_fill.png
new file mode 100644
index 000000000..8f0e8ebfb
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/eye_fill.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:51af75afbaf30abeaae1c99c7ad3e25cf5d5c90a2d6c799aad353b3302384b0a
+size 4829
diff --git a/selfdrive/assets/icons_mici/onroad/eye_orange.png b/selfdrive/assets/icons_mici/onroad/eye_orange.png
new file mode 100644
index 000000000..b61b9b063
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/eye_orange.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:88b2ecf3a9834d2b156bb632ec2090d7dc112e8ab61711ba645c03489d1c457f
+size 29157
diff --git a/selfdrive/assets/icons_mici/onroad/glasses.png b/selfdrive/assets/icons_mici/onroad/glasses.png
new file mode 100644
index 000000000..1ac4442f4
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/glasses.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:28c95c8970648d40b35b94724936a9ab7a6f4cbca367a40f01b86f9abedc70e5
+size 1587
diff --git a/selfdrive/assets/icons_mici/onroad/onroad_fade.png b/selfdrive/assets/icons_mici/onroad/onroad_fade.png
new file mode 100644
index 000000000..bc12e57e1
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/onroad_fade.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d2a2cb4db429467783d7f721ffbed7838551e4aabf32771e73759c87b4a67bca
+size 28880
diff --git a/selfdrive/assets/icons_mici/onroad/sunglasses.png b/selfdrive/assets/icons_mici/onroad/sunglasses.png
new file mode 100644
index 000000000..15e502d61
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/sunglasses.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4b520b8a00ca245f1dcccca4ddbf1b1b6f8da9fb8b6ac9ea351e735db61641e6
+size 1006
diff --git a/selfdrive/assets/icons_mici/onroad/turn_signal_left.png b/selfdrive/assets/icons_mici/onroad/turn_signal_left.png
new file mode 100644
index 000000000..48f52ff9c
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/turn_signal_left.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0e845a211cf5d03f781efdd6eec4f8106e8dd85799ea59b51834a9099b479141
+size 30348
diff --git a/selfdrive/assets/icons_mici/onroad/turn_signal_right.png b/selfdrive/assets/icons_mici/onroad/turn_signal_right.png
new file mode 100644
index 000000000..87ca979fb
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/turn_signal_right.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:009005539f14acc29a4f5510b4e9531d2ba3667133644f6e0069c12b08ba0fd9
+size 35370
diff --git a/selfdrive/assets/icons_mici/settings.png b/selfdrive/assets/icons_mici/settings.png
new file mode 100644
index 000000000..e668ed1fe
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:38a52171bdc6feb3ddfd2d9f9e59db3dabd09fa0aafbc9f81137c59bd03b7c26
+size 2321
diff --git a/selfdrive/assets/icons_mici/settings/comma_icon.png b/selfdrive/assets/icons_mici/settings/comma_icon.png
new file mode 100644
index 000000000..72a7c8c8f
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/comma_icon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:10f469a6f5d25d9e2b0b1aae51b4fbd06d2c7b8417613bb321c2a30bb7298dab
+size 1392
diff --git a/selfdrive/assets/icons_mici/settings/developer/adb.png b/selfdrive/assets/icons_mici/settings/developer/adb.png
new file mode 100644
index 000000000..b3a780146
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/developer/adb.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:19b304727376ea30126e7aeb10d1193885ffa661ca5bdf4c09098e4412d2ab6a
+size 2163
diff --git a/selfdrive/assets/icons_mici/settings/developer/debug_mode.png b/selfdrive/assets/icons_mici/settings/developer/debug_mode.png
new file mode 100644
index 000000000..2849d2733
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/developer/debug_mode.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8bbdcedeb5f6f5844a8f67751f5d47793fe5af5e25ac6e7fd1ffc5857a85d56e
+size 2997
diff --git a/selfdrive/assets/icons_mici/settings/developer/ssh.png b/selfdrive/assets/icons_mici/settings/developer/ssh.png
new file mode 100644
index 000000000..cd86937ae
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/developer/ssh.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c655994336b7da4ca986c6f27494bcab66e77f016ec9db8df271de53ed93e517
+size 1328
diff --git a/selfdrive/assets/icons_mici/settings/developer_icon.png b/selfdrive/assets/icons_mici/settings/developer_icon.png
new file mode 100644
index 000000000..af16c0291
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/developer_icon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a1f058c5640bd763d2f6927432a1daff1587770ea0d06f2e351a28462e9d8335
+size 1743
diff --git a/selfdrive/assets/icons_mici/settings/device/cameras.png b/selfdrive/assets/icons_mici/settings/device/cameras.png
new file mode 100644
index 000000000..c44c51127
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/cameras.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:77a1281979f0b50f0e109ead56a88a33b81ef5901dd1a4537eb3fa048e0d90de
+size 1345
diff --git a/selfdrive/assets/icons_mici/settings/device/cancel.png b/selfdrive/assets/icons_mici/settings/device/cancel.png
new file mode 100644
index 000000000..6da29ad66
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/cancel.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:be9669cff5fc8a0b587dee27d1afb1caa77ceff2f92ca0ce3f01d25659e96596
+size 1332
diff --git a/selfdrive/assets/icons_mici/settings/device/downloading.png b/selfdrive/assets/icons_mici/settings/device/downloading.png
new file mode 100644
index 000000000..2db585698
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/downloading.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a355a3960aea41b176be66d85a3e7bc83ec843bfb1e5bd4c7978f4ea41c5d1a2
+size 2756
diff --git a/selfdrive/assets/icons_mici/settings/device/fcc_logo.png b/selfdrive/assets/icons_mici/settings/device/fcc_logo.png
new file mode 100644
index 000000000..f29b24fd0
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/fcc_logo.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3cac8546d19e75a9edcbc0721a887fd74c8a3c41bfe19e36186b2b2bcabdae98
+size 1817
diff --git a/selfdrive/assets/icons_mici/settings/device/info.png b/selfdrive/assets/icons_mici/settings/device/info.png
new file mode 100644
index 000000000..cb1632069
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/info.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2649d36259700d32a0edef878647e76492b1bec2fe34ac8ea806d4e7e4c57855
+size 2668
diff --git a/selfdrive/assets/icons_mici/settings/device/language.png b/selfdrive/assets/icons_mici/settings/device/language.png
new file mode 100644
index 000000000..f6d57b313
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/language.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4b982ac1b78b45487490d1dbbffed1f68735f6a35def502e882f706c30683aff
+size 3664
diff --git a/selfdrive/assets/icons_mici/settings/device/lkas.png b/selfdrive/assets/icons_mici/settings/device/lkas.png
new file mode 100644
index 000000000..186ea78fb
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/lkas.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ab6aeb6cba94acf948a0ad64a485db00bf1f3de1360ae4c57212f3f083b2bd24
+size 2554
diff --git a/selfdrive/assets/icons_mici/settings/device/pair.png b/selfdrive/assets/icons_mici/settings/device/pair.png
new file mode 100644
index 000000000..f072b2363
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/pair.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ed671f4ad1523f0e66498af39e6075a0c19842ae05eddd00871a6e48ed3685d7
+size 1594
diff --git a/selfdrive/assets/icons_mici/settings/device/power.png b/selfdrive/assets/icons_mici/settings/device/power.png
new file mode 100644
index 000000000..a2de14a4e
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/power.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5b45645ad9ff27776fdb1caa27827c526cae57f8bd4e23bd1160cb0094121ff2
+size 2338
diff --git a/selfdrive/assets/icons_mici/settings/device/reboot.png b/selfdrive/assets/icons_mici/settings/device/reboot.png
new file mode 100644
index 000000000..6c89cd9fc
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/reboot.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f24039f82d7399d02a155022de65b6dc3b8edcf17059a73a9fd3a9209e3f5575
+size 2360
diff --git a/selfdrive/assets/icons_mici/settings/device/uninstall.png b/selfdrive/assets/icons_mici/settings/device/uninstall.png
new file mode 100644
index 000000000..f9173711e
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/uninstall.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:558ea538fb258079f9eb05fe048b2806c7635b9f0452af874b00cb8d79b45f9b
+size 2421
diff --git a/selfdrive/assets/icons_mici/settings/device/up_to_date.png b/selfdrive/assets/icons_mici/settings/device/up_to_date.png
new file mode 100644
index 000000000..ee925458d
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/up_to_date.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4510e65775c6001758ebcf4dc13e9fa561cce5159d1fd54fbb506f22d3c7bdf3
+size 3149
diff --git a/selfdrive/assets/icons_mici/settings/device/update.png b/selfdrive/assets/icons_mici/settings/device/update.png
new file mode 100644
index 000000000..cc05931b0
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/update.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c6137349218ea22adba44f46a096afe2efc35536b2251192ed0ea61be443a3c5
+size 2493
diff --git a/selfdrive/assets/icons_mici/settings/device_icon.png b/selfdrive/assets/icons_mici/settings/device_icon.png
new file mode 100644
index 000000000..0caf0d07c
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device_icon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:db20bea98259b204be634ce0d9a23fbfdcfc73a324fc0aac0f9ac54e1c51556d
+size 2443
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/back.png b/selfdrive/assets/icons_mici/settings/keyboard/back.png
new file mode 100644
index 000000000..fccc2484d
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/back.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:de48d508af786b39fb725022b179e31456f32551a49b96ae07b5f55bfb968699
+size 1814
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/backspace.png b/selfdrive/assets/icons_mici/settings/keyboard/backspace.png
new file mode 100644
index 000000000..342f8e28d
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/backspace.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:116bbbd1509e6644f7b65b8dacd2402b0918785bd80207504a99ab7e13ab738f
+size 2049
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/caps_lock.png b/selfdrive/assets/icons_mici/settings/keyboard/caps_lock.png
new file mode 100644
index 000000000..d63cc56fb
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/caps_lock.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3e8c7fec57640de6bfa8d0ede977e40920a8e651b68ed14e3d6c1850e702f3e3
+size 1399
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/caps_lower.png b/selfdrive/assets/icons_mici/settings/keyboard/caps_lower.png
new file mode 100644
index 000000000..eb3893430
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/caps_lower.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b7dab3af28938e9c3ad7b6c3b60526bb76498b0103c7276d90c4bff3622f07d0
+size 1157
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/caps_upper.png b/selfdrive/assets/icons_mici/settings/keyboard/caps_upper.png
new file mode 100644
index 000000000..4a2cae6c8
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/caps_upper.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0c5a88a0e8e810115b6d497d3e230d866bd96a715ddac632f48c78b40e1df702
+size 1059
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/confirm.png b/selfdrive/assets/icons_mici/settings/keyboard/confirm.png
new file mode 100644
index 000000000..09b180e97
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/confirm.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:32ce109a9fe4814bb9bed88f67d85292791f4a6d7c162e07561920221ac38b2d
+size 1411
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/keyboard_background.png b/selfdrive/assets/icons_mici/settings/keyboard/keyboard_background.png
new file mode 100644
index 000000000..8c2c068d4
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/keyboard_background.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:399e7ff9dea6710244827c91014f1a08d8ae989dce922928d6b7f7504b15ba79
+size 11321
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/space.png b/selfdrive/assets/icons_mici/settings/keyboard/space.png
new file mode 100644
index 000000000..778d1847d
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/space.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9b04d17f3b0340a94210efa5c9547e0ac340dd6b6dd9ac1f81ba5eb3f89f405d
+size 619
diff --git a/selfdrive/assets/icons_mici/settings/manual_icon.png b/selfdrive/assets/icons_mici/settings/manual_icon.png
new file mode 100644
index 000000000..100b29da4
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/manual_icon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:957330e9fbc8c03f05dbef8097178a40efc0fc52a6faf7a9917f97046d9a5e99
+size 1559
diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_full.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_full.png
new file mode 100644
index 000000000..4bf0cd872
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_full.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6a981d5c5558859b283cb6321c84eec947f82fc2dea8dbdd19b66781e4d3f61f
+size 1060
diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_high.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_high.png
new file mode 100644
index 000000000..df6d00933
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_high.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:58da16ede432cf89096c11dc0f4ea098735863fb09a1d655cb06de8a112bd263
+size 1205
diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_low.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_low.png
new file mode 100644
index 000000000..c3323a9fe
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_low.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:031bbd50c34d8fd5e71bdc292ba3e50b28a13c56a48dc84117723f1b35b42f51
+size 1224
diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_medium.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_medium.png
new file mode 100644
index 000000000..64ab947c5
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_medium.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ccb5f2227c72dd28e40c9f19965abe007cbd7b47cdca924907dc9fad906f5c81
+size 1219
diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_none.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_none.png
new file mode 100644
index 000000000..6cdef706b
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_none.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:92c195721fe2b4ca42176077bf4ca3484cdfc314e961f1431b2296476bcae891
+size 1178
diff --git a/selfdrive/assets/icons_mici/settings/network/connect.png b/selfdrive/assets/icons_mici/settings/network/connect.png
new file mode 100644
index 000000000..f7beaf392
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/connect.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e47a5cb8b9ec784b6b893463622b7967a3692979b8a5e46e3334a09f745f1f71
+size 5413
diff --git a/selfdrive/assets/icons_mici/settings/network/connect_disabled.png b/selfdrive/assets/icons_mici/settings/network/connect_disabled.png
new file mode 100644
index 000000000..0563668be
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/connect_disabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:05ddc9456627c0773dddcb10469e78124f9788381e63cab163ce4c6407a000f4
+size 3201
diff --git a/selfdrive/assets/icons_mici/settings/network/connect_pressed.png b/selfdrive/assets/icons_mici/settings/network/connect_pressed.png
new file mode 100644
index 000000000..aef242b6d
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/connect_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cab1783614421ab86467c3686f4d11f439d6de2cce3a84801dec7e044c08c880
+size 9075
diff --git a/selfdrive/assets/icons_mici/settings/network/forget_pill.png b/selfdrive/assets/icons_mici/settings/network/forget_pill.png
new file mode 100644
index 000000000..a80de0763
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/forget_pill.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2645cc56688a6225ba7ba0b4021af6543657942f31892a4986061a2959511054
+size 12106
diff --git a/selfdrive/assets/icons_mici/settings/network/forget_pill_pressed.png b/selfdrive/assets/icons_mici/settings/network/forget_pill_pressed.png
new file mode 100644
index 000000000..a1240481e
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/forget_pill_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:42cea7a190be02027a1cc8d6daca435f4edfb5b9484c26a06e667a2346c91f0f
+size 38471
diff --git a/selfdrive/assets/icons_mici/settings/network/new/connect_button.png b/selfdrive/assets/icons_mici/settings/network/new/connect_button.png
new file mode 100644
index 000000000..eae5af77f
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/connect_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:04236fa0f2759a01c6e321ac7b1c86c7a039215a7953b1a23d250ecf2ef1fa87
+size 8563
diff --git a/selfdrive/assets/icons_mici/settings/network/new/connect_button_pressed.png b/selfdrive/assets/icons_mici/settings/network/new/connect_button_pressed.png
new file mode 100644
index 000000000..0da6c384d
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/connect_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4337098554af30c98ebd512e17ab08207db868ff34acca5f865fcbfc940286d3
+size 21123
diff --git a/selfdrive/assets/icons_mici/settings/network/new/forget_button.png b/selfdrive/assets/icons_mici/settings/network/new/forget_button.png
new file mode 100644
index 000000000..541433be7
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/forget_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6ccb5f2298389ae36df87de84d85440ee5a82c50e803c9bd362c9b89ea45aa69
+size 6611
diff --git a/selfdrive/assets/icons_mici/settings/network/new/forget_button_pressed.png b/selfdrive/assets/icons_mici/settings/network/new/forget_button_pressed.png
new file mode 100644
index 000000000..26cc8b4fc
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/forget_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d9f17c82b2f349d107d27c69418f054be1f1753f970c7d3d3520c1e65de00511
+size 12894
diff --git a/selfdrive/assets/icons_mici/settings/network/new/full_connect_button.png b/selfdrive/assets/icons_mici/settings/network/new/full_connect_button.png
new file mode 100644
index 000000000..905170fd1
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/full_connect_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ffd37d5e5d5980efa98fee1cd0e8ebbf4139149b41c099e7dc3d5bd402cffb92
+size 9072
diff --git a/selfdrive/assets/icons_mici/settings/network/new/full_connect_button_pressed.png b/selfdrive/assets/icons_mici/settings/network/new/full_connect_button_pressed.png
new file mode 100644
index 000000000..88eb4ac2a
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/full_connect_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1b1d58704f8808dcb5a7ce9d86bc4212477759e96ac2419475f16f9184ee6a42
+size 21892
diff --git a/selfdrive/assets/icons_mici/settings/network/new/lock.png b/selfdrive/assets/icons_mici/settings/network/new/lock.png
new file mode 100644
index 000000000..0a0b18c7a
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/lock.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:40dbbb3000e1137ec11fe658fbfebae7cadfc91356953317335f9bb70fcb40d3
+size 1235
diff --git a/selfdrive/assets/icons_mici/settings/network/new/trash.png b/selfdrive/assets/icons_mici/settings/network/new/trash.png
new file mode 100644
index 000000000..99e1a2e24
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/trash.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:efabf98ed66fe4447c0f13c74aec681b084de780c551ce18258c79636d4123c5
+size 1524
diff --git a/selfdrive/assets/icons_mici/settings/network/new/wifi_selected.png b/selfdrive/assets/icons_mici/settings/network/new/wifi_selected.png
new file mode 100644
index 000000000..2a3e83713
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/wifi_selected.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:160f67162e075436200d6719e614ddf96caaa2b7c0a3943f728c2afef10aa4ad
+size 2489
diff --git a/selfdrive/assets/icons_mici/settings/network/tethering.png b/selfdrive/assets/icons_mici/settings/network/tethering.png
new file mode 100644
index 000000000..9e7b90be4
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/tethering.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2907ce46d1b6e676402f390c530955b65e76baf0b77fafc0616c50b988b3994c
+size 1609
diff --git a/selfdrive/assets/icons_mici/settings/network/trash.png b/selfdrive/assets/icons_mici/settings/network/trash.png
new file mode 100644
index 000000000..99e1a2e24
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/trash.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:efabf98ed66fe4447c0f13c74aec681b084de780c551ce18258c79636d4123c5
+size 1524
diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_full.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_full.png
new file mode 100644
index 000000000..1a1655fdd
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_full.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f2715ea698eccb3648ab96cbddf897ea1842acbc1eb9667bc6f34aba82d0896b
+size 1976
diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_low.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_low.png
new file mode 100644
index 000000000..4d64d8062
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_low.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:58d839402c6f002ba8d2217888190b338fc3ac13d372df0988fac7bf95b89302
+size 2111
diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_medium.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_medium.png
new file mode 100644
index 000000000..2d53a20ce
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_medium.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a9918724409dbfa1973a097a692c2f57e45cc2bc0ce71c498ef3e02aa82559d3
+size 2128
diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_none.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_none.png
new file mode 100644
index 000000000..482a0e104
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_none.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3fcef95eb18e2db566b907ae99b8d8f450424b3b7823fdc24cdfe066ccf64378
+size 2141
diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_slash.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_slash.png
new file mode 100644
index 000000000..38ddff84b
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_slash.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:73e4ae4741a039f41d79827c40be6da83f8c6eb79e9103db2dfec718ca96efb7
+size 2512
diff --git a/selfdrive/assets/icons_mici/settings/toggles_icon.png b/selfdrive/assets/icons_mici/settings/toggles_icon.png
new file mode 100644
index 000000000..ccb343e8e
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/toggles_icon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0297535eb73bea71e87c363dc12385bb9163b81403797e50966b20259f725542
+size 2528
diff --git a/selfdrive/assets/icons_mici/settings/vertical_scroll_indicator.png b/selfdrive/assets/icons_mici/settings/vertical_scroll_indicator.png
new file mode 100644
index 000000000..77d9a77d6
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/vertical_scroll_indicator.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:88e6c50358f627fc714c1e9883143aeed00baabeab16132e16001aa1051e5eb8
+size 1272
diff --git a/selfdrive/assets/icons_mici/setup/arrow.png b/selfdrive/assets/icons_mici/setup/arrow.png
new file mode 100644
index 000000000..403aaedfb
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/arrow.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7aee85c239edcb7be41f03a4982b136d9a46908b027f37c5d36c80bd20372b22
+size 847
diff --git a/selfdrive/assets/icons_mici/setup/back.png b/selfdrive/assets/icons_mici/setup/back.png
new file mode 100644
index 000000000..554c63e09
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/back.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f88a958dd51eaf2c3f39df72165f538110c1d1eb4dbcac1f7016563db126c762
+size 1693
diff --git a/selfdrive/assets/icons_mici/setup/back_new.png b/selfdrive/assets/icons_mici/setup/back_new.png
new file mode 100644
index 000000000..c4834a564
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/back_new.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7198352d23952d0f2fbc128f20523ea6f2f2b7e378aa495da748a0e34f192806
+size 1641
diff --git a/selfdrive/assets/icons_mici/setup/green_button.png b/selfdrive/assets/icons_mici/setup/green_button.png
new file mode 100644
index 000000000..9708cfe28
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/green_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:163ac31cb990bdddfe552efef9a68870404caadb1c40fa8a5042b5ae956e6b4c
+size 24687
diff --git a/selfdrive/assets/icons_mici/setup/green_button_pressed.png b/selfdrive/assets/icons_mici/setup/green_button_pressed.png
new file mode 100644
index 000000000..030ce61d5
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/green_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6e4614adb2d3d0e44c64a855c221ec462a7aee22fff26132ad551035141c1a53
+size 62056
diff --git a/selfdrive/assets/icons_mici/setup/green_car.png b/selfdrive/assets/icons_mici/setup/green_car.png
new file mode 100644
index 000000000..867cadbbd
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/green_car.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ce8a34777e0b185f457b98845aa17fe6b5192ca46101463aecd21a9e04c0f0f0
+size 13281
diff --git a/selfdrive/assets/icons_mici/setup/green_dm.png b/selfdrive/assets/icons_mici/setup/green_dm.png
new file mode 100644
index 000000000..d41edd4c2
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/green_dm.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:78795eaa5e0be5fa369e172c02f5bd4b06d20f44363ccb8cbd02cb181b13e529
+size 14289
diff --git a/selfdrive/assets/icons_mici/setup/green_info.png b/selfdrive/assets/icons_mici/setup/green_info.png
new file mode 100644
index 000000000..309e56e6e
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/green_info.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2b0b1777d5bed7149982af9f2abab3fab7b6c576e3d53cf2c459804c6ec9ca1e
+size 3957
diff --git a/selfdrive/assets/icons_mici/setup/green_pedal.png b/selfdrive/assets/icons_mici/setup/green_pedal.png
new file mode 100644
index 000000000..2dd18f489
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/green_pedal.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6cadcda59bc861a1e710e0a8ac67024bdcc44b5f9261abbf098ff11cefb1da51
+size 12209
diff --git a/selfdrive/assets/icons_mici/setup/medium_button_bg.png b/selfdrive/assets/icons_mici/setup/medium_button_bg.png
new file mode 100644
index 000000000..e79dc2eb5
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/medium_button_bg.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9e363a79dc35ca4c4e9efaa6a843d37ad219efa5299d3e538d8249affa230096
+size 7935
diff --git a/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png b/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png
new file mode 100644
index 000000000..e52fb0c17
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cc6fb48520143b6fa1f060d8212e6d929917ab616ce943b5fab5a60665f00da5
+size 18225
diff --git a/selfdrive/assets/icons_mici/setup/reboot.png b/selfdrive/assets/icons_mici/setup/reboot.png
new file mode 100644
index 000000000..5633f2b49
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/reboot.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:345bea231013aab33b98a134a05af22c2df7c166b60a1a81b6b4526da13209a5
+size 2293
diff --git a/selfdrive/assets/icons_mici/setup/red_warning.png b/selfdrive/assets/icons_mici/setup/red_warning.png
new file mode 100644
index 000000000..ed0634079
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/red_warning.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:448d3e7214a77b02b32020ddb440ccd8fe72e110493a51cc10901c8242e72ca8
+size 3185
diff --git a/selfdrive/assets/icons_mici/setup/reset/small_button.png b/selfdrive/assets/icons_mici/setup/reset/small_button.png
new file mode 100644
index 000000000..e3f58b107
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/reset/small_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7a198f13f30b3dbc09f30d7fd8033a0bc07a0da9b010b7ca6ed2678430c9e5b4
+size 6949
diff --git a/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png b/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png
new file mode 100644
index 000000000..5b502e00a
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:75289d004709def2a2d6101a0330ec867895068ec3807aefc2a26d423d907a13
+size 13437
diff --git a/selfdrive/assets/icons_mici/setup/reset/wide_button.png b/selfdrive/assets/icons_mici/setup/reset/wide_button.png
new file mode 100644
index 000000000..3892f6eb8
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/reset/wide_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2452aaf59da18be1b74b475851d66e5c73c50aa49820419a288b1fdb7b42dee1
+size 9071
diff --git a/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png b/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png
new file mode 100644
index 000000000..3a34af884
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6478f7c1c5ef2013e94fc4218ab370889883c5c12231ba3e0975874cb0b6fec9
+size 21893
diff --git a/selfdrive/assets/icons_mici/setup/restore.png b/selfdrive/assets/icons_mici/setup/restore.png
new file mode 100644
index 000000000..6aa6c6b85
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/restore.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9d6b99696163cac1867d46998af9e53e212b82641b33c93b51276671f400a5ac
+size 2962
diff --git a/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png b/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png
new file mode 100644
index 000000000..4d74d8607
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:52535e34e27b0341f7690a72dc16555eeb6e032bc2c2cde0786469852fdf5987
+size 1267
diff --git a/selfdrive/assets/icons_mici/setup/small_button.png b/selfdrive/assets/icons_mici/setup/small_button.png
new file mode 100644
index 000000000..1ee01aeac
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:13919cf5df3137fdffdb8cc53a1215f13bf478a780ca8614234b7af0cdc0e766
+size 5409
diff --git a/selfdrive/assets/icons_mici/setup/small_button_disabled.png b/selfdrive/assets/icons_mici/setup/small_button_disabled.png
new file mode 100644
index 000000000..5028a8cd2
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_button_disabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9728423bd5e3197ef02d62e4bae415e6694aab875ca8630ffc9f188c38e18e5f
+size 4141
diff --git a/selfdrive/assets/icons_mici/setup/small_button_pressed.png b/selfdrive/assets/icons_mici/setup/small_button_pressed.png
new file mode 100644
index 000000000..6e30f47fb
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c032fd71ccfebe161827de420771e7927fe1ed799e615e24d458cfd79fead7f7
+size 7875
diff --git a/selfdrive/assets/icons_mici/setup/small_red_pill.png b/selfdrive/assets/icons_mici/setup/small_red_pill.png
new file mode 100644
index 000000000..4a7db930a
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_red_pill.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b3a336afddad80dc91caca91d54bd29897ce491f180374edf9a5ba517cbc00e9
+size 8765
diff --git a/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png b/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png
new file mode 100644
index 000000000..a8d51960c
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8eee9f10ca80a4e6100c00c02bb46aa5f253b14b086ab9982cfa85ee94eec162
+size 22512
diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow.png
new file mode 100644
index 000000000..bbf1d9625
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8425c56cb413ba757c94febe0332ce472dbf1472236b03cc4e627746fb86d701
+size 1149
diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow_outline.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow_outline.png
new file mode 100644
index 000000000..1515867a4
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow_outline.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f6394fa9dc03f83ac8599cf8179cdcc221c1b80b2c00d396b04bf2e3a7bfdca4
+size 1673
diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png
new file mode 100644
index 000000000..43c10a54a
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:94a86fac6ffe8a8179812cf55350ab9ca6935f36244c6f679c1cf521a842316b
+size 5723
diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_bg_larger.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_bg_larger.png
new file mode 100644
index 000000000..11c3ae2d3
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_bg_larger.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:950d55fd7294fb05c10ba9944537c02637776497c159e1b7d145c73f0f9d3253
+size 7119
diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_black_rounded_rectangle.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_black_rounded_rectangle.png
new file mode 100644
index 000000000..683587a06
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_black_rounded_rectangle.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:61281d3e3ef5ac5a8fe75405a93c2096bf235f090b27832e986444e3fb85715e
+size 7427
diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle.png
new file mode 100644
index 000000000..9ebff76b5
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bcd08444c77b3e559876eeb88d17808f72496adc26e27c3c21c00ff410879447
+size 10966
diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png
new file mode 100644
index 000000000..541433be7
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6ccb5f2298389ae36df87de84d85440ee5a82c50e803c9bd362c9b89ea45aa69
+size 6611
diff --git a/selfdrive/assets/icons_mici/setup/smaller_button.png b/selfdrive/assets/icons_mici/setup/smaller_button.png
new file mode 100644
index 000000000..9b4851c56
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/smaller_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:89ca7e6bb01dfa78300126ce828cb2a64e7a2e68e1e9152de242f57a36d0e57a
+size 8604
diff --git a/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png b/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png
new file mode 100644
index 000000000..6514791de
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b3242a411b559f1d0308f189fe0d25b81d6c7d964ca418a0c599a1bab4bffcbb
+size 5341
diff --git a/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png b/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png
new file mode 100644
index 000000000..64235b3a2
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d354651c0c8107dcc5f599777d260f53ef1901123315785ed8190466166cdce8
+size 17554
diff --git a/selfdrive/assets/icons_mici/setup/warning.png b/selfdrive/assets/icons_mici/setup/warning.png
new file mode 100644
index 000000000..806eea28b
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/warning.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3bc7a85a0672183d80817f337084060465e143362037955025c11bc8ac531076
+size 3247
diff --git a/selfdrive/assets/icons_mici/setup/widish_button.png b/selfdrive/assets/icons_mici/setup/widish_button.png
new file mode 100644
index 000000000..529b7c80c
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/widish_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:74fc21132b1e761ea54ce64617730c6ee79d01668244ab555b3b89870cfea181
+size 7112
diff --git a/selfdrive/assets/icons_mici/setup/widish_button_disabled.png b/selfdrive/assets/icons_mici/setup/widish_button_disabled.png
new file mode 100644
index 000000000..5028a8cd2
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/widish_button_disabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9728423bd5e3197ef02d62e4bae415e6694aab875ca8630ffc9f188c38e18e5f
+size 4141
diff --git a/selfdrive/assets/icons_mici/setup/widish_button_pressed.png b/selfdrive/assets/icons_mici/setup/widish_button_pressed.png
new file mode 100644
index 000000000..1095d4fc2
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/widish_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0ff179f93f421edcb503ca5c22a12b37e3a2aaabc414bf90f57e20ff5255dd75
+size 15572
diff --git a/selfdrive/assets/icons_mici/turn_intent_left.png b/selfdrive/assets/icons_mici/turn_intent_left.png
new file mode 100644
index 000000000..6c2c47e88
--- /dev/null
+++ b/selfdrive/assets/icons_mici/turn_intent_left.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ead8287b7041c32456e13721c238a71933256ca3d2b7e649c8f8731585eb5de8
+size 906
diff --git a/selfdrive/assets/icons_mici/turn_intent_right.png b/selfdrive/assets/icons_mici/turn_intent_right.png
new file mode 100644
index 000000000..03a7245e7
--- /dev/null
+++ b/selfdrive/assets/icons_mici/turn_intent_right.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6fe0532f7040aae78baa85c4cca44f5c939adb6a6f15889e2ca036f4a493f848
+size 935
diff --git a/selfdrive/assets/icons_mici/wheel.png b/selfdrive/assets/icons_mici/wheel.png
new file mode 100644
index 000000000..f122349b8
--- /dev/null
+++ b/selfdrive/assets/icons_mici/wheel.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cc3ef0c8c3038d75f99df2c565a361107bc903944d1afe91de0cbed9f6ca062a
+size 2725
diff --git a/selfdrive/assets/icons_mici/wheel_critical.png b/selfdrive/assets/icons_mici/wheel_critical.png
new file mode 100644
index 000000000..c0e5e8619
--- /dev/null
+++ b/selfdrive/assets/icons_mici/wheel_critical.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:12783dc05ea6dae2647ac3a3a7c8391d520c3f0cf2f458333a357ee9633eb6c4
+size 10909
diff --git a/selfdrive/assets/offroad/mici_fcc.html b/selfdrive/assets/offroad/mici_fcc.html
new file mode 100644
index 000000000..e6e418912
--- /dev/null
+++ b/selfdrive/assets/offroad/mici_fcc.html
@@ -0,0 +1,16 @@
+
HVIN: comma four
+FCC ID: 2BFC6-MICI
+IC: 32232-MICI
+Contains FCC ID: XMR2023EG916QGL
+Contains IC: 10224A-023EG916QGL
+
+This device contains licence-exempt transmitter(s)/receiver(s) that comply with Innovation, Science and Economic Development
+Canada's licence-exempt RSS(s) and complies with part 15 of the FCC Rules. Operation is subject to the following two conditions:
+1. This device may not cause harmful interference.
+2. This device must accept any interference received, including interference that may cause undesired operation of the device.
+
+L'émetteur/récepteur exempt de licence contenu dans le présent appareil est conforme aux CNR d'Innovation, Sciences
+et Développement économique Canada applicables aux appareils radio exempts de licence. L'exploitation est autorisée
+aux deux conditions suivantes :
+1. L'appareil ne doit pas produire de brouillage.
+2. L'appareil doit accepter tout brouillage radioélectrique subi, même si le brouillage est susceptible d'en compromettre
diff --git a/selfdrive/assets/sounds/disengage.wav b/selfdrive/assets/sounds/disengage.wav
index f3b5f21a2..8983884b2 100644
--- a/selfdrive/assets/sounds/disengage.wav
+++ b/selfdrive/assets/sounds/disengage.wav
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:1f061777d66d8d856a5fcb17378416f959088885ed770181355195ad15de881b
-size 68628
+oid sha256:c94582be9d921146b3c356e08a7352700c309cb407877c1180542811b2d637fa
+size 48078
diff --git a/selfdrive/assets/sounds/disengage_tizi.wav b/selfdrive/assets/sounds/disengage_tizi.wav
new file mode 100644
index 000000000..f3b5f21a2
--- /dev/null
+++ b/selfdrive/assets/sounds/disengage_tizi.wav
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1f061777d66d8d856a5fcb17378416f959088885ed770181355195ad15de881b
+size 68628
diff --git a/selfdrive/assets/sounds/engage.wav b/selfdrive/assets/sounds/engage.wav
index fc24a23c2..39d4c749c 100644
--- a/selfdrive/assets/sounds/engage.wav
+++ b/selfdrive/assets/sounds/engage.wav
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6b54a85cc09b8ee79fce23d48209205d2e70510eed4c5a86fa400fe3f7caebfd
-size 63120
+oid sha256:bc2b12bfe816a79307660b6b3d2de87a7643c6ccbfc9d1b33804645ad717682a
+size 48078
diff --git a/selfdrive/assets/sounds/engage_tizi.wav b/selfdrive/assets/sounds/engage_tizi.wav
new file mode 100644
index 000000000..fc24a23c2
--- /dev/null
+++ b/selfdrive/assets/sounds/engage_tizi.wav
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6b54a85cc09b8ee79fce23d48209205d2e70510eed4c5a86fa400fe3f7caebfd
+size 63120
diff --git a/selfdrive/ui/SConscript b/selfdrive/ui/SConscript
index 8a5f5793a..0de3e13c0 100644
--- a/selfdrive/ui/SConscript
+++ b/selfdrive/ui/SConscript
@@ -56,12 +56,16 @@ if GetOption('extras'):
"ld -r -b binary -o $TARGET $SOURCE")
inter = raylib_env.Command("installer/inter_ttf.o", "installer/inter-ascii.ttf",
"ld -r -b binary -o $TARGET $SOURCE")
+ inter_bold = raylib_env.Command("installer/inter_bold.o", "../assets/fonts/Inter-Bold.ttf",
+ "ld -r -b binary -o $TARGET $SOURCE")
+ inter_light = raylib_env.Command("installer/inter_light.o", "../assets/fonts/Inter-Light.ttf",
+ "ld -r -b binary -o $TARGET $SOURCE")
for name, branch in installers:
d = {'BRANCH': f"'\"{branch}\"'"}
if "internal" in name:
d['INTERNAL'] = "1"
obj = raylib_env.Object(f"installer/installers/installer_{name}.o", ["installer/installer.cc"], CPPDEFINES=d)
- f = raylib_env.Program(f"installer/installers/installer_{name}", [obj, cont, inter], LIBS=raylib_libs)
+ f = raylib_env.Program(f"installer/installers/installer_{name}", [obj, cont, inter, inter_bold, inter_light], LIBS=raylib_libs)
# keep installers small
assert f[0].get_size() < 1900*1e3, f[0].get_size()
diff --git a/selfdrive/ui/installer/installer.cc b/selfdrive/ui/installer/installer.cc
index 5cb0a38e0..38dd1ce25 100644
--- a/selfdrive/ui/installer/installer.cc
+++ b/selfdrive/ui/installer/installer.cc
@@ -36,8 +36,17 @@ extern const uint8_t str_continue[] asm("_binary_selfdrive_ui_installer_continue
extern const uint8_t str_continue_end[] asm("_binary_selfdrive_ui_installer_continue_openpilot_sh_end");
extern const uint8_t inter_ttf[] asm("_binary_selfdrive_ui_installer_inter_ascii_ttf_start");
extern const uint8_t inter_ttf_end[] asm("_binary_selfdrive_ui_installer_inter_ascii_ttf_end");
+extern const uint8_t inter_light_ttf[] asm("_binary_selfdrive_assets_fonts_Inter_Light_ttf_start");
+extern const uint8_t inter_light_ttf_end[] asm("_binary_selfdrive_assets_fonts_Inter_Light_ttf_end");
+extern const uint8_t inter_bold_ttf[] asm("_binary_selfdrive_assets_fonts_Inter_Bold_ttf_start");
+extern const uint8_t inter_bold_ttf_end[] asm("_binary_selfdrive_assets_fonts_Inter_Bold_ttf_end");
-Font font;
+Font font_inter;
+Font font_roman;
+Font font_display;
+
+const bool tici_device = Hardware::get_device_type() == cereal::InitData::DeviceType::TICI ||
+ Hardware::get_device_type() == cereal::InitData::DeviceType::TIZI;
std::vector tici_prebuilt_branches = {"release3", "release-tizi", "release3-staging", "nightly", "nightly-dev"};
std::string migrated_branch;
@@ -68,9 +77,13 @@ void run(const char* cmd) {
void finishInstall() {
BeginDrawing();
ClearBackground(BLACK);
- const char *m = "Finishing install...";
- int text_width = MeasureText(m, FONT_SIZE);
- DrawTextEx(font, m, (Vector2){(float)(GetScreenWidth() - text_width)/2 + FONT_SIZE, (float)(GetScreenHeight() - FONT_SIZE)/2}, FONT_SIZE, 0, WHITE);
+ if (tici_device) {
+ const char *m = "Finishing install...";
+ int text_width = MeasureText(m, FONT_SIZE);
+ DrawTextEx(font_display, m, (Vector2){(float)(GetScreenWidth() - text_width)/2 + FONT_SIZE, (float)(GetScreenHeight() - FONT_SIZE)/2}, FONT_SIZE, 0, WHITE);
+ } else {
+ DrawTextEx(font_display, "finishing setup", (Vector2){8, 10}, 82, 0, WHITE);
+ }
EndDrawing();
util::sleep_for(60 * 1000);
}
@@ -78,13 +91,21 @@ void finishInstall() {
void renderProgress(int progress) {
BeginDrawing();
ClearBackground(BLACK);
- DrawTextEx(font, "Installing...", (Vector2){150, 290}, 110, 0, WHITE);
- Rectangle bar = {150, 570, (float)GetScreenWidth() - 300, 72};
- DrawRectangleRec(bar, (Color){41, 41, 41, 255});
- progress = std::clamp(progress, 0, 100);
- bar.width *= progress / 100.0f;
- DrawRectangleRec(bar, (Color){70, 91, 234, 255});
- DrawTextEx(font, (std::to_string(progress) + "%").c_str(), (Vector2){150, 670}, 85, 0, WHITE);
+ if (tici_device) {
+ DrawTextEx(font_inter, "Installing...", (Vector2){150, 290}, 110, 0, WHITE);
+ Rectangle bar = {150, 570, (float)GetScreenWidth() - 300, 72};
+ DrawRectangleRec(bar, (Color){41, 41, 41, 255});
+ progress = std::clamp(progress, 0, 100);
+ bar.width *= progress / 100.0f;
+ DrawRectangleRec(bar, (Color){70, 91, 234, 255});
+ DrawTextEx(font_inter, (std::to_string(progress) + "%").c_str(), (Vector2){150, 670}, 85, 0, WHITE);
+ } else {
+ DrawTextEx(font_display, "installing", (Vector2){8, 10}, 82, 0, WHITE);
+ const std::string percent_str = std::to_string(progress) + "%";
+ DrawTextEx(font_roman, percent_str.c_str(), (Vector2){6, (float)(GetScreenHeight() - 128 + 18)}, 128, 0,
+ (Color){255, 255, 255, (unsigned char)(255 * 0.9 * 0.35)});
+ }
+
EndDrawing();
}
@@ -211,9 +232,18 @@ void cloneFinished(int exitCode) {
}
int main(int argc, char *argv[]) {
- InitWindow(2160, 1080, "Installer");
- font = LoadFontFromMemory(".ttf", inter_ttf, inter_ttf_end - inter_ttf, FONT_SIZE, NULL, 0);
- SetTextureFilter(font.texture, TEXTURE_FILTER_BILINEAR);
+ if (tici_device) {
+ InitWindow(2160, 1080, "Installer");
+ } else {
+ InitWindow(536, 240, "Installer");
+ }
+
+ font_inter = LoadFontFromMemory(".ttf", inter_ttf, inter_ttf_end - inter_ttf, FONT_SIZE, NULL, 0);
+ font_roman = LoadFontFromMemory(".ttf", inter_light_ttf, inter_light_ttf_end - inter_light_ttf, FONT_SIZE, NULL, 0);
+ font_display = LoadFontFromMemory(".ttf", inter_bold_ttf, inter_bold_ttf_end - inter_bold_ttf, FONT_SIZE, NULL, 0);
+ SetTextureFilter(font_inter.texture, TEXTURE_FILTER_BILINEAR);
+ SetTextureFilter(font_roman.texture, TEXTURE_FILTER_BILINEAR);
+ SetTextureFilter(font_display.texture, TEXTURE_FILTER_BILINEAR);
branchMigration();
@@ -226,6 +256,8 @@ int main(int argc, char *argv[]) {
}
CloseWindow();
- UnloadFont(font);
+ UnloadFont(font_inter);
+ UnloadFont(font_roman);
+ UnloadFont(font_display);
return 0;
}
diff --git a/selfdrive/ui/layouts/home.py b/selfdrive/ui/layouts/home.py
index 7f477d424..cd6ae600e 100644
--- a/selfdrive/ui/layouts/home.py
+++ b/selfdrive/ui/layouts/home.py
@@ -20,8 +20,6 @@ SPACING = 25
RIGHT_COLUMN_WIDTH = 750
REFRESH_INTERVAL = 10.0
-PRIME_BG_COLOR = rl.Color(51, 51, 51, 255)
-
class HomeLayoutState(IntEnum):
HOME = 0
diff --git a/selfdrive/ui/layouts/settings/common.py b/selfdrive/ui/layouts/settings/common.py
new file mode 100644
index 000000000..5e87a6447
--- /dev/null
+++ b/selfdrive/ui/layouts/settings/common.py
@@ -0,0 +1,5 @@
+from openpilot.selfdrive.ui.ui_state import ui_state
+
+
+def restart_needed_callback(_):
+ ui_state.params.put_bool("OnroadCycleRequested", True)
diff --git a/selfdrive/ui/layouts/settings/developer.py b/selfdrive/ui/layouts/settings/developer.py
index 9ea1019f5..646c81750 100644
--- a/selfdrive/ui/layouts/settings/developer.py
+++ b/selfdrive/ui/layouts/settings/developer.py
@@ -3,7 +3,7 @@ from openpilot.selfdrive.ui.widgets.ssh_key import ssh_key_item
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.list_view import toggle_item
-from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py
index f5f37fbd3..00ae6a188 100644
--- a/selfdrive/ui/layouts/settings/device.py
+++ b/selfdrive/ui/layouts/settings/device.py
@@ -17,7 +17,7 @@ from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dial
from openpilot.system.ui.widgets.html_render import HtmlModal
from openpilot.system.ui.widgets.list_view import text_item, button_item, dual_button_item
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
-from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.scroller_tici import Scroller
# Description constants
DESCRIPTIONS = {
diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py
index 8166a8a9e..4b8b7015f 100644
--- a/selfdrive/ui/layouts/settings/software.py
+++ b/selfdrive/ui/layouts/settings/software.py
@@ -9,7 +9,7 @@ from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.widgets.list_view import button_item, text_item, ListItem
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
-from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.scroller_tici import Scroller
# TODO: remove this. updater fails to respond on startup if time is not correct
UPDATED_TIMEOUT = 10 # seconds to wait for updated to respond
diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py
index 3a1265e0f..7fae2dfd2 100644
--- a/selfdrive/ui/layouts/settings/toggles.py
+++ b/selfdrive/ui/layouts/settings/toggles.py
@@ -2,7 +2,7 @@ from cereal import log
from openpilot.common.params import Params, UnknownKeyName
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.list_view import multiple_button_item, toggle_item
-from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
diff --git a/selfdrive/ui/mici/layouts/__init__.py b/selfdrive/ui/mici/layouts/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py
new file mode 100644
index 000000000..014fc0c45
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/home.py
@@ -0,0 +1,272 @@
+import time
+
+from cereal import log
+import pyray as rl
+from collections.abc import Callable
+from openpilot.system.ui.widgets.label import gui_label, MiciLabel
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_COLOR, MousePos
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.ui.text import wrap_text
+from openpilot.system.version import training_version
+
+HEAD_BUTTON_FONT_SIZE = 40
+HOME_PADDING = 8
+
+RELEASE_BRANCH = "release3"
+
+NetworkType = log.DeviceState.NetworkType
+
+NETWORK_TYPES = {
+ NetworkType.none: "Offline",
+ NetworkType.wifi: "WiFi",
+ NetworkType.cell2G: "2G",
+ NetworkType.cell3G: "3G",
+ NetworkType.cell4G: "LTE",
+ NetworkType.cell5G: "5G",
+ NetworkType.ethernet: "Ethernet",
+}
+
+
+class DeviceStatus(Widget):
+ def __init__(self):
+ super().__init__()
+ self.set_rect(rl.Rectangle(0, 0, 300, 175))
+ self._update_state()
+ self._version_text = self._get_version_text()
+
+ self._do_welcome()
+
+ def _do_welcome(self):
+ ui_state.params.put("CompletedTrainingVersion", training_version)
+
+ def refresh(self):
+ self._update_state()
+ self._version_text = self._get_version_text()
+
+ def _get_version_text(self) -> str:
+ brand = "openpilot"
+ description = ui_state.params.get("UpdaterCurrentDescription")
+ return f"{brand} {description}" if description else brand
+
+ def _update_state(self):
+ # TODO: refresh function that can be called periodically, not at 60 fps, so we can update version
+ # update system status
+ self._system_status = "SYSTEM READY ✓" if ui_state.panda_type != log.PandaState.PandaType.unknown else "BOOTING UP..."
+
+ # update network status
+ strength = ui_state.sm['deviceState'].networkStrength.raw
+ strength_text = "● " * strength + "○ " * (4 - strength) # ◌ also works
+ network_type = NETWORK_TYPES[ui_state.sm['deviceState'].networkType.raw]
+ self._network_status = f"{network_type} {strength_text}"
+
+ def _render(self, _):
+ # draw status
+ status_rect = rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, 40)
+ gui_label(status_rect, self._system_status, font_size=HEAD_BUTTON_FONT_SIZE, color=DEFAULT_TEXT_COLOR,
+ font_weight=FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
+
+ # draw network status
+ network_rect = rl.Rectangle(self._rect.x, self._rect.y + 60, self._rect.width, 40)
+ gui_label(network_rect, self._network_status, font_size=40, color=DEFAULT_TEXT_COLOR,
+ font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
+
+ # draw version
+ version_font_size = 30
+ version_rect = rl.Rectangle(self._rect.x, self._rect.y + 140, self._rect.width + 20, 40)
+ wrapped_text = '\n'.join(wrap_text(self._version_text, version_font_size, version_rect.width))
+ gui_label(version_rect, wrapped_text, font_size=version_font_size, color=DEFAULT_TEXT_COLOR,
+ font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
+
+
+class MiciHomeLayout(Widget):
+ def __init__(self):
+ super().__init__()
+ self._on_settings_click: Callable | None = None
+
+ self._last_refresh = 0
+ self._mouse_down_t: None | float = None
+ self._did_long_press = False
+ self._is_pressed_prev = False
+
+ self._version_text = None
+ self._experimental_mode = False
+
+ self._settings_txt = gui_app.texture("icons_mici/settings.png", 48, 48)
+ self._experimental_txt = gui_app.texture("icons_mici/experimental_mode.png", 48, 48)
+ self._mic_txt = gui_app.texture("icons_mici/microphone.png", 48, 48)
+
+ self._net_type = NETWORK_TYPES.get(NetworkType.none)
+ self._net_strength = 0
+
+ self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 50, 44)
+ self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 50, 44)
+ self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 50, 44)
+ self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 50, 44)
+ self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 50, 44)
+
+ self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 55, 35)
+ self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 55, 35)
+ self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 55, 35)
+ self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 55, 35)
+ self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 55, 35)
+
+ self._openpilot_label = MiciLabel("openpilot", font_size=96, color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.DISPLAY)
+ self._version_label = MiciLabel("", font_size=36, font_weight=FontWeight.ROMAN)
+ self._large_version_label = MiciLabel("", font_size=64, color=rl.GRAY, font_weight=FontWeight.ROMAN)
+ self._date_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN)
+ self._branch_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN, elide_right=False, scroll=True)
+ self._version_commit_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN)
+
+ def show_event(self):
+ self._version_text = self._get_version_text()
+ self._update_network_status(ui_state.sm['deviceState'])
+ self._update_params()
+
+ def _update_params(self):
+ self._experimental_mode = ui_state.params.get_bool("ExperimentalMode")
+
+ def _update_state(self):
+ if self.is_pressed and not self._is_pressed_prev:
+ self._mouse_down_t = time.monotonic()
+ elif not self.is_pressed and self._is_pressed_prev:
+ self._mouse_down_t = None
+ self._did_long_press = False
+ self._is_pressed_prev = self.is_pressed
+
+ if self._mouse_down_t is not None:
+ if time.monotonic() - self._mouse_down_t > 0.5:
+ # long gating for experimental mode - only allow toggle if longitudinal control is available
+ if ui_state.has_longitudinal_control:
+ self._experimental_mode = not self._experimental_mode
+ ui_state.params.put("ExperimentalMode", self._experimental_mode)
+ self._mouse_down_t = None
+ self._did_long_press = True
+
+ if rl.get_time() - self._last_refresh > 5.0:
+ device_state = ui_state.sm['deviceState']
+ self._update_network_status(device_state)
+
+ # Update version text
+ self._version_text = self._get_version_text()
+ self._last_refresh = rl.get_time()
+ self._update_params()
+
+ def _update_network_status(self, device_state):
+ self._net_type = device_state.networkType
+ strength = device_state.networkStrength
+ self._net_strength = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0
+
+ def set_callbacks(self, on_settings: Callable | None = None):
+ self._on_settings_click = on_settings
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ if not self._did_long_press:
+ if self._on_settings_click:
+ self._on_settings_click()
+ self._did_long_press = False
+
+ def _get_version_text(self) -> tuple[str, str, str, str] | None:
+ description = ui_state.params.get("UpdaterCurrentDescription")
+
+ if description is not None and len(description) > 0:
+ # Expect "version / branch / commit / date"; be tolerant of other formats
+ try:
+ version, branch, commit, date = description.split(" / ")
+ return version, branch, commit, date
+ except Exception:
+ return None
+
+ return None
+
+ def _render(self, _):
+ # TODO: why is there extra space here to get it to be flush?
+ text_pos = rl.Vector2(self.rect.x - 2 + HOME_PADDING, self.rect.y - 16)
+ self._openpilot_label.set_position(text_pos.x, text_pos.y)
+ self._openpilot_label.render()
+
+ if self._version_text is not None:
+ # release branch
+ if self._version_text[0] == RELEASE_BRANCH:
+ version_pos = rl.Vector2(text_pos.x, text_pos.y + self._openpilot_label.font_size + 16)
+ self._large_version_label.set_text(self._version_text[0])
+ self._large_version_label.set_position(version_pos.x, version_pos.y)
+ self._large_version_label.render()
+
+ else:
+ version_pos = rl.Rectangle(text_pos.x, text_pos.y + self._openpilot_label.font_size + 16, 100, 44)
+ self._version_label.set_text(self._version_text[0])
+ self._version_label.set_position(version_pos.x, version_pos.y)
+ self._version_label.render()
+
+ self._date_label.set_text(" " + self._version_text[3])
+ self._date_label.set_position(version_pos.x + self._version_label.rect.width + 10, version_pos.y)
+ self._date_label.render()
+
+ self._branch_label.set_width(gui_app.width - self._version_label.rect.width - self._date_label.rect.width - 32)
+ self._branch_label.set_text(" " + self._version_text[1])
+ self._branch_label.set_position(version_pos.x + self._version_label.rect.width + self._date_label.rect.width + 20, version_pos.y)
+ self._branch_label.render()
+
+ # 2nd line
+ self._version_commit_label.set_text(self._version_text[2])
+ self._version_commit_label.set_position(version_pos.x, version_pos.y + self._date_label.font_size + 7)
+ self._version_commit_label.render()
+
+ self._render_bottom_status_bar()
+
+ def _render_bottom_status_bar(self):
+ # ***** Center-aligned bottom section icons *****
+
+ # TODO: refactor repeated icon drawing into a small loop
+ ITEM_SPACING = 18
+ Y_CENTER = 24
+
+ last_x = self.rect.x + HOME_PADDING
+
+ # Draw settings icon in bottom left corner
+ rl.draw_texture(self._settings_txt, int(last_x), int(self._rect.y + self.rect.height - self._settings_txt.height / 2 - Y_CENTER),
+ rl.Color(255, 255, 255, int(255 * 0.9)))
+ last_x = last_x + self._settings_txt.width + ITEM_SPACING
+
+ # draw network
+ if self._net_type == NetworkType.wifi:
+ # There is no 1
+ draw_net_txt = {0: self._wifi_none_txt,
+ 2: self._wifi_low_txt,
+ 3: self._wifi_medium_txt,
+ 4: self._wifi_full_txt,
+ 5: self._wifi_full_txt}.get(self._net_strength, self._wifi_low_txt)
+ rl.draw_texture(draw_net_txt, int(last_x),
+ int(self._rect.y + self.rect.height - draw_net_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, int(255 * 0.9)))
+ last_x += draw_net_txt.width + ITEM_SPACING
+
+ elif self._net_type in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G):
+ draw_net_txt = {0: self._cell_none_txt,
+ 2: self._cell_low_txt,
+ 3: self._cell_medium_txt,
+ 4: self._cell_high_txt,
+ 5: self._cell_full_txt}.get(self._net_strength, self._cell_none_txt)
+ rl.draw_texture(draw_net_txt, int(last_x),
+ int(self._rect.y + self.rect.height - draw_net_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, int(255 * 0.9)))
+ last_x += draw_net_txt.width + ITEM_SPACING
+
+ else:
+ # No network
+ # Offset by difference in height between slashless and slash icons to make center align match
+ rl.draw_texture(self._wifi_slash_txt, int(last_x), int(self._rect.y + self.rect.height - self._wifi_slash_txt.height / 2 -
+ (self._wifi_slash_txt.height - self._wifi_none_txt.height) / 2 - Y_CENTER),
+ rl.Color(255, 255, 255, 255))
+ last_x += self._wifi_slash_txt.width + ITEM_SPACING
+
+ # draw experimental icon
+ if self._experimental_mode:
+ rl.draw_texture(self._experimental_txt, int(last_x),
+ int(self._rect.y + self.rect.height - self._experimental_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, 255))
+ last_x += self._experimental_txt.width + ITEM_SPACING
+
+ # draw microphone icon when recording audio is enabled
+ if ui_state.recording_audio:
+ rl.draw_texture(self._mic_txt, int(last_x),
+ int(self._rect.y + self.rect.height - self._mic_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, 255))
+ last_x += self._mic_txt.width + ITEM_SPACING
diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py
new file mode 100644
index 000000000..b52f9ed39
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/main.py
@@ -0,0 +1,149 @@
+import pyray as rl
+from enum import IntEnum
+import cereal.messaging as messaging
+from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout
+from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout
+from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts
+from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView
+from openpilot.selfdrive.ui.ui_state import device, ui_state
+from openpilot.selfdrive.ui.mici.layouts.onboarding import OnboardingWindow
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.lib.application import gui_app
+
+
+ONROAD_DELAY = 2.5 # seconds
+
+
+class MainState(IntEnum):
+ MAIN = 0
+ SETTINGS = 1
+
+
+class MiciMainLayout(Widget):
+ def __init__(self):
+ super().__init__()
+
+ self._pm = messaging.PubMaster(['bookmarkButton'])
+
+ self._current_mode: MainState | None = None
+ self._prev_onroad = False
+ self._prev_standstill = False
+ self._onroad_time_delay: float | None = None
+ self._setup = False
+
+ # Initialize widgets
+ self._home_layout = MiciHomeLayout()
+ self._alerts_layout = MiciOffroadAlerts()
+ self._settings_layout = SettingsLayout()
+ self._onroad_layout = AugmentedRoadView(bookmark_callback=self._on_bookmark_clicked)
+
+ # Initialize widget rects
+ for widget in (self._home_layout, self._settings_layout, self._alerts_layout, self._onroad_layout):
+ # TODO: set parent rect and use it if never passed rect from render (like in Scroller)
+ widget.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+
+ self._scroller = Scroller([
+ self._alerts_layout,
+ self._home_layout,
+ self._onroad_layout,
+ ], spacing=0, pad_start=0, pad_end=0)
+ self._scroller.set_reset_scroll_at_show(False)
+
+ # Disable scrolling when onroad is interacting with bookmark
+ self._scroller.set_scrolling_enabled(lambda: not self._onroad_layout.is_swiping_left())
+
+ self._layouts = {
+ MainState.MAIN: self._scroller,
+ MainState.SETTINGS: self._settings_layout,
+ }
+
+ # Set callbacks
+ self._setup_callbacks()
+
+ # Start onboarding if terms or training not completed
+ self._onboarding_window = OnboardingWindow()
+ if not self._onboarding_window.completed:
+ gui_app.set_modal_overlay(self._onboarding_window)
+
+ def _setup_callbacks(self):
+ self._home_layout.set_callbacks(on_settings=self._on_settings_clicked)
+ self._settings_layout.set_callbacks(on_close=self._on_settings_closed)
+ self._onroad_layout.set_click_callback(lambda: self._scroll_to(self._home_layout))
+ device.add_interactive_timeout_callback(self._set_mode_for_started)
+
+ def _scroll_to(self, layout: Widget):
+ layout_x = int(layout.rect.x)
+ self._scroller.scroll_to(layout_x, smooth=True)
+
+ def _render(self, _):
+ # Initial show event
+ if self._current_mode is None:
+ self._set_mode(MainState.MAIN)
+
+ if not self._setup:
+ if self._alerts_layout.active_alerts() > 0:
+ self._scroller.scroll_to(self._alerts_layout.rect.x)
+ else:
+ self._scroller.scroll_to(self._rect.width)
+ self._setup = True
+
+ # Render
+ if self._current_mode == MainState.MAIN:
+ self._scroller.render(self._rect)
+
+ elif self._current_mode == MainState.SETTINGS:
+ self._settings_layout.render(self._rect)
+
+ self._handle_transitions()
+
+ def _set_mode(self, mode: MainState):
+ if mode != self._current_mode:
+ if self._current_mode is not None:
+ self._layouts[self._current_mode].hide_event()
+ self._layouts[mode].show_event()
+ self._current_mode = mode
+
+ def _handle_transitions(self):
+ if ui_state.started != self._prev_onroad:
+ self._prev_onroad = ui_state.started
+
+ if ui_state.started:
+ self._onroad_time_delay = rl.get_time()
+ else:
+ self._set_mode_for_started(True)
+
+ # delay so we show home for a bit after starting
+ if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY:
+ self._set_mode_for_started(True)
+ self._onroad_time_delay = None
+
+ CS = ui_state.sm["carState"]
+ if not CS.standstill and self._prev_standstill:
+ self._set_mode(MainState.MAIN)
+ self._scroll_to(self._onroad_layout)
+ self._prev_standstill = CS.standstill
+
+ def _set_mode_for_started(self, onroad_transition: bool = False):
+ if ui_state.started:
+ CS = ui_state.sm["carState"]
+ # Only go onroad if car starts or is not at a standstill
+ if not CS.standstill or onroad_transition:
+ self._set_mode(MainState.MAIN)
+ self._scroll_to(self._onroad_layout)
+ else:
+ # Stay in settings if car turns off while in settings
+ if not onroad_transition or self._current_mode != MainState.SETTINGS:
+ self._set_mode(MainState.MAIN)
+ self._scroll_to(self._home_layout)
+
+ def _on_settings_clicked(self):
+ self._set_mode(MainState.SETTINGS)
+
+ def _on_settings_closed(self):
+ self._set_mode(MainState.MAIN)
+
+ def _on_bookmark_clicked(self):
+ user_bookmark = messaging.new_message('bookmarkButton')
+ user_bookmark.valid = True
+ self._pm.send('bookmarkButton', user_bookmark)
diff --git a/selfdrive/ui/mici/layouts/offroad_alerts.py b/selfdrive/ui/mici/layouts/offroad_alerts.py
new file mode 100644
index 000000000..2e9a8bee3
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/offroad_alerts.py
@@ -0,0 +1,307 @@
+import pyray as rl
+import re
+import time
+from dataclasses import dataclass
+from enum import IntEnum
+from openpilot.common.params import Params
+from openpilot.selfdrive.selfdrived.alertmanager import OFFROAD_ALERTS
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.label import UnifiedLabel
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.lib.multilang import tr
+
+REFRESH_INTERVAL = 5.0 # seconds
+
+
+class AlertSize(IntEnum):
+ SMALL = 0
+ MEDIUM = 1
+ BIG = 2
+
+
+@dataclass
+class AlertData:
+ key: str
+ text: str
+ severity: int
+ visible: bool = False
+
+
+class AlertItem(Widget):
+ # TODO: click should always go somewhere: home or specific settings pane
+ """Individual alert item widget with background image and text."""
+ ALERT_WIDTH = 520
+ ALERT_HEIGHT_SMALL = 212
+ ALERT_HEIGHT_MED = 240
+ ALERT_HEIGHT_BIG = 324
+ ALERT_PADDING = 28
+ ICON_SIZE = 64
+ ICON_MARGIN = 12
+ TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
+ TITLE_BODY_SPACING = 24
+
+ def __init__(self, alert_data: AlertData):
+ super().__init__()
+ self.alert_data = alert_data
+
+ # Load background textures
+ self._bg_small = gui_app.texture("icons_mici/offroad_alerts/small_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_SMALL)
+ self._bg_small_pressed = gui_app.texture("icons_mici/offroad_alerts/small_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_SMALL)
+ self._bg_medium = gui_app.texture("icons_mici/offroad_alerts/medium_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_MED)
+ self._bg_medium_pressed = gui_app.texture("icons_mici/offroad_alerts/medium_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_MED)
+ self._bg_big = gui_app.texture("icons_mici/offroad_alerts/big_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG)
+ self._bg_big_pressed = gui_app.texture("icons_mici/offroad_alerts/big_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG)
+
+ # Load warning icons
+ self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", self.ICON_SIZE, self.ICON_SIZE)
+ self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", self.ICON_SIZE, self.ICON_SIZE)
+ self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE)
+
+ self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, line_height=0.95)
+
+ self._body_label = UnifiedLabel(text="", font_size=28, font_weight=FontWeight.ROMAN, text_color=self.TEXT_COLOR,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, line_height=0.95)
+
+ self._title_text = ""
+ self._body_text = ""
+ self._alert_size = AlertSize.SMALL
+
+ self._update_content()
+
+ def _split_text(self, text: str) -> tuple[str, str]:
+ """Split text into title (first sentence) and body (remaining text)."""
+ # Find the end of the first sentence (period, exclamation, or question mark followed by space or end)
+ match = re.search(r'[.!?](?:\s+|$)', text)
+ if match:
+ # Found a sentence boundary - split at the end of the sentence
+ title = text[:match.start()].strip()
+ body = text[match.end():].strip()
+ return title, body
+ else:
+ # No sentence boundary found, return full text as title
+ return "", text
+
+ def _update_content(self):
+ """Update text and calculate height."""
+ if not self.alert_data.visible or not self.alert_data.text:
+ self.set_visible(False)
+ return
+
+ self.set_visible(True)
+
+ # Split text into title and body
+ self._title_text, self._body_text = self._split_text(self.alert_data.text)
+
+ # Calculate text width (alert width minus padding and icon space on right)
+ title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN
+ body_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2)
+
+ # Update labels
+ self._title_label.set_text(self._title_text)
+ self._body_label.set_text(self._body_text)
+
+ # Calculate content height
+ title_height = self._title_label.get_content_height(title_width) if self._title_text else 0
+ body_height = self._body_label.get_content_height(body_width) if self._body_text else 0
+ spacing = self.TITLE_BODY_SPACING if (self._title_text and self._body_text) else 0
+ total_text_height = title_height + spacing + body_height
+
+ # Determine which background size to use based on content height
+ min_height_with_padding = total_text_height + (self.ALERT_PADDING * 2)
+ if min_height_with_padding > self.ALERT_HEIGHT_MED:
+ self._alert_size = AlertSize.BIG
+ height = self.ALERT_HEIGHT_BIG
+ elif min_height_with_padding > self.ALERT_HEIGHT_SMALL:
+ self._alert_size = AlertSize.MEDIUM
+ height = self.ALERT_HEIGHT_MED
+ else:
+ self._alert_size = AlertSize.SMALL
+ height = self.ALERT_HEIGHT_SMALL
+
+ # Set rect size
+ self.set_rect(rl.Rectangle(0, 0, self.ALERT_WIDTH, height))
+
+ def update_alert_data(self, alert_data: AlertData):
+ """Update alert data and refresh display."""
+ self.alert_data = alert_data
+ self._update_content()
+
+ def _render(self, _):
+ if not self.alert_data.visible or not self.alert_data.text:
+ return
+
+ # Choose background based on size
+ if self._alert_size == AlertSize.BIG:
+ bg_texture = self._bg_big_pressed if self.is_pressed else self._bg_big
+ elif self._alert_size == AlertSize.MEDIUM:
+ bg_texture = self._bg_medium_pressed if self.is_pressed else self._bg_medium
+ else: # AlertSize.SMALL
+ bg_texture = self._bg_small_pressed if self.is_pressed else self._bg_small
+
+ # Draw background
+ rl.draw_texture(bg_texture, int(self._rect.x), int(self._rect.y), rl.WHITE)
+
+ # Calculate text area (left side, avoiding icon on right)
+ title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN
+ body_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2)
+ text_x = self._rect.x + self.ALERT_PADDING
+ text_y = self._rect.y + self.ALERT_PADDING
+
+ # Draw title label
+ if self._title_text:
+ title_rect = rl.Rectangle(
+ text_x,
+ text_y,
+ title_width,
+ self._title_label.get_content_height(title_width),
+ )
+ self._title_label.render(title_rect)
+ text_y += title_rect.height + self.TITLE_BODY_SPACING
+
+ # Draw body label
+ if self._body_text:
+ body_rect = rl.Rectangle(
+ text_x,
+ text_y,
+ body_width,
+ self._rect.height - text_y + self._rect.y - self.ALERT_PADDING,
+ )
+ self._body_label.render(body_rect)
+
+ # Draw warning icon on the right side
+ # Use green icon for update alerts (severity = -1), red for high severity, orange for low severity
+ if self.alert_data.severity == -1:
+ icon_texture = self._icon_green
+ elif self.alert_data.severity > 0:
+ icon_texture = self._icon_red
+ else:
+ icon_texture = self._icon_orange
+ icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE
+ icon_y = self._rect.y + self.ALERT_PADDING
+ rl.draw_texture(icon_texture, int(icon_x), int(icon_y), rl.WHITE)
+
+
+class MiciOffroadAlerts(Widget):
+ """Offroad alerts layout with vertical scrolling."""
+
+ def __init__(self):
+ super().__init__()
+ self.params = Params()
+ self.sorted_alerts: list[AlertData] = []
+ self.alert_items: list[AlertItem] = []
+ self._last_refresh = 0.0
+
+ # Create vertical scroller
+ self._scroller = Scroller([], horizontal=False, spacing=12, pad_start=0, pad_end=0, snap_items=False)
+
+ # Create empty state label
+ self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
+
+ # Build initial alert list
+ self._build_alerts()
+
+ def active_alerts(self) -> int:
+ return sum(alert.visible for alert in self.sorted_alerts)
+
+ def scrolling(self):
+ return self._scroller.scroll_panel.is_touch_valid()
+
+ def _build_alerts(self):
+ """Build sorted list of alerts from OFFROAD_ALERTS."""
+ self.sorted_alerts = []
+
+ # Add UpdateAvailable alert at the top (severity = -1 to indicate special handling)
+ update_alert_data = AlertData(key="UpdateAvailable", text="", severity=-1)
+ self.sorted_alerts.append(update_alert_data)
+ update_alert_item = AlertItem(update_alert_data)
+ self.alert_items.append(update_alert_item)
+ self._scroller.add_widget(update_alert_item)
+
+ # Add regular alerts sorted by severity
+ for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True):
+ severity = config.get("severity", 0)
+ alert_data = AlertData(key=key, text="", severity=severity)
+ self.sorted_alerts.append(alert_data)
+
+ # Create alert item widget
+ alert_item = AlertItem(alert_data)
+ self.alert_items.append(alert_item)
+ self._scroller.add_widget(alert_item)
+
+ def refresh(self) -> int:
+ """Refresh alerts from params and return active count."""
+ active_count = 0
+
+ # Handle UpdateAvailable alert specially
+ update_available = self.params.get_bool("UpdateAvailable")
+ update_alert_data = next((alert_data for alert_data in self.sorted_alerts if alert_data.key == "UpdateAvailable"), None)
+
+ if update_alert_data:
+ if update_available:
+ # Default text
+ update_alert_data.text = "update available. go to comma.ai/blog to read the release notes."
+
+ # Get new version description and parse version and date
+ new_desc = self.params.get("UpdaterNewDescription") or ""
+ if new_desc:
+ # Parse description (format: "version / branch / commit / date")
+ parts = new_desc.split(" / ")
+ if len(parts) > 3:
+ version, date = parts[0], parts[3]
+ update_alert_data.text = f"update available\n openpilot {version}, {date}. go to comma.ai/blog to read the release notes."
+
+ update_alert_data.visible = True
+ active_count += 1
+ else:
+ update_alert_data.text = ""
+ update_alert_data.visible = False
+
+ # Handle regular alerts
+ for alert_data in self.sorted_alerts:
+ if alert_data.key == "UpdateAvailable":
+ continue # Skip, already handled above
+
+ text = ""
+ alert_json = self.params.get(alert_data.key)
+
+ if alert_json:
+ text = alert_json.get("text", "").replace("%1", alert_json.get("extra", ""))
+
+ alert_data.text = text
+ alert_data.visible = bool(text)
+
+ if alert_data.visible:
+ active_count += 1
+
+ # Update alert items (they reference the same alert_data objects)
+ for alert_item in self.alert_items:
+ alert_item.update_alert_data(alert_item.alert_data)
+
+ return active_count
+
+ def show_event(self):
+ """Reset scroll position when shown and refresh alerts."""
+ self._scroller.show_event()
+ self._last_refresh = time.monotonic()
+ self.refresh()
+
+ def _update_state(self):
+ """Periodically refresh alerts."""
+ # Refresh alerts periodically, not every frame
+ current_time = time.monotonic()
+ if current_time - self._last_refresh >= REFRESH_INTERVAL:
+ self.refresh()
+ self._last_refresh = current_time
+
+ def _render(self, rect: rl.Rectangle):
+ """Render the alerts scroller or empty state."""
+ if self.active_alerts() == 0:
+ self._empty_label.render(rect)
+ else:
+ self._scroller.render(rect)
diff --git a/selfdrive/ui/mici/layouts/onboarding.py b/selfdrive/ui/mici/layouts/onboarding.py
new file mode 100644
index 000000000..6036393aa
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/onboarding.py
@@ -0,0 +1,552 @@
+from enum import IntEnum
+from collections.abc import Callable
+
+import pyray as rl
+from openpilot.system.ui.lib.application import FontWeight, gui_app
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.button import SmallButton
+from openpilot.system.ui.widgets.label import UnifiedLabel
+from openpilot.system.ui.widgets.slider import SmallSlider
+from openpilot.system.ui.mici_setup import TermsHeader, TermsPage as SetupTermsPage
+from openpilot.selfdrive.ui.ui_state import ui_state, device
+from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall
+from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar
+from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
+from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog
+from openpilot.system.ui.widgets.label import gui_label
+from openpilot.system.ui.lib.multilang import tr
+
+
+class OnboardingState(IntEnum):
+ TERMS = 0
+ ONBOARDING = 1
+ DECLINE = 2
+
+
+class DriverCameraSetupDialog(DriverCameraDialog):
+ def __init__(self, confirm_callback: Callable):
+ super().__init__(no_escape=True)
+ self.driver_state_renderer = DriverStateRenderer(confirm_mode=True, confirm_callback=confirm_callback)
+ self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 200, 200))
+ self.driver_state_renderer.load_icons()
+
+ def _render(self, rect):
+ rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
+ self._camera_view._render(rect)
+
+ if not self._camera_view.frame:
+ gui_label(rect, tr("camera starting"), font_size=64, font_weight=FontWeight.BOLD,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
+ rl.end_scissor_mode()
+ return -1
+
+ # Position dmoji on opposite side from driver
+ # TODO: we don't have design for RHD yet
+ is_rhd = False
+ driver_state_rect = (
+ rect.x if is_rhd else rect.x + rect.width - self.driver_state_renderer.rect.width,
+ rect.y + (rect.height - self.driver_state_renderer.rect.height) / 2,
+ )
+ self.driver_state_renderer.set_position(*driver_state_rect)
+ self.driver_state_renderer.render()
+
+ rl.end_scissor_mode()
+ return -1
+
+
+class TrainingGuideIntro(SetupTermsPage):
+ def __init__(self, continue_callback):
+ super().__init__(continue_callback, continue_text="continue")
+ self._title_header = TermsHeader("welcome to openpilot", gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 60, 60))
+
+ self._dm_label = UnifiedLabel("Before we get on the road, let's review the " +
+ "functionality and limitations of openpilot.", 36,
+ FontWeight.ROMAN)
+
+ @property
+ def _content_height(self):
+ return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset()
+
+ def _render_content(self, scroll_offset):
+ self._title_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16 + scroll_offset,
+ self._title_header.rect.width,
+ self._title_header.rect.height,
+ ))
+
+ self._dm_label.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._title_header.rect.y + self._title_header.rect.height + 16,
+ self._rect.width - 32,
+ self._dm_label.get_content_height(int(self._rect.width - 32)),
+ ))
+
+
+class TrainingGuidePreDMTutorial(SetupTermsPage):
+ def __init__(self, continue_callback):
+ super().__init__(continue_callback, continue_text="continue")
+ self._title_header = TermsHeader("driver monitoring setup", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60))
+
+ self._dm_label = UnifiedLabel("Next, we'll ensure comma four is mounted properly.\n\nIf it does not have a clear view of the driver, " +
+ "simply unplug and remount before continuing.\n\n" +
+ "NOTE: the driver camera will have a purple tint due to the IR illumination used for seeing at night.", 36,
+ FontWeight.ROMAN)
+
+ def show_event(self):
+ super().show_event()
+ # Get driver monitoring model ready for next step
+ ui_state.params.put_bool("IsDriverViewEnabled", True)
+
+ @property
+ def _content_height(self):
+ return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset()
+
+ def _render_content(self, scroll_offset):
+ self._title_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16 + scroll_offset,
+ self._title_header.rect.width,
+ self._title_header.rect.height,
+ ))
+
+ self._dm_label.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._title_header.rect.y + self._title_header.rect.height + 16,
+ self._rect.width - 32,
+ self._dm_label.get_content_height(int(self._rect.width - 32)),
+ ))
+
+
+class TrainingGuideDMTutorial(Widget):
+ def __init__(self, continue_callback):
+ super().__init__()
+ self._title_header = TermsHeader("fill the circle to continue", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60))
+
+ self._dialog = DriverCameraSetupDialog(continue_callback)
+
+ # Disable driver monitoring model when device times out for inactivity
+ def inactivity_callback():
+ ui_state.params.put_bool("IsDriverViewEnabled", False)
+
+ device.add_interactive_timeout_callback(inactivity_callback)
+
+ def show_event(self):
+ super().show_event()
+ self._dialog.show_event()
+
+ def _update_state(self):
+ super()._update_state()
+ if device.awake:
+ ui_state.params.put_bool("IsDriverViewEnabled", True)
+
+ def _render(self, _):
+ self._dialog.render(self._rect)
+
+ rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - self._title_header.rect.height * 1.5 - 32),
+ int(self._rect.width), int(self._title_header.rect.height * 1.5 + 32),
+ rl.BLANK, rl.Color(0, 0, 0, 150))
+ self._title_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + self._rect.height - self._title_header.rect.height - 16,
+ self._title_header.rect.width,
+ self._title_header.rect.height,
+ ))
+
+
+class TrainingGuideRecordFront(SetupTermsPage):
+ def __init__(self, continue_callback):
+ def on_back():
+ ui_state.params.put_bool("RecordFront", False)
+ continue_callback()
+
+ def on_continue():
+ ui_state.params.put_bool("RecordFront", True)
+ continue_callback()
+
+ super().__init__(on_continue, back_callback=on_back, back_text="no", continue_text="yes")
+ self._title_header = TermsHeader("improve driver monitoring", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60))
+
+ self._dm_label = UnifiedLabel("Help improve driver monitoring by including your driving data in the training data set. " +
+ "Your preference can be changed at any time in Settings. Would you like to share your data?", 36,
+ FontWeight.ROMAN)
+
+ def show_event(self):
+ super().show_event()
+ # Disable driver monitoring model after last step
+ ui_state.params.put_bool("IsDriverViewEnabled", False)
+
+ @property
+ def _content_height(self):
+ return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset()
+
+ def _render_content(self, scroll_offset):
+ self._title_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16 + scroll_offset,
+ self._title_header.rect.width,
+ self._title_header.rect.height,
+ ))
+
+ self._dm_label.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._title_header.rect.y + self._title_header.rect.height + 16,
+ self._rect.width - 32,
+ self._dm_label.get_content_height(int(self._rect.width - 32)),
+ ))
+
+
+class TrainingGuideAttentionNotice1(SetupTermsPage):
+ def __init__(self, continue_callback):
+ super().__init__(continue_callback, continue_text="continue")
+ self._title_header = TermsHeader("not a self driving car", gui_app.texture("icons_mici/setup/warning.png", 60, 60))
+ self._warning_label = UnifiedLabel("THIS IS A DRIVER ASSISTANCE SYSTEM. A DRIVER ASSISTANCE SYSTEM IS NOT A SELF DRIVING CAR.", 36,
+ FontWeight.ROMAN)
+
+ @property
+ def _content_height(self):
+ return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset()
+
+ def _render_content(self, scroll_offset):
+ self._title_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16 + scroll_offset,
+ self._title_header.rect.width,
+ self._title_header.rect.height,
+ ))
+
+ self._warning_label.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._title_header.rect.y + self._title_header.rect.height + 16,
+ self._rect.width - 32,
+ self._warning_label.get_content_height(int(self._rect.width - 32)),
+ ))
+
+
+class TrainingGuideAttentionNotice2(SetupTermsPage):
+ def __init__(self, continue_callback):
+ super().__init__(continue_callback, continue_text="continue")
+ self._title_header = TermsHeader("attention is required", gui_app.texture("icons_mici/setup/warning.png", 60, 60))
+ self._warning_label = UnifiedLabel("YOU MUST PAY ATTENTION AT ALL TIMES. YOU ARE FULLY RESPONSIBLE FOR DRIVING THE CAR.", 36,
+ FontWeight.ROMAN)
+
+ @property
+ def _content_height(self):
+ return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset()
+
+ def _render_content(self, scroll_offset):
+ self._title_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16 + scroll_offset,
+ self._title_header.rect.width,
+ self._title_header.rect.height,
+ ))
+
+ self._warning_label.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._title_header.rect.y + self._title_header.rect.height + 16,
+ self._rect.width - 32,
+ self._warning_label.get_content_height(int(self._rect.width - 32)),
+ ))
+
+
+class TrainingGuideDisengaging(SetupTermsPage):
+ def __init__(self, continue_callback):
+ super().__init__(continue_callback, continue_text="continue")
+ self._title_header = TermsHeader("disengaging openpilot", gui_app.texture("icons_mici/setup/green_pedal.png", 60, 60))
+ self._warning_label = UnifiedLabel("You can disengage openpilot by either pressing the brake pedal or " +
+ "the cancel button on your steering wheel.", 36,
+ FontWeight.ROMAN)
+
+ @property
+ def _content_height(self):
+ return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset()
+
+ def _render_content(self, scroll_offset):
+ self._title_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16 + scroll_offset,
+ self._title_header.rect.width,
+ self._title_header.rect.height,
+ ))
+
+ self._warning_label.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._title_header.rect.y + self._title_header.rect.height + 16,
+ self._rect.width - 32,
+ self._warning_label.get_content_height(int(self._rect.width - 32)),
+ ))
+
+
+class TrainingGuideConfidenceBall(SetupTermsPage):
+ ANIMATION_PAUSE = 3.5
+
+ def __init__(self, continue_callback):
+ super().__init__(continue_callback, continue_text="continue")
+ self._confidence_ball = ConfidenceBall(demo=True)
+ self._start_time = 0.0
+
+ self._title_header = TermsHeader("confidence ball", gui_app.texture("icons_mici/setup/green_car.png", 60, 60))
+ self._warning_label = UnifiedLabel("The ball on the right communicates how confident the driving " +
+ "model is about the road scene at any given time.", 36,
+ FontWeight.ROMAN)
+
+ def show_event(self):
+ super().show_event()
+ self._start_time = rl.get_time()
+
+ @property
+ def _content_height(self):
+ return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset()
+
+ def _render_content(self, scroll_offset):
+ self._title_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16 + scroll_offset,
+ self._title_header.rect.width,
+ self._title_header.rect.height,
+ ))
+
+ # room for confidence ball
+ label_width = self._rect.width - 32 - 60
+ self._warning_label.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._title_header.rect.y + self._title_header.rect.height + 16,
+ label_width,
+ self._warning_label.get_content_height(int(label_width)),
+ ))
+
+ duration = rl.get_time() - self._start_time
+ if duration > 5 + self.ANIMATION_PAUSE * 2:
+ # reset animation
+ self._start_time = rl.get_time()
+ if duration > 5 + self.ANIMATION_PAUSE:
+ self._confidence_ball.update_filter(0.1)
+ elif duration > 5:
+ self._confidence_ball.update_filter(0.4)
+ elif duration > 0.5:
+ self._confidence_ball.update_filter(0.9)
+
+ self._confidence_ball.render(self._rect)
+ self._rect.width -= 60
+
+
+class TrainingGuideSteeringArc(SetupTermsPage):
+ ANIMATION_PAUSE = 2
+ TORQUE_BAR_HEIGHT = 100
+
+ def __init__(self, continue_callback):
+ super().__init__(continue_callback, continue_text="finish")
+ self._torque_bar = TorqueBar(demo=True)
+ self._start_time = 0.0
+
+ self._title_header = TermsHeader("steering arc", gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 60, 60))
+ self._warning_label = UnifiedLabel("All cars limit the amount of steering that openpilot is able to apply. While driving, the " +
+ "steering arc shows the current amount of force being applied in relation to the maximum available to openpilot. " +
+ "You may need to assist if you see the arc nearing its orange state.", 36,
+ FontWeight.ROMAN)
+
+ def show_event(self):
+ super().show_event()
+ self._start_time = rl.get_time()
+
+ @property
+ def _content_height(self):
+ return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() + self.TORQUE_BAR_HEIGHT
+
+ def _render_content(self, scroll_offset):
+ self._title_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16 + scroll_offset,
+ self._title_header.rect.width,
+ self._title_header.rect.height,
+ ))
+
+ self._warning_label.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._title_header.rect.y + self._title_header.rect.height + 16,
+ self._rect.width - 32,
+ self._warning_label.get_content_height(int(self._rect.width - 32)),
+ ))
+
+ duration = rl.get_time() - self._start_time
+ if duration > self.ANIMATION_PAUSE * 5:
+ # reset animation
+ self._start_time = rl.get_time()
+ elif duration > self.ANIMATION_PAUSE * 4:
+ self._torque_bar.update_filter(-1.0)
+ elif duration > self.ANIMATION_PAUSE * 3:
+ self._torque_bar.update_filter(-0.2)
+ elif duration > self.ANIMATION_PAUSE * 2:
+ self._torque_bar.update_filter(1.0)
+ elif duration > self.ANIMATION_PAUSE:
+ self._torque_bar.update_filter(0.7)
+ else:
+ self._torque_bar.update_filter(0.0)
+
+ # background gradient for torque bar legibility
+ rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height * 0.6),
+ int(self._rect.width), int(self._rect.height * 0.2),
+ rl.BLANK, rl.Color(0, 0, 0, int(255 * 0.9)))
+ rl.draw_rectangle(int(self._rect.x), int(self._rect.y + self._rect.height * 0.8),
+ int(self._rect.width), int(self._rect.height * 0.2),
+ rl.Color(0, 0, 0, int(255 * 0.9)))
+
+ # scroll torque bar once we get to the bottom of content
+ torque_y_offset = min(0.0, self._warning_label.rect.y + self._warning_label.rect.height -
+ self._rect.height + self.TORQUE_BAR_HEIGHT)
+ torque_rect = rl.Rectangle(
+ self._rect.x,
+ self._rect.y + torque_y_offset,
+ self._rect.width,
+ self._rect.height,
+ )
+ self._torque_bar.render(torque_rect)
+
+
+class TrainingGuide(Widget):
+ def __init__(self, completed_callback=None):
+ super().__init__()
+ self._completed_callback = completed_callback
+ self._step = 0
+
+ self._steps = [
+ TrainingGuideIntro(continue_callback=self._advance_step),
+ TrainingGuideAttentionNotice1(continue_callback=self._advance_step),
+ TrainingGuideAttentionNotice2(continue_callback=self._advance_step),
+ TrainingGuidePreDMTutorial(continue_callback=self._advance_step),
+ TrainingGuideDMTutorial(continue_callback=self._advance_step),
+ TrainingGuideRecordFront(continue_callback=self._advance_step),
+ TrainingGuideDisengaging(continue_callback=self._advance_step),
+ TrainingGuideConfidenceBall(continue_callback=self._advance_step),
+ TrainingGuideSteeringArc(continue_callback=self._advance_step),
+ ]
+
+ def _advance_step(self):
+ if self._step < len(self._steps) - 1:
+ self._step += 1
+ self._steps[self._step].show_event()
+ else:
+ self._step = 0
+ if self._completed_callback:
+ self._completed_callback()
+
+ def _render(self, _):
+ if self._step < len(self._steps):
+ self._steps[self._step].render(self._rect)
+ return -1
+
+
+class DeclinePage(Widget):
+ def __init__(self, back_callback=None):
+ super().__init__()
+ self._uninstall_slider = SmallSlider("uninstall openpilot", self._on_uninstall)
+
+ self._back_button = SmallButton("back")
+ self._back_button.set_click_callback(back_callback)
+
+ self._warning_header = TermsHeader("you must accept the\nterms to use openpilot",
+ gui_app.texture("icons_mici/setup/red_warning.png", 66, 60))
+
+ def _on_uninstall(self):
+ ui_state.params.put_bool("DoUninstall", True)
+ gui_app.request_close()
+
+ def _render(self, _):
+ self._warning_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16,
+ self._warning_header.rect.width,
+ self._warning_header.rect.height,
+ ))
+
+ self._back_button.set_opacity(1 - self._uninstall_slider.slider_percentage)
+ self._back_button.render(rl.Rectangle(
+ self._rect.x + 8,
+ self._rect.y + self._rect.height - self._back_button.rect.height,
+ self._back_button.rect.width,
+ self._back_button.rect.height,
+ ))
+
+ self._uninstall_slider.render(rl.Rectangle(
+ self._rect.x + self._rect.width - self._uninstall_slider.rect.width,
+ self._rect.y + self._rect.height - self._uninstall_slider.rect.height,
+ self._uninstall_slider.rect.width,
+ self._uninstall_slider.rect.height,
+ ))
+
+
+class TermsPage(SetupTermsPage):
+ def __init__(self, on_accept=None, on_decline=None):
+ super().__init__(on_accept, on_decline, "decline")
+
+ info_txt = gui_app.texture("icons_mici/setup/green_info.png", 60, 60)
+ self._title_header = TermsHeader("scroll down to read &\n accept terms", info_txt)
+
+ self._terms_label = UnifiedLabel("You must accept the Terms and Conditions to use openpilot. " +
+ "Read the latest terms at https://comma.ai/terms before continuing.", 36,
+ FontWeight.ROMAN)
+
+ @property
+ def _content_height(self):
+ return self._terms_label.rect.y + self._terms_label.rect.height - self._scroll_panel.get_offset()
+
+ def _render_content(self, scroll_offset):
+ self._title_header.set_position(self._rect.x + 16, self._rect.y + 12 + scroll_offset)
+ self._title_header.render()
+
+ self._terms_label.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING,
+ self._rect.width - 100,
+ self._terms_label.get_content_height(int(self._rect.width - 100)),
+ ))
+
+
+class OnboardingWindow(Widget):
+ def __init__(self):
+ super().__init__()
+ self._current_terms_version = ui_state.params.get("TermsVersion")
+ self._current_training_version = ui_state.params.get("TrainingVersion")
+ self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == self._current_terms_version
+ self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == self._current_training_version
+
+ self._state = OnboardingState.TERMS if not self._accepted_terms else OnboardingState.ONBOARDING
+
+ self.set_rect(rl.Rectangle(0, 0, 458, gui_app.height))
+
+ # Windows
+ self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_terms_declined)
+ self._training_guide = TrainingGuide(completed_callback=self._on_completed_training)
+ self._decline_page = DeclinePage(back_callback=self._on_decline_back)
+
+ @property
+ def completed(self) -> bool:
+ return self._accepted_terms and self._training_done
+
+ def _on_terms_declined(self):
+ self._state = OnboardingState.DECLINE
+
+ def _on_decline_back(self):
+ self._state = OnboardingState.TERMS
+
+ def close(self):
+ ui_state.params.put_bool("IsDriverViewEnabled", False)
+ gui_app.set_modal_overlay(None)
+
+ def _on_terms_accepted(self):
+ ui_state.params.put("HasAcceptedTerms", self._current_terms_version)
+ self._state = OnboardingState.ONBOARDING
+
+ def _on_completed_training(self):
+ ui_state.params.put("CompletedTrainingVersion", self._current_training_version)
+ self.close()
+
+ def _render(self, _):
+ if self._state == OnboardingState.TERMS:
+ self._terms.render(self._rect)
+ elif self._state == OnboardingState.ONBOARDING:
+ self._training_guide.render(self._rect)
+ elif self._state == OnboardingState.DECLINE:
+ self._decline_page.render(self._rect)
+ return -1
diff --git a/selfdrive/ui/mici/layouts/settings/developer.py b/selfdrive/ui/mici/layouts/settings/developer.py
new file mode 100644
index 000000000..8fc63e896
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/settings/developer.py
@@ -0,0 +1,151 @@
+import pyray as rl
+from collections.abc import Callable
+
+from openpilot.common.time_helpers import system_time_valid
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle, BigParamControl
+from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.widgets import NavWidget
+from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyAction
+
+
+class DeveloperLayoutMici(NavWidget):
+ def __init__(self, back_callback: Callable):
+ super().__init__()
+ self.set_back_callback(back_callback)
+
+ def github_username_callback(username: str):
+ if username:
+ ssh_keys = SshKeyAction()
+ ssh_keys._fetch_ssh_key(username)
+ if not ssh_keys._error_message:
+ self._ssh_keys_btn.set_value(username)
+ else:
+ dlg = BigDialog("", ssh_keys._error_message)
+ gui_app.set_modal_overlay(dlg)
+
+ def ssh_keys_callback():
+ github_username = ui_state.params.get("GithubUsername") or ""
+ dlg = BigInputDialog("enter GitHub username", github_username, confirm_callback=github_username_callback)
+ if not system_time_valid():
+ dlg = BigDialog("Please connect to Wi-Fi to fetch your key", "")
+ gui_app.set_modal_overlay(dlg)
+ return
+ gui_app.set_modal_overlay(dlg)
+
+ txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 77, 44)
+ github_username = ui_state.params.get("GithubUsername") or ""
+ self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh)
+ self._ssh_keys_btn.set_click_callback(ssh_keys_callback)
+
+ # adb, ssh, ssh keys, debug mode, joystick debug mode, longitudinal maneuver mode, ip address
+ # ******** Main Scroller ********
+ self._adb_toggle = BigParamControl("enable ADB", "AdbEnabled")
+ self._ssh_toggle = BigParamControl("enable SSH", "SshEnabled")
+ self._joystick_toggle = BigToggle("joystick debug mode",
+ initial_state=ui_state.params.get_bool("JoystickDebugMode"),
+ toggle_callback=self._on_joystick_debug_mode)
+ self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode",
+ initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"),
+ toggle_callback=self._on_long_maneuver_mode)
+ self._alpha_long_toggle = BigToggle("alpha longitudinal",
+ initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"),
+ toggle_callback=self._on_alpha_long_enabled)
+ self._debug_mode_toggle = BigParamControl("ui debug mode", "ShowDebugInfo",
+ toggle_callback=lambda checked: (gui_app.set_show_touches(checked),
+ gui_app.set_show_fps(checked)))
+
+ self._scroller = Scroller([
+ self._adb_toggle,
+ self._ssh_toggle,
+ self._ssh_keys_btn,
+ self._joystick_toggle,
+ self._long_maneuver_toggle,
+ self._alpha_long_toggle,
+ self._debug_mode_toggle,
+ ], snap_items=False)
+
+ # Toggle lists
+ self._refresh_toggles = (
+ ("AdbEnabled", self._adb_toggle),
+ ("SshEnabled", self._ssh_toggle),
+ ("JoystickDebugMode", self._joystick_toggle),
+ ("LongitudinalManeuverMode", self._long_maneuver_toggle),
+ ("AlphaLongitudinalEnabled", self._alpha_long_toggle),
+ ("ShowDebugInfo", self._debug_mode_toggle),
+ )
+ onroad_blocked_toggles = (self._adb_toggle, self._joystick_toggle)
+ release_blocked_toggles = (self._joystick_toggle, self._long_maneuver_toggle, self._alpha_long_toggle)
+ engaged_blocked_toggles = (self._long_maneuver_toggle, self._alpha_long_toggle)
+
+ # Hide non-release toggles on release builds
+ for item in release_blocked_toggles:
+ item.set_visible(not ui_state.is_release)
+
+ # Disable toggles that require offroad
+ for item in onroad_blocked_toggles:
+ item.set_enabled(lambda: ui_state.is_offroad())
+
+ # Disable toggles that require not engaged
+ for item in engaged_blocked_toggles:
+ item.set_enabled(lambda: not ui_state.engaged)
+
+ # Set initial state
+ if ui_state.params.get_bool("ShowDebugInfo"):
+ gui_app.set_show_touches(True)
+ gui_app.set_show_fps(True)
+
+ ui_state.add_offroad_transition_callback(self._update_toggles)
+
+ def show_event(self):
+ super().show_event()
+ self._scroller.show_event()
+ self._update_toggles()
+
+ def _render(self, rect: rl.Rectangle):
+ self._scroller.render(rect)
+
+ def _update_toggles(self):
+ ui_state.update_params()
+
+ # CP gating
+ if ui_state.CP is not None:
+ alpha_avail = ui_state.CP.alphaLongitudinalAvailable
+ if not alpha_avail or ui_state.is_release:
+ self._alpha_long_toggle.set_visible(False)
+ ui_state.params.remove("AlphaLongitudinalEnabled")
+ else:
+ self._alpha_long_toggle.set_visible(True)
+
+ long_man_enabled = ui_state.has_longitudinal_control and ui_state.is_offroad()
+ self._long_maneuver_toggle.set_enabled(long_man_enabled)
+ if not long_man_enabled:
+ self._long_maneuver_toggle.set_checked(False)
+ ui_state.params.put_bool("LongitudinalManeuverMode", False)
+ else:
+ self._long_maneuver_toggle.set_enabled(False)
+ self._alpha_long_toggle.set_visible(False)
+
+ # Refresh toggles from params to mirror external changes
+ for key, item in self._refresh_toggles:
+ item.set_checked(ui_state.params.get_bool(key))
+
+ def _on_joystick_debug_mode(self, state: bool):
+ ui_state.params.put_bool("JoystickDebugMode", state)
+ ui_state.params.put_bool("LongitudinalManeuverMode", False)
+ self._long_maneuver_toggle.set_checked(False)
+
+ def _on_long_maneuver_mode(self, state: bool):
+ ui_state.params.put_bool("LongitudinalManeuverMode", state)
+ ui_state.params.put_bool("JoystickDebugMode", False)
+ self._joystick_toggle.set_checked(False)
+ restart_needed_callback(state)
+
+ def _on_alpha_long_enabled(self, state: bool):
+ # TODO: show confirmation dialog before enabling
+ ui_state.params.put_bool("AlphaLongitudinalEnabled", state)
+ restart_needed_callback(state)
+ self._update_toggles()
diff --git a/selfdrive/ui/mici/layouts/settings/device.py b/selfdrive/ui/mici/layouts/settings/device.py
new file mode 100644
index 000000000..de2e11caf
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/settings/device.py
@@ -0,0 +1,378 @@
+import os
+import threading
+import json
+import pyray as rl
+from enum import IntEnum
+from collections.abc import Callable
+
+from openpilot.common.basedir import BASEDIR
+from openpilot.common.params import Params
+from openpilot.common.time_helpers import system_time_valid
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
+from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton
+from openpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog, BigDialog, BigConfirmationDialogV2
+from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog
+from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog
+from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide
+from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
+from openpilot.system.ui.lib.multilang import tr
+from openpilot.system.ui.widgets import Widget, NavWidget
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.ui.widgets.label import MiciLabel
+from openpilot.system.ui.widgets.html_render import HtmlModal, HtmlRenderer
+from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID
+
+
+class MiciFccModal(NavWidget):
+ BACK_TOUCH_AREA_PERCENTAGE = 0.1
+
+ def __init__(self, file_path: str | None = None, text: str | None = None):
+ super().__init__()
+ self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
+ self._content = HtmlRenderer(file_path=file_path, text=text)
+ self._scroll_panel = GuiScrollPanel2(horizontal=False)
+ self._fcc_logo = gui_app.texture("icons_mici/settings/device/fcc_logo.png", 76, 64)
+
+ def _render(self, rect: rl.Rectangle):
+ content_height = self._content.get_total_height(int(rect.width))
+ content_height += self._fcc_logo.height + 20
+
+ scroll_content_rect = rl.Rectangle(rect.x, rect.y, rect.width, content_height)
+ scroll_offset = self._scroll_panel.update(rect, scroll_content_rect.height)
+
+ fcc_pos = rl.Vector2(rect.x + 20, rect.y + 20 + scroll_offset)
+
+ scroll_content_rect.y += scroll_offset + self._fcc_logo.height + 20
+ self._content.render(scroll_content_rect)
+
+ rl.draw_texture_ex(self._fcc_logo, fcc_pos, 0.0, 1.0, rl.WHITE)
+
+ return -1
+
+
+def _engaged_confirmation_callback(callback: Callable, action_text: str):
+ if not ui_state.engaged:
+ def confirm_callback():
+ # Check engaged again in case it changed while the dialog was open
+ if not ui_state.engaged:
+ callback()
+
+ red = False
+ if action_text == "power off":
+ icon = "icons_mici/settings/device/power.png"
+ red = True
+ elif action_text == "reboot":
+ icon = "icons_mici/settings/device/reboot.png"
+ elif action_text == "reset":
+ icon = "icons_mici/settings/device/lkas.png"
+ elif action_text == "uninstall":
+ icon = "icons_mici/settings/device/uninstall.png"
+ else:
+ # TODO: check
+ icon = "icons_mici/settings/comma_icon.png"
+
+ dlg: BigConfirmationDialogV2 | BigDialog = BigConfirmationDialogV2(f"slide to\n{action_text.lower()}", icon, red=red,
+ exit_on_confirm=action_text == "reset",
+ confirm_callback=confirm_callback)
+ gui_app.set_modal_overlay(dlg)
+ else:
+ dlg = BigDialog(f"Disengage to {action_text}", "")
+ gui_app.set_modal_overlay(dlg)
+
+
+class DeviceInfoLayoutMici(Widget):
+ def __init__(self):
+ super().__init__()
+
+ self.set_rect(rl.Rectangle(0, 0, 360, 180))
+
+ params = Params()
+ header_color = rl.Color(255, 255, 255, int(255 * 0.9))
+ subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
+ max_width = int(self._rect.width - 20)
+ self._dongle_id_label = MiciLabel("device ID", 48, width=max_width, color=header_color, font_weight=FontWeight.DISPLAY)
+ self._dongle_id_text_label = MiciLabel(params.get("DongleId") or 'N/A', 32, width=max_width, color=subheader_color, font_weight=FontWeight.ROMAN)
+
+ self._serial_number_label = MiciLabel("serial", 48, color=header_color, font_weight=FontWeight.DISPLAY)
+ self._serial_number_text_label = MiciLabel(params.get("HardwareSerial") or 'N/A', 32, width=max_width, color=subheader_color, font_weight=FontWeight.ROMAN)
+
+ def _render(self, _):
+ self._dongle_id_label.set_position(self._rect.x + 20, self._rect.y - 10)
+ self._dongle_id_label.render()
+
+ self._dongle_id_text_label.set_position(self._rect.x + 20, self._rect.y + 68 - 25)
+ self._dongle_id_text_label.render()
+
+ self._serial_number_label.set_position(self._rect.x + 20, self._rect.y + 114 - 30)
+ self._serial_number_label.render()
+
+ self._serial_number_text_label.set_position(self._rect.x + 20, self._rect.y + 161 - 25)
+ self._serial_number_text_label.render()
+
+
+class UpdaterState(IntEnum):
+ IDLE = 0
+ WAITING_FOR_UPDATER = 1
+ UPDATER_RESPONDING = 2
+
+
+class PairBigButton(BigButton):
+ def __init__(self):
+ super().__init__("pair", "connect.comma.ai", "icons_mici/settings/comma_icon.png")
+
+ def _update_state(self):
+ if ui_state.prime_state.is_paired():
+ self.set_text("paired")
+ if ui_state.prime_state.is_prime():
+ self.set_value("subscribed")
+ else:
+ self.set_value("upgrade to prime")
+ else:
+ self.set_text("pair")
+ self.set_value("connect.comma.ai")
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+
+ # TODO: show ad dialog when clicked if not prime
+ if ui_state.prime_state.is_paired():
+ return
+ dlg: BigDialog | PairingDialog
+ if not system_time_valid():
+ dlg = BigDialog(tr("Please connect to Wi-Fi to complete initial pairing"), "")
+ elif UNREGISTERED_DONGLE_ID == (ui_state.params.get("DongleId") or UNREGISTERED_DONGLE_ID):
+ dlg = BigDialog(tr("Device must be registered with the comma.ai backend to pair"), "")
+ else:
+ dlg = PairingDialog()
+ gui_app.set_modal_overlay(dlg)
+
+
+UPDATER_TIMEOUT = 10.0 # seconds to wait for updater to respond
+
+
+class UpdateOpenpilotBigButton(BigButton):
+ def __init__(self):
+ self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64)
+ self._txt_reboot_icon = gui_app.texture("icons_mici/settings/device/reboot.png", 64, 64)
+ self._txt_up_to_date_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64)
+ super().__init__("update openpilot", "", self._txt_update_icon)
+
+ self._waiting_for_updater_t: float | None = None
+ self._hide_value_t: float | None = None
+ self._state: UpdaterState = UpdaterState.IDLE
+
+ ui_state.add_offroad_transition_callback(self.offroad_transition)
+
+ def offroad_transition(self):
+ if ui_state.is_offroad():
+ self.set_enabled(True)
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ self.set_enabled(False)
+ self._state = UpdaterState.WAITING_FOR_UPDATER
+ self.set_icon(self._txt_update_icon)
+
+ def run():
+ if self.get_value() == "download update":
+ os.system("pkill -SIGHUP -f system.updated.updated")
+ elif self.get_value() == "update now":
+ ui_state.params.put_bool("DoReboot", True)
+ else:
+ os.system("pkill -SIGUSR1 -f system.updated.updated")
+
+ threading.Thread(target=run, daemon=True).start()
+
+ def set_value(self, value: str):
+ super().set_value(value)
+ if value:
+ self.set_text("")
+ else:
+ self.set_text("update openpilot")
+
+ def _update_state(self):
+ if ui_state.started:
+ self.set_enabled(False)
+ return
+
+ updater_state = ui_state.params.get("UpdaterState") or ""
+ failed_count = ui_state.params.get("UpdateFailedCount")
+ failed = False if failed_count is None else int(failed_count) > 0
+
+ if ui_state.params.get_bool("UpdateAvailable"):
+ self.set_rotate_icon(False)
+ self.set_enabled(True)
+ if self.get_value() != "update now":
+ self.set_value("update now")
+ self.set_icon(self._txt_reboot_icon)
+
+ elif self._state == UpdaterState.WAITING_FOR_UPDATER:
+ self.set_rotate_icon(True)
+ if updater_state != "idle":
+ self._state = UpdaterState.UPDATER_RESPONDING
+
+ # Recover from updater not responding (time invalid shortly after boot)
+ if self._waiting_for_updater_t is None:
+ self._waiting_for_updater_t = rl.get_time()
+
+ if self._waiting_for_updater_t is not None and rl.get_time() - self._waiting_for_updater_t > UPDATER_TIMEOUT:
+ self.set_rotate_icon(False)
+ self.set_value("updater failed to respond")
+ self._state = UpdaterState.IDLE
+ self._hide_value_t = rl.get_time()
+
+ elif self._state == UpdaterState.UPDATER_RESPONDING:
+ if updater_state == "idle":
+ self.set_rotate_icon(False)
+ self._state = UpdaterState.IDLE
+ self._hide_value_t = rl.get_time()
+ else:
+ if self.get_value() != updater_state:
+ self.set_value(updater_state)
+
+ elif self._state == UpdaterState.IDLE:
+ self.set_rotate_icon(False)
+ if failed:
+ if self.get_value() != "failed to update":
+ self.set_value("failed to update")
+
+ elif ui_state.params.get_bool("UpdaterFetchAvailable"):
+ self.set_enabled(True)
+ if self.get_value() != "download update":
+ self.set_value("download update")
+
+ elif self._hide_value_t is not None:
+ self.set_enabled(True)
+ if self.get_value() == "checking...":
+ self.set_value("up to date")
+ self.set_icon(self._txt_up_to_date_icon)
+
+ # Hide previous text after short amount of time (up to date or failed)
+ if rl.get_time() - self._hide_value_t > 3.0:
+ self._hide_value_t = None
+ self.set_value("")
+ self.set_icon(self._txt_update_icon)
+ else:
+ if self.get_value() != "":
+ self.set_value("")
+
+ if self._state != UpdaterState.WAITING_FOR_UPDATER:
+ self._waiting_for_updater_t = None
+
+
+class DeviceLayoutMici(NavWidget):
+ def __init__(self, back_callback: Callable):
+ super().__init__()
+
+ self._fcc_dialog: HtmlModal | None = None
+ self._driver_camera: DriverCameraDialog | None = None
+ self._training_guide: TrainingGuide | None = None
+
+ def power_off_callback():
+ ui_state.params.put_bool("DoShutdown", True)
+
+ def reboot_callback():
+ ui_state.params.put_bool("DoReboot", True)
+
+ def reset_calibration_callback():
+ params = ui_state.params
+ params.remove("CalibrationParams")
+ params.remove("LiveTorqueParameters")
+ params.remove("LiveParameters")
+ params.remove("LiveParametersV2")
+ params.remove("LiveDelay")
+ params.put_bool("OnroadCycleRequested", True)
+
+ def uninstall_openpilot_callback():
+ ui_state.params.put_bool("DoUninstall", True)
+
+ reset_calibration_btn = BigButton("reset calibration", "", "icons_mici/settings/device/lkas.png")
+ reset_calibration_btn.set_click_callback(lambda: _engaged_confirmation_callback(reset_calibration_callback, "reset"))
+
+ uninstall_openpilot_btn = BigButton("uninstall openpilot", "", "icons_mici/settings/device/uninstall.png")
+ uninstall_openpilot_btn.set_click_callback(lambda: _engaged_confirmation_callback(uninstall_openpilot_callback, "uninstall"))
+
+ reboot_btn = BigCircleButton("icons_mici/settings/device/reboot.png", red=False)
+ reboot_btn.set_click_callback(lambda: _engaged_confirmation_callback(reboot_callback, "reboot"))
+
+ self._power_off_btn = BigCircleButton("icons_mici/settings/device/power.png", red=True)
+ self._power_off_btn.set_click_callback(lambda: _engaged_confirmation_callback(power_off_callback, "power off"))
+
+ self._load_languages()
+
+ def language_callback():
+ def selected_language_callback():
+ selected_language = dlg.get_selected_option()
+ ui_state.params.put("LanguageSetting", self._languages[selected_language])
+
+ current_language_name = ui_state.params.get("LanguageSetting")
+ current_language = next(name for name, lang in self._languages.items() if lang == current_language_name)
+
+ dlg = BigMultiOptionDialog(list(self._languages), default=current_language, right_btn_callback=selected_language_callback)
+ gui_app.set_modal_overlay(dlg)
+
+ # lang_button = BigButton("change language", "", "icons_mici/settings/device/language.png")
+ # lang_button.set_click_callback(language_callback)
+
+ regulatory_btn = BigButton("regulatory info", "", "icons_mici/settings/device/info.png")
+ regulatory_btn.set_click_callback(self._on_regulatory)
+
+ driver_cam_btn = BigButton("driver camera preview", "", "icons_mici/settings/device/cameras.png")
+ driver_cam_btn.set_click_callback(self._show_driver_camera)
+ driver_cam_btn.set_enabled(lambda: ui_state.is_offroad())
+
+ review_training_guide_btn = BigButton("review training guide", "", "icons_mici/settings/device/info.png")
+ review_training_guide_btn.set_click_callback(self._on_review_training_guide)
+ review_training_guide_btn.set_enabled(lambda: ui_state.is_offroad())
+
+ self._scroller = Scroller([
+ DeviceInfoLayoutMici(),
+ UpdateOpenpilotBigButton(),
+ PairBigButton(),
+ review_training_guide_btn,
+ driver_cam_btn,
+ # lang_button,
+ reset_calibration_btn,
+ uninstall_openpilot_btn,
+ regulatory_btn,
+ reboot_btn,
+ self._power_off_btn,
+ ], snap_items=False)
+
+ # Set up back navigation
+ self.set_back_callback(back_callback)
+
+ # Hide power off button when onroad
+ ui_state.add_offroad_transition_callback(self._offroad_transition)
+
+ def _on_regulatory(self):
+ if not self._fcc_dialog:
+ self._fcc_dialog = MiciFccModal(os.path.join(BASEDIR, "selfdrive/assets/offroad/mici_fcc.html"))
+ gui_app.set_modal_overlay(self._fcc_dialog, callback=setattr(self, '_fcc_dialog', None))
+
+ def _offroad_transition(self):
+ self._power_off_btn.set_visible(ui_state.is_offroad())
+
+ 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 _on_review_training_guide(self):
+ if not self._training_guide:
+ def completed_callback():
+ gui_app.set_modal_overlay(None)
+
+ self._training_guide = TrainingGuide(completed_callback=completed_callback)
+ gui_app.set_modal_overlay(self._training_guide, callback=lambda result: setattr(self, '_training_guide', None))
+
+ def _load_languages(self):
+ with open(os.path.join(BASEDIR, "selfdrive/ui/translations/languages.json")) as f:
+ self._languages = json.load(f)
+
+ def show_event(self):
+ super().show_event()
+ self._scroller.show_event()
+
+ def _render(self, rect: rl.Rectangle):
+ self._scroller.render(rect)
diff --git a/selfdrive/ui/mici/layouts/settings/firehose.py b/selfdrive/ui/mici/layouts/settings/firehose.py
new file mode 100644
index 000000000..303d976b0
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/settings/firehose.py
@@ -0,0 +1,223 @@
+import threading
+import time
+import pyray as rl
+
+from openpilot.common.api import api_get
+from openpilot.common.params import Params
+from openpilot.common.swaglog import cloudlog
+from openpilot.selfdrive.ui.lib.api_helpers import get_token
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID
+from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
+from openpilot.system.ui.lib.wrap_text import wrap_text
+from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
+from openpilot.system.ui.lib.multilang import tr, trn, tr_noop
+from openpilot.system.ui.widgets import NavWidget
+
+
+TITLE = tr_noop("Firehose Mode")
+DESCRIPTION = tr_noop(
+ "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_INTRO = tr_noop(
+ "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."
+)
+FAQ_HEADER = tr_noop("Frequently Asked Questions")
+FAQ_ITEMS = [
+ (tr_noop("Does it matter how or where I drive?"), tr_noop("Nope, just drive as you normally would.")),
+ (tr_noop("Do all of my segments get pulled in Firehose Mode?"), tr_noop("No, we selectively pull a subset of your segments.")),
+ (tr_noop("What's a good USB-C adapter?"), tr_noop("Any fast phone or laptop charger should be fine.")),
+ (tr_noop("Does it matter which software I run?"), tr_noop("Yes, only upstream openpilot (and particular forks) are able to be used for training.")),
+]
+
+
+class FirehoseLayoutMici(NavWidget):
+ BACK_TOUCH_AREA_PERCENTAGE = 0.1
+
+ 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, back_callback):
+ super().__init__()
+ self.set_back_callback(back_callback)
+
+ self.params = Params()
+ self.segment_count = self._get_segment_count()
+
+ self._scroll_panel = GuiScrollPanel2(horizontal=False)
+ self._content_height = 0
+
+ self._running = True
+ self._update_thread = threading.Thread(target=self._update_loop, daemon=True)
+ self._update_thread.start()
+
+ def __del__(self):
+ self._running = False
+ try:
+ if self._update_thread and self._update_thread.is_alive():
+ self._update_thread.join(timeout=1.0)
+ except Exception:
+ pass
+
+ def show_event(self):
+ super().show_event()
+ self._scroll_panel.set_offset(0)
+
+ def _get_segment_count(self) -> int:
+ stats = self.params.get(self.PARAM_KEY)
+ if not stats:
+ return 0
+ try:
+ return int(stats.get("firehose", 0))
+ except Exception:
+ cloudlog.exception(f"Failed to decode firehose stats: {stats}")
+ return 0
+
+ def _render(self, rect: rl.Rectangle):
+ # compute total content height for scrolling
+ content_height = self._measure_content_height(rect)
+ scroll_offset = self._scroll_panel.update(rect, content_height)
+
+ # start drawing with offset
+ x = int(rect.x + 40)
+ y = int(rect.y + 40 + scroll_offset)
+ w = int(rect.width - 80)
+
+ # Title
+ title_text = tr(TITLE)
+ title_font = gui_app.font(FontWeight.BOLD)
+ title_size = 64
+ rl.draw_text_ex(title_font, title_text, rl.Vector2(x, y), title_size, 0, rl.WHITE)
+ y += int(title_size * FONT_SCALE) + 20
+
+ # Description
+ y = self._draw_wrapped_text(x, y, w, tr(DESCRIPTION), gui_app.font(FontWeight.ROMAN), 36, rl.WHITE)
+ y += 20
+
+ # Separator
+ rl.draw_rectangle(x, y, w, 2, self.GRAY)
+ y += 20
+
+ # Status
+ status_text, status_color = self._get_status()
+ y = self._draw_wrapped_text(x, y, w, status_text, gui_app.font(FontWeight.BOLD), 48, status_color)
+ y += 20
+
+ # Contribution count (if available)
+ if self.segment_count > 0:
+ contrib_text = trn("{} segment of your driving is in the training dataset so far.",
+ "{} segments of your driving is in the training dataset so far.", self.segment_count).format(self.segment_count)
+ y = self._draw_wrapped_text(x, y, w, contrib_text, gui_app.font(FontWeight.BOLD), 42, rl.WHITE)
+ y += 20
+
+ # Separator
+ rl.draw_rectangle(x, y, w, 2, self.GRAY)
+ y += 20
+
+ # Instructions intro
+ y = self._draw_wrapped_text(x, y, w, tr(INSTRUCTIONS_INTRO), gui_app.font(FontWeight.ROMAN), 32, self.LIGHT_GRAY)
+ y += 20
+
+ # FAQ Header
+ y = self._draw_wrapped_text(x, y, w, tr(FAQ_HEADER), gui_app.font(FontWeight.BOLD), 44, rl.WHITE)
+ y += 20
+
+ # FAQ Items
+ for question, answer in FAQ_ITEMS:
+ y = self._draw_wrapped_text(x, y, w, tr(question), gui_app.font(FontWeight.BOLD), 32, self.LIGHT_GRAY)
+ y = self._draw_wrapped_text(x, y, w, tr(answer), gui_app.font(FontWeight.ROMAN), 32, self.LIGHT_GRAY)
+ y += 20
+
+ # return value not used by NavWidget
+ return -1
+
+ def _draw_wrapped_text(self, x, y, width, text, font, font_size, color):
+ wrapped = wrap_text(font, text, font_size, width)
+ for line in wrapped:
+ rl.draw_text_ex(font, line, rl.Vector2(x, y), font_size, 0, color)
+ y += int(font_size * FONT_SCALE)
+ return y
+
+ def _measure_content_height(self, rect: rl.Rectangle) -> int:
+ # Rough measurement using the same wrapping as rendering
+ w = int(rect.width - 80)
+ y = 40
+
+ # Title
+ title_size = 72
+ y += int(title_size * FONT_SCALE) + 20
+
+ # Description
+ desc_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(DESCRIPTION), 36, w)
+ y += int(len(desc_lines) * 36 * FONT_SCALE) + 20
+
+ # Separator + Status
+ y += 2 + 20
+ status_text, _ = self._get_status()
+ status_lines = wrap_text(gui_app.font(FontWeight.BOLD), status_text, 48, w)
+ y += int(len(status_lines) * 48 * FONT_SCALE) + 20
+
+ # Contribution count
+ if self.segment_count > 0:
+ contrib_text = trn("{} segment of your driving is in the training dataset so far.",
+ "{} segments of your driving is in the training dataset so far.", self.segment_count).format(self.segment_count)
+ contrib_lines = wrap_text(gui_app.font(FontWeight.BOLD), contrib_text, 42, w)
+ y += int(len(contrib_lines) * 42 * FONT_SCALE) + 20
+
+ # Separator + Instructions
+ y += 2 + 20
+
+ # Instructions intro
+ intro_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(INSTRUCTIONS_INTRO), 32, w)
+ y += int(len(intro_lines) * 32 * FONT_SCALE) + 20
+
+ # FAQ Header
+ faq_header_lines = wrap_text(gui_app.font(FontWeight.BOLD), tr(FAQ_HEADER), 44, w)
+ y += int(len(faq_header_lines) * 44 * FONT_SCALE) + 20
+
+ # FAQ Items
+ for question, answer in FAQ_ITEMS:
+ q_lines = wrap_text(gui_app.font(FontWeight.BOLD), tr(question), 32, w)
+ y += int(len(q_lines) * 32 * FONT_SCALE)
+ a_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(answer), 32, w)
+ y += int(len(a_lines) * 32 * FONT_SCALE) + 20
+
+ # bottom padding
+ y += 40
+ 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 tr("ACTIVE"), self.GREEN
+ else:
+ return tr("INACTIVE: connect to an unmetered network"), self.RED
+
+ def _fetch_firehose_stats(self):
+ try:
+ dongle_id = self.params.get("DongleId")
+ if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID:
+ return
+ identity_token = get_token(dongle_id)
+ 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, 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)
diff --git a/selfdrive/ui/mici/layouts/settings/network.py b/selfdrive/ui/mici/layouts/settings/network.py
new file mode 100644
index 000000000..a62c1d153
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/settings/network.py
@@ -0,0 +1,558 @@
+import math
+import numpy as np
+import pyray as rl
+from enum import IntEnum
+from collections.abc import Callable
+
+from openpilot.common.swaglog import cloudlog
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.label import UnifiedLabel
+from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigToggle
+from openpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog, BigInputDialog, BigDialogOptionButton, BigConfirmationDialogV2
+from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
+from openpilot.system.ui.widgets import Widget, NavWidget
+from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType, MeteredType
+
+
+def normalize_ssid(ssid: str) -> str:
+ return ssid.replace("’", "'") # for iPhone hotspots
+
+
+class NetworkPanelType(IntEnum):
+ NONE = 0
+ WIFI = 1
+
+
+class LoadingAnimation(Widget):
+ def _render(self, _):
+ cx = int(self._rect.x + 70)
+ cy = int(self._rect.y + self._rect.height / 2 - 50)
+
+ y_mag = 20
+ anim_scale = 5
+ spacing = 28
+
+ for i in range(3):
+ x = cx - spacing + i * spacing
+ y = int(cy + min(math.sin((rl.get_time() - i * 0.2) * anim_scale) * y_mag, 0))
+ alpha = int(np.interp(cy - y, [0, y_mag], [255 * 0.45, 255 * 0.9]))
+ rl.draw_circle(x, y, 10, rl.Color(255, 255, 255, alpha))
+
+
+class WifiIcon(Widget):
+ def __init__(self):
+ super().__init__()
+ self.set_rect(rl.Rectangle(0, 0, 89, 64))
+
+ self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 89, 64)
+ self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 89, 64)
+ self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 89, 64)
+ self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 89, 64)
+ self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 89, 64)
+ self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 23, 32)
+
+ self._network: Network | None = None
+ self._scale = 1.0
+
+ def set_current_network(self, network: Network):
+ self._network = network
+
+ def set_scale(self, scale: float):
+ self._scale = scale
+
+ def _render(self, _):
+ if self._network is None:
+ return
+
+ # Determine which wifi strength icon to use
+ strength = round(self._network.strength / 100 * 4)
+ if strength == 4:
+ strength_icon = self._wifi_full_txt
+ elif strength == 3:
+ strength_icon = self._wifi_medium_txt
+ elif strength == 2:
+ strength_icon = self._wifi_low_txt
+ elif self._network.strength < 0:
+ strength_icon = self._wifi_slash_txt
+ else:
+ strength_icon = self._wifi_none_txt
+
+ icon_x = int(self._rect.x + (self._rect.width - strength_icon.width * self._scale) // 2)
+ icon_y = int(self._rect.y + (self._rect.height - strength_icon.height * self._scale) // 2)
+ rl.draw_texture_ex(strength_icon, (icon_x, icon_y), 0.0, self._scale, rl.WHITE)
+
+ # Render lock icon at lower right of wifi icon if secured
+ if self._network.security_type not in (SecurityType.OPEN, SecurityType.UNSUPPORTED):
+ lock_scale = self._scale * 1.1
+ lock_x = int(icon_x + 1 + strength_icon.width * self._scale - self._lock_txt.width * lock_scale / 2)
+ lock_y = int(icon_y + 1 + strength_icon.height * self._scale - self._lock_txt.height * lock_scale / 2)
+ rl.draw_texture_ex(self._lock_txt, (lock_x, lock_y), 0.0, lock_scale, rl.WHITE)
+
+
+class WifiItem(BigDialogOptionButton):
+ LEFT_MARGIN = 20
+
+ def __init__(self, network: Network):
+ super().__init__(network.ssid)
+
+ self.set_rect(rl.Rectangle(0, 0, gui_app.width, 64))
+
+ self._selected_txt = gui_app.texture("icons_mici/settings/network/new/wifi_selected.png", 48, 96)
+
+ self._network = network
+ self._wifi_icon = WifiIcon()
+ self._wifi_icon.set_current_network(network)
+
+ def set_current_network(self, network: Network):
+ self._network = network
+ self._wifi_icon.set_current_network(network)
+
+ def _render(self, _):
+ if self._network.is_connected:
+ selected_x = int(self._rect.x - self._selected_txt.width / 2)
+ selected_y = int(self._rect.y + (self._rect.height - self._selected_txt.height) / 2)
+ rl.draw_texture(self._selected_txt, selected_x, selected_y, rl.WHITE)
+
+ self._wifi_icon.set_scale((1.0 if self._selected else 0.65) * 0.7)
+ self._wifi_icon.render(rl.Rectangle(
+ self._rect.x + self.LEFT_MARGIN,
+ self._rect.y,
+ self._rect.height,
+ self._rect.height
+ ))
+
+ if self._selected:
+ self._label.set_font_size(74)
+ self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.9)))
+ self._label.set_font_weight(FontWeight.DISPLAY)
+ else:
+ self._label.set_font_size(70)
+ self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.58)))
+ self._label.set_font_weight(FontWeight.DISPLAY_REGULAR)
+
+ label_offset = self.LEFT_MARGIN + self._wifi_icon.rect.width + 20
+ label_rect = rl.Rectangle(self._rect.x + label_offset, self._rect.y, self._rect.width - label_offset, self._rect.height)
+ self._label.set_text(normalize_ssid(self._network.ssid))
+ self._label.render(label_rect)
+
+
+class ConnectButton(Widget):
+ def __init__(self):
+ super().__init__()
+ self._bg_txt = gui_app.texture("icons_mici/settings/network/new/connect_button.png", 410, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/connect_button_pressed.png", 410, 100)
+ self._bg_full_txt = gui_app.texture("icons_mici/settings/network/new/full_connect_button.png", 520, 100)
+ self._bg_full_pressed_txt = gui_app.texture("icons_mici/settings/network/new/full_connect_button_pressed.png", 520, 100)
+
+ self._full: bool = False
+
+ self._label = UnifiedLabel("", 36, FontWeight.MEDIUM, rl.Color(255, 255, 255, int(255 * 0.9)),
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
+
+ @property
+ def full(self) -> bool:
+ return self._full
+
+ def set_full(self, full: bool):
+ self._full = full
+ self.set_rect(rl.Rectangle(0, 0, 520 if self._full else 410, 100))
+
+ def set_label(self, text: str):
+ self._label.set_text(text)
+
+ def _render(self, _):
+ if self._full:
+ bg_txt = self._bg_full_pressed_txt if self.is_pressed and self.enabled else self._bg_full_txt
+ else:
+ bg_txt = self._bg_pressed_txt if self.is_pressed and self.enabled else self._bg_txt
+
+ rl.draw_texture(bg_txt, int(self._rect.x), int(self._rect.y), rl.WHITE)
+
+ self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9) if self.enabled else int(255 * 0.9 * 0.65)))
+ self._label.render(self._rect)
+
+
+class ForgetButton(Widget):
+ HORIZONTAL_MARGIN = 8
+
+ def __init__(self, forget_network: Callable, open_network_manage_page):
+ super().__init__()
+ self._forget_network = forget_network
+ self._open_network_manage_page = open_network_manage_page
+
+ self._bg_txt = gui_app.texture("icons_mici/settings/network/new/forget_button.png", 100, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/forget_button_pressed.png", 100, 100)
+ self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 32, 36)
+ self.set_rect(rl.Rectangle(0, 0, 100 + self.HORIZONTAL_MARGIN * 2, 100))
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+ dlg = BigConfirmationDialogV2("slide to forget", "icons_mici/settings/network/new/trash.png", red=True,
+ confirm_callback=self._forget_network)
+ gui_app.set_modal_overlay(dlg, callback=self._open_network_manage_page)
+
+ def _render(self, _):
+ bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt
+ rl.draw_texture(bg_txt, int(self._rect.x + self.HORIZONTAL_MARGIN), int(self._rect.y), rl.WHITE)
+
+ trash_x = int(self._rect.x + (self._rect.width - self._trash_txt.width) // 2)
+ trash_y = int(self._rect.y + (self._rect.height - self._trash_txt.height) // 2)
+ rl.draw_texture(self._trash_txt, trash_x, trash_y, rl.WHITE)
+
+
+class NetworkInfoPage(NavWidget):
+ def __init__(self, wifi_manager, connect_callback: Callable, forget_callback: Callable, open_network_manage_page: Callable):
+ super().__init__()
+ self._wifi_manager = wifi_manager
+
+ self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+
+ self._wifi_icon = WifiIcon()
+ self._forget_btn = ForgetButton(lambda: forget_callback(self._network.ssid) if self._network is not None else None,
+ open_network_manage_page)
+ self._connect_btn = ConnectButton()
+ self._connect_btn.set_click_callback(lambda: connect_callback(self._network.ssid) if self._network is not None else None)
+
+ self._title = UnifiedLabel("", 64, FontWeight.DISPLAY, rl.Color(255, 255, 255, int(255 * 0.9)),
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
+ self._subtitle = UnifiedLabel("", 36, FontWeight.ROMAN, rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)),
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
+
+ self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
+
+ # State
+ self._network: Network | None = None
+ self._connecting: Callable[[], str | None] | None = None
+
+ def update_networks(self, networks: dict[str, Network]):
+ # update current network from latest scan results
+ for ssid, network in networks.items():
+ if self._network is not None and ssid == self._network.ssid:
+ self.set_current_network(network)
+ break
+ else:
+ # network disappeared, close page
+ gui_app.set_modal_overlay(None)
+
+ def _update_state(self):
+ super()._update_state()
+ # Modal overlays stop main UI rendering, so we need to call here
+ self._wifi_manager.process_callbacks()
+
+ if self._network is None:
+ return
+
+ self._connect_btn.set_full(not self._network.is_saved and not self._is_connecting)
+ if self._is_connecting:
+ self._connect_btn.set_label("connecting...")
+ self._connect_btn.set_enabled(False)
+ elif self._network.is_connected:
+ self._connect_btn.set_label("connected")
+ self._connect_btn.set_enabled(False)
+ elif self._network.security_type == SecurityType.UNSUPPORTED:
+ self._connect_btn.set_label("connect")
+ self._connect_btn.set_enabled(False)
+ else: # saved or unknown
+ self._connect_btn.set_label("connect")
+ self._connect_btn.set_enabled(True)
+
+ self._title.set_text(normalize_ssid(self._network.ssid))
+ if self._network.security_type == SecurityType.OPEN:
+ self._subtitle.set_text("open")
+ elif self._network.security_type == SecurityType.UNSUPPORTED:
+ self._subtitle.set_text("unsupported")
+ else:
+ self._subtitle.set_text("secured")
+
+ def set_current_network(self, network: Network):
+ self._network = network
+ self._wifi_icon.set_current_network(network)
+
+ def set_connecting(self, is_connecting: Callable[[], str | None]):
+ self._connecting = is_connecting
+
+ @property
+ def _is_connecting(self):
+ if self._connecting is None or self._network is None:
+ return False
+ is_connecting = self._connecting() == self._network.ssid
+ return is_connecting
+
+ def _render(self, _):
+ self._wifi_icon.render(rl.Rectangle(
+ self._rect.x + 32,
+ self._rect.y + (self._rect.height - self._connect_btn.rect.height - self._wifi_icon.rect.height) / 2,
+ self._wifi_icon.rect.width,
+ self._wifi_icon.rect.height,
+ ))
+
+ self._title.render(rl.Rectangle(
+ self._rect.x + self._wifi_icon.rect.width + 32 + 32,
+ self._rect.y + 32 - 16,
+ self._rect.width - (self._wifi_icon.rect.width + 32 + 32),
+ 64,
+ ))
+
+ self._subtitle.render(rl.Rectangle(
+ self._rect.x + self._wifi_icon.rect.width + 32 + 32,
+ self._rect.y + 32 + 64 - 16,
+ self._rect.width - (self._wifi_icon.rect.width + 32 + 32),
+ 48,
+ ))
+
+ self._connect_btn.render(rl.Rectangle(
+ self._rect.x + 8,
+ self._rect.y + self._rect.height - self._connect_btn.rect.height,
+ self._connect_btn.rect.width,
+ self._connect_btn.rect.height,
+ ))
+
+ if not self._connect_btn.full:
+ self._forget_btn.render(rl.Rectangle(
+ self._rect.x + self._rect.width - self._forget_btn.rect.width,
+ self._rect.y + self._rect.height - self._forget_btn.rect.height,
+ self._forget_btn.rect.width,
+ self._forget_btn.rect.height,
+ ))
+
+ return -1
+
+
+class WifiUIMici(BigMultiOptionDialog):
+ def __init__(self, wifi_manager: WifiManager, back_callback: Callable):
+ super().__init__([], None, None, right_btn_callback=None)
+
+ # Set up back navigation
+ self.set_back_callback(back_callback)
+
+ self._network_info_page = NetworkInfoPage(wifi_manager, self._connect_to_network, self._forget_network, self._open_network_manage_page)
+ self._network_info_page.set_connecting(lambda: self._connecting)
+ self._should_open_network_info_page = False # wait for scroll_to animation
+
+ self._loading_animation = LoadingAnimation()
+
+ self._wifi_manager = wifi_manager
+ self._connecting: str | None = None
+ self._networks: dict[str, Network] = {}
+
+ self._wifi_manager.add_callbacks(
+ need_auth=self._on_need_auth,
+ activated=self._on_activated,
+ forgotten=self._on_forgotten,
+ networks_updated=self._on_network_updated,
+ disconnected=self._on_disconnected,
+ )
+
+ def show_event(self):
+ # Call super to prepare scroller; selection scroll is handled dynamically
+ super().show_event()
+ self._wifi_manager.set_active(True)
+ self._scroller.show_event()
+
+ def hide_event(self):
+ super().hide_event()
+ self._wifi_manager.set_active(False)
+
+ def _update_state(self):
+ super()._update_state()
+ if self._should_open_network_info_page:
+ self._should_open_network_info_page = False
+ self._open_network_manage_page()
+
+ def _open_network_manage_page(self, result=None):
+ self._network_info_page.update_networks(self._networks)
+ gui_app.set_modal_overlay(self._network_info_page)
+
+ def _forget_network(self, ssid: str):
+ network = self._networks.get(ssid)
+ if network is None:
+ cloudlog.warning(f"Trying to forget unknown network: {ssid}")
+ return
+
+ self._wifi_manager.forget_connection(network.ssid)
+
+ def _on_network_updated(self, networks: list[Network]):
+ self._networks = {network.ssid: network for network in networks}
+ self._update_buttons()
+ self._network_info_page.update_networks(self._networks)
+
+ def _update_buttons(self):
+ for network in self._networks.values():
+ # pop and re-insert to eliminate stuttering on update (prevents position lost for a frame)
+ network_button_idx = next((i for i, btn in enumerate(self._scroller._items) if btn.option == network.ssid), None)
+ if network_button_idx is not None:
+ network_button = self._scroller._items.pop(network_button_idx)
+ # Update network on existing button
+ network_button.set_current_network(network)
+ else:
+ network_button = WifiItem(network)
+
+ def show_network_info_page(_network):
+ self._network_info_page.set_current_network(_network)
+ self._should_open_network_info_page = True
+
+ network_button.set_click_callback(lambda _net=network,_button=network_button: _button._selected and show_network_info_page(_net))
+
+ self.add_button(network_button)
+
+ # remove networks no longer present
+ self._scroller._items[:] = [btn for btn in self._scroller._items if btn.option in self._networks]
+
+ def _connect_with_password(self, ssid: str, password: str):
+ if password:
+ self._connecting = ssid
+ self._wifi_manager.connect_to_network(ssid, password)
+ self._update_buttons()
+
+ def _connect_to_network(self, ssid: str):
+ network = self._networks.get(ssid)
+ if network is None:
+ cloudlog.warning(f"Trying to connect to unknown network: {ssid}")
+ return
+
+ if network.is_saved:
+ self._connecting = network.ssid
+ self._wifi_manager.activate_connection(network.ssid)
+ self._update_buttons()
+ elif network.security_type == SecurityType.OPEN:
+ self._connecting = network.ssid
+ self._wifi_manager.connect_to_network(network.ssid, "")
+ self._update_buttons()
+ else:
+ self._on_need_auth(network.ssid, False)
+
+ def _on_need_auth(self, ssid, incorrect_password=True):
+ hint = "incorrect password..." if incorrect_password else "enter password..."
+ dlg = BigInputDialog(hint, "", minimum_length=8,
+ confirm_callback=lambda _password: self._connect_with_password(ssid, _password))
+ # go back to the manage network page
+ gui_app.set_modal_overlay(dlg, self._open_network_manage_page)
+
+ def _on_activated(self):
+ self._connecting = None
+
+ def _on_forgotten(self):
+ self._connecting = None
+
+ def _on_disconnected(self):
+ self._connecting = None
+
+ def _render(self, _):
+ super()._render(_)
+
+ if not self._networks:
+ self._loading_animation.render(self._rect)
+
+
+class NetworkLayoutMici(NavWidget):
+ def __init__(self, back_callback: Callable):
+ super().__init__()
+
+ self._current_panel = NetworkPanelType.WIFI
+ self.set_back_enabled(lambda: self._current_panel == NetworkPanelType.NONE)
+
+ self._wifi_manager = WifiManager()
+ self._wifi_manager.set_active(False)
+ self._wifi_ui = WifiUIMici(self._wifi_manager, back_callback=lambda: self._switch_to_panel(NetworkPanelType.NONE))
+
+ self._wifi_manager.add_callbacks(
+ networks_updated=self._on_network_updated,
+ )
+
+ _tethering_icon = "icons_mici/settings/network/tethering.png"
+
+ # ******** Tethering ********
+ def tethering_toggle_callback(checked: bool):
+ self._tethering_toggle_btn.set_enabled(False)
+ self._network_metered_btn.set_enabled(False)
+ self._wifi_manager.set_tethering_active(checked)
+
+ self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback)
+
+ def tethering_password_callback(password: str):
+ if password:
+ self._wifi_manager.set_tethering_password(password)
+
+ def tethering_password_clicked():
+ tethering_password = self._wifi_manager.tethering_password
+ dlg = BigInputDialog("enter password...", tethering_password, minimum_length=8,
+ confirm_callback=tethering_password_callback)
+ gui_app.set_modal_overlay(dlg)
+
+ txt_tethering = gui_app.texture(_tethering_icon, 64, 53)
+ self._tethering_password_btn = BigButton("tethering password", "", txt_tethering)
+ self._tethering_password_btn.set_click_callback(tethering_password_clicked)
+
+ # ******** IP Address ********
+ self._ip_address_btn = BigButton("IP Address", "Not connected")
+
+ # ******** Network Metered ********
+ def network_metered_callback(value: str):
+ self._network_metered_btn.set_enabled(False)
+ metered = {
+ 'default': MeteredType.UNKNOWN,
+ 'metered': MeteredType.YES,
+ 'unmetered': MeteredType.NO
+ }.get(value, MeteredType.UNKNOWN)
+ self._wifi_manager.set_current_network_metered(metered)
+
+ # TODO: signal for current network metered type when changing networks, this is wrong until you press it once
+ # TODO: disable when not connected
+ self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback)
+ self._network_metered_btn.set_enabled(False)
+
+ wifi_button = BigButton("wi-fi")
+ wifi_button.set_click_callback(lambda: self._switch_to_panel(NetworkPanelType.WIFI))
+
+ # Main scroller ----------------------------------
+ self._scroller = Scroller([
+ wifi_button,
+ self._network_metered_btn,
+ self._tethering_toggle_btn,
+ self._tethering_password_btn,
+ self._ip_address_btn,
+ ], snap_items=False)
+
+ # Set up back navigation
+ self.set_back_callback(back_callback)
+
+ def show_event(self):
+ super().show_event()
+ self._current_panel = NetworkPanelType.NONE
+ self._wifi_ui.show_event()
+ self._scroller.show_event()
+
+ def hide_event(self):
+ super().hide_event()
+ self._wifi_ui.hide_event()
+
+ def _on_network_updated(self, networks: list[Network]):
+ # Update tethering state
+ tethering_active = self._wifi_manager.is_tethering_active()
+ # TODO: use real signals (like activated/settings changed, etc.) to speed up re-enabling buttons
+ self._tethering_toggle_btn.set_enabled(True)
+ self._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address))
+ self._tethering_toggle_btn.set_checked(tethering_active)
+
+ # Update IP address
+ self._ip_address_btn.set_value(self._wifi_manager.ipv4_address or "Not connected")
+
+ # Update network metered
+ self._network_metered_btn.set_value(
+ {
+ MeteredType.UNKNOWN: 'default',
+ MeteredType.YES: 'metered',
+ MeteredType.NO: 'unmetered'
+ }.get(self._wifi_manager.current_network_metered, 'default'))
+
+ def _switch_to_panel(self, panel_type: NetworkPanelType):
+ self._current_panel = panel_type
+
+ def _render(self, rect: rl.Rectangle):
+ self._wifi_manager.process_callbacks()
+
+ if self._current_panel == NetworkPanelType.WIFI:
+ self._wifi_ui.render(rect)
+ else:
+ self._scroller.render(rect)
diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py
new file mode 100644
index 000000000..75238d581
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/settings/settings.py
@@ -0,0 +1,113 @@
+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.system.ui.widgets.scroller import Scroller
+from openpilot.selfdrive.ui.mici.widgets.button import BigButton
+from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici
+from openpilot.selfdrive.ui.mici.layouts.settings.network import NetworkLayoutMici
+from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton
+from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici
+from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayoutMici
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.widgets import Widget, NavWidget
+
+
+class PanelType(IntEnum):
+ TOGGLES = 0
+ NETWORK = 1
+ DEVICE = 2
+ DEVELOPER = 3
+ USER_MANUAL = 4
+ FIREHOSE = 5
+
+
+@dataclass
+class PanelInfo:
+ name: str
+ instance: Widget
+
+
+class SettingsLayout(NavWidget):
+ def __init__(self):
+ super().__init__()
+ self._params = Params()
+ self._current_panel = None # PanelType.DEVICE
+
+ toggles_btn = BigButton("toggles", "", "icons_mici/settings/toggles_icon.png")
+ toggles_btn.set_click_callback(lambda: self._set_current_panel(PanelType.TOGGLES))
+ network_btn = BigButton("network", "", "icons_mici/settings/network/wifi_strength_full.png")
+ network_btn.set_click_callback(lambda: self._set_current_panel(PanelType.NETWORK))
+ device_btn = BigButton("device", "", "icons_mici/settings/device_icon.png")
+ device_btn.set_click_callback(lambda: self._set_current_panel(PanelType.DEVICE))
+ developer_btn = BigButton("developer", "", "icons_mici/settings/developer_icon.png")
+ developer_btn.set_click_callback(lambda: self._set_current_panel(PanelType.DEVELOPER))
+
+ firehose_btn = BigButton("firehose", "", "icons_mici/settings/comma_icon.png")
+ firehose_btn.set_click_callback(lambda: self._set_current_panel(PanelType.FIREHOSE))
+
+ self._scroller = Scroller([
+ toggles_btn,
+ network_btn,
+ device_btn,
+ PairBigButton(),
+ #BigDialogButton("manual", "", "icons_mici/settings/manual_icon.png", "Check out the mici user\nmanual at comma.ai/setup"),
+ firehose_btn,
+ developer_btn,
+ ], snap_items=False)
+
+ # Set up back navigation
+ self.set_back_callback(self.close_settings)
+ self.set_back_enabled(lambda: self._current_panel is None)
+
+ self._panels = {
+ PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayoutMici(back_callback=lambda: self._set_current_panel(None))),
+ PanelType.NETWORK: PanelInfo("Network", NetworkLayoutMici(back_callback=lambda: self._set_current_panel(None))),
+ PanelType.DEVICE: PanelInfo("Device", DeviceLayoutMici(back_callback=lambda: self._set_current_panel(None))),
+ PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayoutMici(back_callback=lambda: self._set_current_panel(None))),
+ PanelType.FIREHOSE: PanelInfo("Firehose", FirehoseLayoutMici(back_callback=lambda: self._set_current_panel(None))),
+ }
+
+ self._font_medium = gui_app.font(FontWeight.MEDIUM)
+
+ # Callbacks
+ self._close_callback: Callable | None = None
+
+ def show_event(self):
+ super().show_event()
+ self._set_current_panel(None)
+ self._scroller.show_event()
+ if self._current_panel is not None:
+ self._panels[self._current_panel].instance.show_event()
+
+ def hide_event(self):
+ super().hide_event()
+ if self._current_panel is not None:
+ self._panels[self._current_panel].instance.hide_event()
+
+ def set_callbacks(self, on_close: Callable):
+ self._close_callback = on_close
+
+ def _render(self, rect: rl.Rectangle):
+ if self._current_panel is not None:
+ self._draw_current_panel()
+ else:
+ self._scroller.render(rect)
+
+ def _draw_current_panel(self):
+ panel = self._panels[self._current_panel]
+ panel.instance.render(self._rect)
+
+ def _set_current_panel(self, panel_type: PanelType | None):
+ if panel_type != self._current_panel:
+ if self._current_panel is not None:
+ self._panels[self._current_panel].instance.hide_event()
+ self._current_panel = panel_type
+ if self._current_panel is not None:
+ self._panels[self._current_panel].instance.show_event()
+
+ def close_settings(self):
+ if self._close_callback:
+ self._close_callback()
diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py
new file mode 100644
index 000000000..8efb516a4
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/settings/toggles.py
@@ -0,0 +1,95 @@
+import pyray as rl
+from collections.abc import Callable
+from cereal import log
+
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.widgets import NavWidget
+from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback
+from openpilot.selfdrive.ui.ui_state import ui_state
+
+PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants
+
+
+class TogglesLayoutMici(NavWidget):
+ def __init__(self, back_callback: Callable):
+ super().__init__()
+ self.set_back_callback(back_callback)
+
+ self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"])
+ self._experimental_btn = BigParamControl("experimental mode", "ExperimentalMode")
+ is_metric_toggle = BigParamControl("use metric units", "IsMetric")
+ ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled")
+ always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM")
+ record_front = BigParamControl("record & upload driver camera", "RecordFront", toggle_callback=restart_needed_callback)
+ record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback)
+ enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback)
+
+ self._scroller = Scroller([
+ self._personality_toggle,
+ self._experimental_btn,
+ is_metric_toggle,
+ ldw_toggle,
+ always_on_dm_toggle,
+ record_front,
+ record_mic,
+ enable_openpilot,
+ ], snap_items=False)
+
+ # Toggle lists
+ self._refresh_toggles = (
+ ("ExperimentalMode", self._experimental_btn),
+ ("IsMetric", is_metric_toggle),
+ ("IsLdwEnabled", ldw_toggle),
+ ("AlwaysOnDM", always_on_dm_toggle),
+ ("RecordFront", record_front),
+ ("RecordAudio", record_mic),
+ ("OpenpilotEnabledToggle", enable_openpilot),
+ )
+
+ enable_openpilot.set_enabled(lambda: not ui_state.engaged)
+ record_front.set_enabled(False if ui_state.params.get_bool("RecordFrontLock") else (lambda: not ui_state.engaged))
+ record_mic.set_enabled(lambda: not ui_state.engaged)
+
+ if ui_state.params.get_bool("ShowDebugInfo"):
+ gui_app.set_show_touches(True)
+ gui_app.set_show_fps(True)
+
+ ui_state.add_engaged_transition_callback(self._update_toggles)
+
+ def _update_state(self):
+ super()._update_state()
+
+ if ui_state.sm.updated["selfdriveState"]:
+ personality = PERSONALITY_TO_INT[ui_state.sm["selfdriveState"].personality]
+ if personality != ui_state.personality and ui_state.started:
+ self._personality_toggle.set_value(self._personality_toggle._options[personality])
+ ui_state.personality = personality
+
+ def show_event(self):
+ super().show_event()
+ self._scroller.show_event()
+ self._update_toggles()
+
+ def _update_toggles(self):
+ ui_state.update_params()
+
+ # CP gating for experimental mode
+ if ui_state.CP is not None:
+ if ui_state.has_longitudinal_control:
+ self._experimental_btn.set_enabled(True)
+ self._personality_toggle.set_enabled(True)
+ else:
+ # no long for now
+ self._experimental_btn.set_enabled(False)
+ self._experimental_btn.set_checked(False)
+ self._personality_toggle.set_enabled(False)
+ ui_state.params.remove("ExperimentalMode")
+
+ # Refresh toggles from params to mirror external changes
+ for key, item in self._refresh_toggles:
+ item.set_checked(ui_state.params.get_bool(key))
+
+ def _render(self, rect: rl.Rectangle):
+ self._scroller.render(rect)
diff --git a/selfdrive/ui/mici/onroad/__init__.py b/selfdrive/ui/mici/onroad/__init__.py
new file mode 100644
index 000000000..bb45117b9
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/__init__.py
@@ -0,0 +1,12 @@
+import pyray as rl
+
+SIDE_PANEL_WIDTH = 60
+
+
+def blend_colors(a: rl.Color, b: rl.Color, f: float) -> rl.Color:
+ h0, s0, v0 = (hsv0 := rl.color_to_hsv(a)).x, hsv0.y, hsv0.z
+ h1, s1, v1 = (hsv1 := rl.color_to_hsv(b)).x, hsv1.y, hsv1.z
+ dh = ((h1 - h0 + 180) % 360) - 180 # shortest hue delta
+ return rl.color_from_hsv((h0 + f * dh) % 360,
+ s0 + f * (s1 - s0),
+ v0 + f * (v1 - v0))
diff --git a/selfdrive/ui/mici/onroad/alert_renderer.py b/selfdrive/ui/mici/onroad/alert_renderer.py
new file mode 100644
index 000000000..eb5555660
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/alert_renderer.py
@@ -0,0 +1,361 @@
+import time
+from enum import StrEnum
+from typing import NamedTuple
+import pyray as rl
+import random
+import string
+from dataclasses import dataclass
+from cereal import messaging, log, car
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
+from openpilot.system.hardware import TICI
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.label import UnifiedLabel
+
+AlertSize = log.SelfdriveState.AlertSize
+AlertStatus = log.SelfdriveState.AlertStatus
+
+ALERT_MARGIN = 18
+
+ALERT_FONT_SMALL = 66 - 50
+ALERT_FONT_BIG = 88 - 40
+
+SELFDRIVE_STATE_TIMEOUT = 5 # Seconds
+SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds
+
+# Constants
+ALERT_COLORS = {
+ AlertStatus.normal: rl.Color(0, 0, 0, 255),
+ AlertStatus.userPrompt: rl.Color(255, 115, 0, 255),
+ AlertStatus.critical: rl.Color(255, 0, 21, 255),
+}
+
+TURN_SIGNAL_BLINK_PERIOD = 1 / (80 / 60) # Mazda heartbeat turn signal BPM
+
+DEBUG = False
+
+
+class IconSide(StrEnum):
+ left = 'left'
+ right = 'right'
+
+
+class IconLayout(NamedTuple):
+ texture: rl.Texture
+ side: IconSide
+ margin_x: int
+ margin_y: int
+
+
+class AlertLayout(NamedTuple):
+ text_rect: rl.Rectangle
+ icon: IconLayout | None
+
+
+@dataclass
+class Alert:
+ text1: str = ""
+ text2: str = ""
+ size: int = 0
+ status: int = 0
+ visual_alert: int = car.CarControl.HUDControl.VisualAlert.none
+ alert_type: str = ""
+
+
+# Pre-defined alert instances
+ALERT_STARTUP_PENDING = Alert(
+ text1="openpilot Unavailable",
+ text2="Waiting to start",
+ size=AlertSize.mid,
+ status=AlertStatus.normal,
+)
+
+ALERT_CRITICAL_TIMEOUT = Alert(
+ text1="TAKE CONTROL IMMEDIATELY",
+ text2="System Unresponsive",
+ size=AlertSize.full,
+ status=AlertStatus.critical,
+)
+
+ALERT_CRITICAL_REBOOT = Alert(
+ text1="System Unresponsive",
+ text2="Reboot Device",
+ size=AlertSize.full,
+ status=AlertStatus.critical,
+)
+
+
+class AlertRenderer(Widget):
+ def __init__(self):
+ super().__init__()
+ self.font_regular: rl.Font = gui_app.font(FontWeight.MEDIUM)
+ self.font_roman: rl.Font = gui_app.font(FontWeight.ROMAN)
+ self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
+ self.font_display: rl.Font = gui_app.font(FontWeight.DISPLAY)
+
+ self._alert_text1_label = UnifiedLabel(text="", font_size=ALERT_FONT_BIG, font_weight=FontWeight.DISPLAY, line_height=0.86,
+ letter_spacing=-0.02)
+ self._alert_text2_label = UnifiedLabel(text="", font_size=ALERT_FONT_SMALL, font_weight=FontWeight.ROMAN, line_height=0.86,
+ letter_spacing=0.025)
+
+ self._prev_alert: Alert | None = None
+ self._text_gen_time = 0
+ self._alert_text2_gen = ''
+
+ # animation filters
+ # TODO: use 0.1 but with proper alert height calculation
+ self._alert_y_filter = BounceFilter(0, 0.1, 1 / gui_app.target_fps)
+ self._alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
+
+ self._turn_signal_timer = 0.0
+ self._turn_signal_alpha_filter = FirstOrderFilter(0.0, 0.3, 1 / gui_app.target_fps)
+ self._last_icon_side: IconSide | None = None
+
+ self._load_icons()
+
+ def _load_icons(self):
+ self._txt_turn_signal_left = gui_app.texture('icons_mici/onroad/turn_signal_left.png', 100, 91)
+ self._txt_turn_signal_right = gui_app.texture('icons_mici/onroad/turn_signal_right.png', 100, 91)
+ self._txt_blind_spot_left = gui_app.texture('icons_mici/onroad/blind_spot_left.png', 108, 128)
+ self._txt_blind_spot_right = gui_app.texture('icons_mici/onroad/blind_spot_right.png', 108, 128)
+
+ def get_alert(self, sm: messaging.SubMaster) -> Alert | None:
+ """Generate the current alert based on selfdrive state."""
+ ss = sm['selfdriveState']
+
+ # Check if selfdriveState messages have stopped arriving
+ if not sm.updated['selfdriveState']:
+ recv_frame = sm.recv_frame['selfdriveState']
+ time_since_onroad = time.monotonic() - ui_state.started_time
+
+ # 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:
+ return None
+
+ # Return current alert
+ ret = Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw,
+ visual_alert=ss.alertHudVisual, alert_type=ss.alertType)
+ self._prev_alert = ret
+ return ret
+
+ def will_render(self) -> tuple[Alert | None, bool]:
+ alert = self.get_alert(ui_state.sm)
+ return alert or self._prev_alert, alert is None
+
+ def _icon_helper(self, alert: Alert) -> AlertLayout:
+ icon_side = None
+ txt_icon = None
+ icon_margin_x = 20
+ icon_margin_y = 18
+
+ # alert_type format is "EventName/eventType" (e.g., "preLaneChangeLeft/warning")
+ event_name = alert.alert_type.split('/')[0] if alert.alert_type else ''
+
+ if event_name == 'preLaneChangeLeft':
+ icon_side = IconSide.left
+ txt_icon = self._txt_turn_signal_left
+ icon_margin_x = 2
+ icon_margin_y = 5
+
+ elif event_name == 'preLaneChangeRight':
+ icon_side = IconSide.right
+ txt_icon = self._txt_turn_signal_right
+ icon_margin_x = 2
+ icon_margin_y = 5
+
+ elif event_name == 'laneChange':
+ icon_side = self._last_icon_side
+ txt_icon = self._txt_turn_signal_left if self._last_icon_side == 'left' else self._txt_turn_signal_right
+ icon_margin_x = 2
+ icon_margin_y = 5
+
+ elif event_name == 'laneChangeBlocked':
+ CS = ui_state.sm['carState']
+ if CS.leftBlinker:
+ icon_side = IconSide.left
+ elif CS.rightBlinker:
+ icon_side = IconSide.right
+ else:
+ icon_side = self._last_icon_side
+ txt_icon = self._txt_blind_spot_left if icon_side == 'left' else self._txt_blind_spot_right
+ icon_margin_x = 8
+ icon_margin_y = 0
+
+ else:
+ self._turn_signal_timer = 0.0
+
+ self._last_icon_side = icon_side
+
+ # create text rect based on icon presence
+ text_x = self._rect.x + ALERT_MARGIN
+ text_width = self._rect.width - ALERT_MARGIN
+ if icon_side == 'left':
+ text_x = self._rect.x + self._txt_turn_signal_right.width + 20 * 2
+ text_width = self._rect.width - ALERT_MARGIN - self._txt_turn_signal_right.width - 20 * 2
+ elif icon_side == 'right':
+ text_x = self._rect.x + ALERT_MARGIN
+ text_width = self._rect.width - ALERT_MARGIN - self._txt_turn_signal_right.width - 20 * 2
+
+ text_rect = rl.Rectangle(
+ text_x,
+ self._alert_y_filter.x,
+ text_width,
+ self._rect.height,
+ )
+ icon_layout = IconLayout(txt_icon, icon_side, icon_margin_x, icon_margin_y) if txt_icon is not None and icon_side is not None else None
+ return AlertLayout(text_rect, icon_layout)
+
+ def _render(self, rect: rl.Rectangle) -> bool:
+ alert = self.get_alert(ui_state.sm)
+
+ # Animate fade and slide in/out
+ self._alert_y_filter.update(self._rect.y - 50 if alert is None else self._rect.y)
+ self._alpha_filter.update(0 if alert is None else 1)
+
+ if alert is None:
+ # If still animating out, keep the previous alert
+ if self._alpha_filter.x > 0.01 and self._prev_alert is not None:
+ alert = self._prev_alert
+ else:
+ self._prev_alert = None
+ return False
+
+ self._draw_background(alert)
+
+ alert_layout = self._icon_helper(alert)
+ self._draw_text(alert, alert_layout)
+ self._draw_icons(alert_layout)
+
+ return True
+
+ def _draw_icons(self, alert_layout: AlertLayout) -> None:
+ if alert_layout.icon is None:
+ return
+
+ if time.monotonic() - self._turn_signal_timer > TURN_SIGNAL_BLINK_PERIOD:
+ self._turn_signal_timer = time.monotonic()
+ self._turn_signal_alpha_filter.x = 255 * 2
+ else:
+ self._turn_signal_alpha_filter.update(255 * 0.2)
+
+ if alert_layout.icon.side == 'left':
+ pos_x = int(self._rect.x + alert_layout.icon.margin_x)
+ else:
+ pos_x = int(self._rect.x + self._rect.width - alert_layout.icon.margin_x - alert_layout.icon.texture.width)
+
+ if alert_layout.icon.texture not in (self._txt_turn_signal_left, self._txt_turn_signal_right):
+ icon_alpha = 255
+ else:
+ icon_alpha = int(min(self._turn_signal_alpha_filter.x, 255))
+
+ rl.draw_texture(alert_layout.icon.texture, pos_x, int(self._rect.y + alert_layout.icon.margin_y),
+ rl.Color(255, 255, 255, int(icon_alpha * self._alpha_filter.x)))
+
+ def _draw_background(self, alert: Alert) -> None:
+ # draw top gradient for alert text at top
+ color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal])
+ color = rl.Color(color.r, color.g, color.b, int(255 * 0.90 * self._alpha_filter.x))
+ translucent_color = rl.Color(color.r, color.g, color.b, int(0 * self._alpha_filter.x))
+
+ small_alert_height = round(self._rect.height * 0.583) # 140px at mici height
+ medium_alert_height = round(self._rect.height * 0.833) # 200px at mici height
+
+ # alert_type format is "EventName/eventType" (e.g., "preLaneChangeLeft/warning")
+ event_name = alert.alert_type.split('/')[0] if alert.alert_type else ''
+
+ if event_name == 'preLaneChangeLeft':
+ bg_height = small_alert_height
+ elif event_name == 'preLaneChangeRight':
+ bg_height = small_alert_height
+ elif event_name == 'laneChange':
+ bg_height = small_alert_height
+ elif event_name == 'laneChangeBlocked':
+ bg_height = medium_alert_height
+ else:
+ bg_height = int(self._rect.height)
+
+ solid_height = round(bg_height * 0.2)
+ rl.draw_rectangle(int(self._rect.x), int(self._rect.y), int(self._rect.width), solid_height, color)
+ rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + solid_height), int(self._rect.width),
+ int(bg_height - solid_height),
+ color, translucent_color)
+
+ def _draw_text(self, alert: Alert, alert_layout: AlertLayout) -> None:
+ icon_side = alert_layout.icon.side if alert_layout.icon is not None else None
+
+ # TODO: hack
+ alert_text1 = alert.text1.lower().replace('calibrating: ', 'calibrating:\n')
+ can_draw_second_line = False
+ # TODO: there should be a common way to determine font size based on text length to maximize rect
+ if len(alert_text1) <= 12:
+ can_draw_second_line = True
+ font_size = 92 - 10
+ elif len(alert_text1) <= 16:
+ can_draw_second_line = True
+ font_size = 70
+ else:
+ font_size = 64 - 10
+
+ if icon_side is not None:
+ font_size -= 10
+
+ color = rl.Color(255, 255, 255, int(255 * 0.9 * self._alpha_filter.x))
+
+ text1_y_offset = 11 if font_size >= 70 else 4
+ text_rect1 = rl.Rectangle(
+ alert_layout.text_rect.x,
+ alert_layout.text_rect.y - text1_y_offset,
+ alert_layout.text_rect.width,
+ alert_layout.text_rect.height,
+ )
+ self._alert_text1_label.set_text(alert_text1)
+ self._alert_text1_label.set_text_color(color)
+ self._alert_text1_label.set_font_size(font_size)
+ self._alert_text1_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
+ self._alert_text1_label.render(text_rect1)
+
+ alert_text2 = alert.text2.lower()
+
+ # randomize chars and length for testing
+ if DEBUG:
+ if time.monotonic() - self._text_gen_time > 0.5:
+ self._alert_text2_gen = ''.join(random.choices(string.ascii_lowercase + ' ', k=random.randint(0, 40)))
+ self._text_gen_time = time.monotonic()
+ alert_text2 = self._alert_text2_gen or alert_text2
+
+ if can_draw_second_line and alert_text2:
+ last_line_h = self._alert_text1_label.rect.y + self._alert_text1_label.get_content_height(int(alert_layout.text_rect.width))
+ last_line_h -= 4
+ if len(alert_text2) > 18:
+ small_font_size = 36
+ elif len(alert_text2) > 24:
+ small_font_size = 32
+ else:
+ small_font_size = 40
+ text_rect2 = rl.Rectangle(
+ alert_layout.text_rect.x,
+ last_line_h,
+ alert_layout.text_rect.width,
+ alert_layout.text_rect.height - last_line_h
+ )
+ color = rl.Color(255, 255, 255, int(255 * 0.65 * self._alpha_filter.x))
+
+ self._alert_text2_label.set_text(alert_text2)
+ self._alert_text2_label.set_text_color(color)
+ self._alert_text2_label.set_font_size(small_font_size)
+ self._alert_text2_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
+ self._alert_text2_label.render(text_rect2)
diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py
new file mode 100644
index 000000000..ab55f392f
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/augmented_road_view.py
@@ -0,0 +1,358 @@
+import numpy as np
+import pyray as rl
+from cereal import car, log
+from msgq.visionipc import VisionStreamType
+from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
+from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH
+from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer
+from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
+from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer
+from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer
+from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall
+from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
+from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent
+from openpilot.system.ui.widgets.label import UnifiedLabel
+from openpilot.system.ui.widgets import Widget
+from openpilot.common.filter_simple import BounceFilter
+from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame
+from openpilot.common.transformations.orientation import rot_from_euler
+from enum import IntEnum
+
+OpState = log.SelfdriveState.OpenpilotState
+CALIBRATED = log.LiveCalibrationData.Status.calibrated
+ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD
+WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD
+DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"]
+
+
+class BookmarkState(IntEnum):
+ HIDDEN = 0
+ DRAGGING = 1
+ TRIGGERED = 2
+
+WIDE_CAM_MAX_SPEED = 5.0 # m/s (10 mph)
+ROAD_CAM_MIN_SPEED = 10 # m/s (25 mph)
+
+CAM_Y_OFFSET = 20
+
+
+class BookmarkIcon(Widget):
+ PEEK_THRESHOLD = 50 # If icon peeks out this much, snap it fully visible
+ FULL_VISIBLE_OFFSET = 200 # How far onscreen when fully visible
+ HIDDEN_OFFSET = -50 # How far offscreen when hidden
+
+ def __init__(self, bookmark_callback):
+ super().__init__()
+ self._bookmark_callback = bookmark_callback
+ self._icon = gui_app.texture("icons_mici/onroad/bookmark.png", 180, 180)
+ self._offset_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps)
+
+ # State
+ self._interacting = False
+ self._state = BookmarkState.HIDDEN
+ self._swipe_start_x = 0.0
+ self._swipe_current_x = 0.0
+ self._is_swiping = False
+ self._is_swiping_left: bool = False
+ self._triggered_time: float = 0.0
+
+ def is_swiping_left(self) -> bool:
+ """Check if currently swiping left (for scroller to disable)."""
+ return self._is_swiping_left
+
+ def interacting(self):
+ interacting, self._interacting = self._interacting, False
+ return interacting
+
+ def _update_state(self):
+ if self._state == BookmarkState.DRAGGING:
+ # Allow pulling past activated position with rubber band effect
+ swipe_offset = self._swipe_start_x - self._swipe_current_x
+ swipe_offset = min(swipe_offset, self.FULL_VISIBLE_OFFSET + 50)
+ self._offset_filter.update(swipe_offset)
+
+ elif self._state == BookmarkState.TRIGGERED:
+ # Continue animating to fully visible
+ self._offset_filter.update(self.FULL_VISIBLE_OFFSET)
+ # Stay in TRIGGERED state for 1 second
+ if rl.get_time() - self._triggered_time >= 1.5:
+ self._state = BookmarkState.HIDDEN
+
+ elif self._state == BookmarkState.HIDDEN:
+ self._offset_filter.update(self.HIDDEN_OFFSET)
+
+ if self._offset_filter.x < 1e-3:
+ self._interacting = False
+
+ def _handle_mouse_event(self, mouse_event: MouseEvent):
+ if not ui_state.started:
+ return
+
+ if mouse_event.left_pressed:
+ # Store relative position within widget
+ self._swipe_start_x = mouse_event.pos.x
+ self._swipe_current_x = mouse_event.pos.x
+ self._is_swiping = True
+ self._is_swiping_left = False
+ self._state = BookmarkState.DRAGGING
+
+ elif mouse_event.left_down and self._is_swiping:
+ self._swipe_current_x = mouse_event.pos.x
+ swipe_offset = self._swipe_start_x - self._swipe_current_x
+ self._is_swiping_left = swipe_offset > 0
+ if self._is_swiping_left:
+ self._interacting = True
+
+ elif mouse_event.left_released:
+ if self._is_swiping:
+ swipe_distance = self._swipe_start_x - self._swipe_current_x
+
+ # If peeking past threshold, transition to animating to fully visible and bookmark
+ if swipe_distance > self.PEEK_THRESHOLD:
+ self._state = BookmarkState.TRIGGERED
+ self._triggered_time = rl.get_time()
+ self._bookmark_callback()
+ else:
+ # Otherwise, transition back to hidden
+ self._state = BookmarkState.HIDDEN
+
+ # Reset swipe state
+ self._is_swiping = False
+ self._is_swiping_left = False
+
+ def _render(self, _):
+ """Render the bookmark icon."""
+ if self._offset_filter.x > 0:
+ icon_x = self.rect.x + self.rect.width - round(self._offset_filter.x)
+ icon_y = self.rect.y + (self.rect.height - self._icon.height) / 2 # Vertically centered
+ rl.draw_texture(self._icon, int(icon_x), int(icon_y), rl.WHITE)
+
+
+class AugmentedRoadView(CameraView):
+ def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD):
+ super().__init__("camerad", stream_type)
+ self._bookmark_callback = bookmark_callback
+ self._set_placeholder_color(rl.BLACK)
+
+ self.device_camera: DeviceCameraConfig | None = None
+ self.view_from_calib = view_frame_from_device_frame.copy()
+ self.view_from_wide_calib = view_frame_from_device_frame.copy()
+
+ self._last_calib_time: float = 0
+ self._last_rect_dims = (0.0, 0.0)
+ self._last_stream_type = stream_type
+ self._cached_matrix: np.ndarray | None = None
+ self._content_rect = rl.Rectangle()
+ self._last_click_time = 0.0
+
+ # Bookmark icon with swipe gesture
+ self._bookmark_icon = BookmarkIcon(bookmark_callback)
+
+ self._model_renderer = ModelRenderer()
+ self._hud_renderer = HudRenderer()
+ self._alert_renderer = AlertRenderer()
+ self._driver_state_renderer = DriverStateRenderer()
+ self._confidence_ball = ConfidenceBall()
+ self._offroad_label = UnifiedLabel("start the car to\nuse openpilot", 54, FontWeight.DISPLAY,
+ text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
+
+ self._fade_texture = gui_app.texture("icons_mici/onroad/onroad_fade.png")
+
+ def is_swiping_left(self) -> bool:
+ """Check if currently swiping left (for scroller to disable)."""
+ return self._bookmark_icon.is_swiping_left()
+
+ def _update_state(self):
+ super()._update_state()
+
+ # update offroad label
+ if ui_state.panda_type == log.PandaState.PandaType.unknown:
+ self._offroad_label.set_text("system booting")
+ else:
+ self._offroad_label.set_text("start the car to\nuse openpilot")
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ # Don't trigger click callback if bookmark was triggered
+ if not self._bookmark_icon.interacting():
+ super()._handle_mouse_release(mouse_pos)
+
+ def _render(self, _):
+ self._switch_stream_if_needed(ui_state.sm)
+
+ # Update calibration before rendering
+ self._update_calibration()
+
+ # Create inner content area with border padding
+ self._content_rect = rl.Rectangle(
+ self.rect.x,
+ self.rect.y,
+ self.rect.width - SIDE_PANEL_WIDTH,
+ self.rect.height,
+ )
+
+ # Enable scissor mode to clip all rendering within content rectangle boundaries
+ # This creates a rendering viewport that prevents graphics from drawing outside the border
+ rl.begin_scissor_mode(
+ int(self._content_rect.x),
+ int(self._content_rect.y),
+ int(self._content_rect.width),
+ int(self._content_rect.height)
+ )
+
+ # Render the base camera view
+ super()._render(self._content_rect)
+
+ # Draw all UI overlays
+ self._model_renderer.render(self._content_rect)
+
+ # Fade out bottom of overlays for looks
+ rl.draw_texture_ex(self._fade_texture, rl.Vector2(self._content_rect.x, self._content_rect.y), 0.0, 1.0, rl.WHITE)
+
+ alert_to_render, not_animating_out = self._alert_renderer.will_render()
+
+ # Hide DMoji when disengaged unless AlwaysOnDM is enabled
+ should_draw_dmoji = (not self._hud_renderer.drawing_top_icons() and ui_state.is_onroad() and
+ (ui_state.status != UIStatus.DISENGAGED or ui_state.always_on_dm))
+ self._driver_state_renderer.set_should_draw(should_draw_dmoji)
+ self._driver_state_renderer.set_position(self._rect.x + 16, self._rect.y + 10)
+ self._driver_state_renderer.render()
+
+ self._hud_renderer.set_can_draw_top_icons(alert_to_render is None)
+ self._hud_renderer.set_wheel_critical_icon(alert_to_render is not None and not not_animating_out and
+ alert_to_render.visual_alert == car.CarControl.HUDControl.VisualAlert.steerRequired)
+ # TODO: have alert renderer draw offroad mici label below
+ if ui_state.started:
+ self._alert_renderer.render(self._content_rect)
+ self._hud_renderer.render(self._content_rect)
+
+ # Draw fake rounded border
+ rl.draw_rectangle_rounded_lines_ex(self._content_rect, 0.2 * 1.02, 10, 50, rl.BLACK)
+
+ # End clipping region
+ rl.end_scissor_mode()
+
+ # Custom UI extension point - add custom overlays here
+ # Use self._content_rect for positioning within camera bounds
+ self._confidence_ball.render(self.rect)
+
+ self._bookmark_icon.render(self.rect)
+
+ # Draw darkened background and text if not onroad
+ if not ui_state.started:
+ rl.draw_rectangle(int(self.rect.x), int(self.rect.y), int(self.rect.width), int(self.rect.height), rl.Color(0, 0, 0, 175))
+ self._offroad_label.render(self._content_rect)
+
+ 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
+ if not self.device_camera and sm.seen['roadCameraState'] and sm.seen['deviceState']:
+ self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))]
+
+ # Check if live calibration data is available and valid
+ if not (sm.updated["liveCalibration"] and sm.valid['liveCalibration']):
+ return
+
+ calib = sm['liveCalibration']
+ if len(calib.rpyCalib) != 3 or calib.calStatus != CALIBRATED:
+ return
+
+ # Update view_from_calib matrix
+ device_from_calib = rot_from_euler(calib.rpyCalib)
+ self.view_from_calib = view_frame_from_device_frame @ device_from_calib
+
+ # Update wide calibration if available
+ if hasattr(calib, 'wideFromDeviceEuler') and len(calib.wideFromDeviceEuler) == 3:
+ wide_from_device = rot_from_euler(calib.wideFromDeviceEuler)
+ self.view_from_wide_calib = view_frame_from_device_frame @ wide_from_device @ device_from_calib
+
+ def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
+ # Get camera configuration
+ # TODO: cache with vEgo?
+ calib_time = ui_state.sm.recv_frame['liveCalibration']
+ current_dims = (self._content_rect.width, self._content_rect.height)
+ device_camera = self.device_camera or DEFAULT_DEVICE_CAMERA
+ 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
+ if is_wide_camera:
+ zoom = 0.7 * 1.5
+ else:
+ zoom = np.interp(ui_state.sm['carState'].vEgo, [10, 30], [0.8, 1.0])
+
+ # Calculate transforms for vanishing point
+ inf_point = np.array([1000.0, 0.0, 0.0])
+ calib_transform = intrinsic @ calibration
+ kep = calib_transform @ inf_point
+
+ # Calculate center points and dimensions
+ x, y = self._content_rect.x, self._content_rect.y
+ w, h = self._content_rect.width, self._content_rect.height
+ cx, cy = intrinsic[0, 2], intrinsic[1, 2]
+
+ # Calculate max allowed offsets with margins
+ margin = 5
+ max_x_offset = cx * zoom - w / 2 - margin
+ max_y_offset = cy * zoom - h / 2 - margin
+
+ # Calculate and clamp offsets to prevent out-of-bounds issues
+ try:
+ if abs(kep[2]) > 1e-6:
+ x_offset = np.clip((kep[0] / kep[2] - cx) * zoom, -max_x_offset, max_x_offset)
+ y_offset = np.clip((kep[1] / kep[2] - cy) * zoom + CAM_Y_OFFSET, -max_y_offset, max_y_offset)
+ else:
+ x_offset, y_offset = 0, 0
+ except (ZeroDivisionError, OverflowError):
+ x_offset, y_offset = 0, 0
+
+ # Cache the computed transformation matrix to avoid recalculations
+ self._last_calib_time = calib_time
+ self._last_rect_dims = current_dims
+ self._last_stream_type = self.stream_type
+ self._cached_matrix = np.array([
+ [zoom * 2 * cx / w, 0, -x_offset / w * 2],
+ [0, zoom * 2 * cy / h, -y_offset / h * 2],
+ [0, 0, 1.0]
+ ])
+
+ 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]
+ ])
+ self._model_renderer.set_transform(video_transform @ calib_transform)
+
+ return self._cached_matrix
+
+
+if __name__ == "__main__":
+ gui_app.init_window("OnRoad Camera View")
+ road_camera_view = AugmentedRoadView(ROAD_CAM)
+ print("***press space to switch camera view***")
+ try:
+ for _ in gui_app.render():
+ ui_state.update()
+ if rl.is_key_released(rl.KeyboardKey.KEY_SPACE):
+ if WIDE_CAM in road_camera_view.available_streams:
+ stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM
+ road_camera_view.switch_stream(stream)
+ road_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ finally:
+ road_camera_view.close()
diff --git a/selfdrive/ui/mici/onroad/cameraview.py b/selfdrive/ui/mici/onroad/cameraview.py
new file mode 100644
index 000000000..f962210af
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/cameraview.py
@@ -0,0 +1,390 @@
+import platform
+import numpy as np
+import pyray as rl
+
+from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf
+from openpilot.common.swaglog import cloudlog
+from openpilot.system.hardware import TICI
+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.widgets import Widget
+from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
+
+CONNECTION_RETRY_INTERVAL = 0.2 # seconds between connection attempts
+
+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;
+in vec4 vertexColor;
+uniform mat4 mvp;
+out vec2 fragTexCoord;
+out vec4 fragColor;
+void main() {
+ fragTexCoord = vertexTexCoord;
+ fragColor = vertexColor;
+ gl_Position = mvp * vec4(vertexPosition, 1.0);
+}
+"""
+
+# Choose fragment shader based on platform capabilities
+if TICI:
+ FRAME_FRAGMENT_SHADER = """
+ #version 300 es
+ #extension GL_OES_EGL_image_external_essl3 : enable
+ precision mediump float;
+ in vec2 fragTexCoord;
+ uniform samplerExternalOES texture0;
+ out vec4 fragColor;
+ uniform int engaged;
+
+ void main() {
+ vec4 color = texture(texture0, fragTexCoord);
+ if (engaged == 1) {
+ float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); // Luma
+ color.rgb = mix(vec3(gray), color.rgb, 0.2); // 20% saturation
+ color.rgb = clamp((color.rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast
+ color.rgb = pow(color.rgb, vec3(1.0/1.28));
+ fragColor = vec4(color.rgb, color.a);
+ } else {
+ fragColor = vec4(color.rgb * 0.85, color.a); // 85% opacity
+ }
+ }
+ """
+else:
+ FRAME_FRAGMENT_SHADER = VERSION + """
+ in vec2 fragTexCoord;
+ uniform sampler2D texture0;
+ uniform sampler2D texture1;
+ out vec4 fragColor;
+ uniform int engaged;
+
+ void main() {
+ float y = texture(texture0, fragTexCoord).r;
+ vec2 uv = texture(texture1, fragTexCoord).ra - 0.5;
+ vec3 rgb = vec3(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x);
+ if (engaged == 1) {
+ float gray = dot(rgb, vec3(0.299, 0.587, 0.114));
+ rgb = mix(vec3(gray), rgb, 0.2); // 20% saturation
+ rgb = clamp((rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast
+ fragColor = vec4(rgb, 1.0);
+ } else {
+ fragColor = vec4(rgb * 0.85, 1.0); // 85% opacity
+ }
+ }
+ """
+
+
+class CameraView(Widget):
+ def __init__(self, name: str, stream_type: VisionStreamType):
+ super().__init__()
+ # TODO: implement a receiver and connect thread
+ self._name = name
+ # Primary stream
+ self.client = VisionIpcClient(name, stream_type, conflate=True)
+ self._stream_type = stream_type
+ self.available_streams: list[VisionStreamType] = []
+
+ # Target stream for switching
+ self._target_client: VisionIpcClient | None = None
+ 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)
+ self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1
+ self._engaged_loc = rl.get_shader_location(self.shader, "engaged")
+ self._engaged_val = rl.ffi.new("int[1]", [1])
+
+ self.frame: VisionBuf | None = None
+ self.texture_y: rl.Texture | None = None
+ self.texture_uv: rl.Texture | None = None
+
+ # EGL resources
+ self.egl_images: dict[int, EGLImage] = {}
+ self.egl_texture: rl.Texture | None = None
+
+ self._placeholder_color: rl.Color | None = None
+
+ # Initialize EGL for zero-copy rendering on TICI
+ if TICI:
+ if not init_egl():
+ raise RuntimeError("Failed to initialize EGL")
+
+ # Create a 1x1 pixel placeholder texture for EGL image binding
+ temp_image = rl.gen_image_color(1, 1, rl.BLACK)
+ self.egl_texture = rl.load_texture_from_image(temp_image)
+ rl.unload_image(temp_image)
+
+ ui_state.add_offroad_transition_callback(self._offroad_transition)
+
+ def _offroad_transition(self):
+ # Reconnect if not first time going onroad
+ if ui_state.is_onroad() and self.frame is not None:
+ # Prevent old frames from showing when going onroad. Qt has a separate thread
+ # which drains the VisionIpcClient SubSocket for us. Re-connecting is not enough
+ # and only clears internal buffers, not the message queue.
+ self.frame = None
+ self.available_streams.clear()
+ if self.client:
+ del self.client
+ self.client = VisionIpcClient(self._name, self._stream_type, conflate=True)
+
+ def _set_placeholder_color(self, color: rl.Color):
+ """Set a placeholder color to be drawn when no frame is available."""
+ self._placeholder_color = color
+
+ def switch_stream(self, stream_type: VisionStreamType) -> None:
+ if self._stream_type == stream_type:
+ return
+
+ if self._switching and self._target_stream_type == stream_type:
+ return
+
+ cloudlog.debug(f'Preparing switch from {self._stream_type} to {stream_type}')
+
+ if self._target_client:
+ del self._target_client
+
+ self._target_stream_type = stream_type
+ self._target_client = VisionIpcClient(self._name, stream_type, conflate=True)
+ self._switching = True
+
+ @property
+ def stream_type(self) -> VisionStreamType:
+ return self._stream_type
+
+ def close(self) -> None:
+ self._clear_textures()
+
+ # Clean up EGL texture
+ if TICI and self.egl_texture:
+ rl.unload_texture(self.egl_texture)
+ self.egl_texture = None
+
+ # Clean up shader
+ if self.shader and self.shader.id:
+ rl.unload_shader(self.shader)
+
+ self.client = None
+
+ def __del__(self):
+ self.close()
+
+ def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
+ if not self.frame:
+ return np.eye(3)
+
+ # Calculate aspect ratios
+ widget_aspect_ratio = rect.width / rect.height
+ frame_aspect_ratio = self.frame.width / self.frame.height
+
+ # Calculate scaling factors to maintain aspect ratio
+ zx = min(frame_aspect_ratio / widget_aspect_ratio, 1.0)
+ 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]
+ ])
+
+ def _render(self, rect: rl.Rectangle):
+ if self._switching:
+ self._handle_switch()
+
+ if not self._ensure_connection():
+ self._draw_placeholder(rect)
+ return
+
+ # Try to get a new buffer without blocking
+ buffer = self.client.recv(timeout_ms=0)
+ if buffer:
+ self._texture_needs_update = True
+ self.frame = buffer
+
+ if not self.frame:
+ self._draw_placeholder(rect)
+ return
+
+ transform = self._calc_frame_matrix(rect)
+ src_rect = rl.Rectangle(0, 0, float(self.frame.width), float(self.frame.height))
+ # Flip driver camera horizontally
+ if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER:
+ src_rect.width = -src_rect.width
+
+ # Calculate scale
+ scale_x = rect.width * transform[0, 0] # zx
+ scale_y = rect.height * transform[1, 1] # zy
+
+ # Calculate base position (centered)
+ x_offset = rect.x + (rect.width - scale_x) / 2
+ y_offset = rect.y + (rect.height - scale_y) / 2
+
+ x_offset += transform[0, 2] * rect.width / 2
+ y_offset += transform[1, 2] * rect.height / 2
+
+ dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y)
+
+ # Render with appropriate method
+ if TICI:
+ self._render_egl(src_rect, dst_rect)
+ else:
+ self._render_textures(src_rect, dst_rect)
+
+ def _draw_placeholder(self, rect: rl.Rectangle):
+ if self._placeholder_color:
+ rl.draw_rectangle_rec(rect, self._placeholder_color)
+
+ def _render_egl(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
+ """Render using EGL for direct buffer access"""
+ if self.frame is None or self.egl_texture is None:
+ return
+
+ idx = self.frame.idx
+ egl_image = self.egl_images.get(idx)
+
+ # Create EGL image if needed
+ if egl_image is None:
+ egl_image = create_egl_image(self.frame.width, self.frame.height, self.frame.stride, self.frame.fd, self.frame.uv_offset)
+ if egl_image:
+ self.egl_images[idx] = egl_image
+ else:
+ return
+
+ # Update texture dimensions to match current frame
+ self.egl_texture.width = self.frame.width
+ self.egl_texture.height = self.frame.height
+
+ # Bind the EGL image to our texture
+ bind_egl_image_to_texture(self.egl_texture.id, egl_image)
+
+ # Render with shader
+ rl.begin_shader_mode(self.shader)
+ self._update_texture_color_filtering()
+ rl.draw_texture_pro(self.egl_texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
+ rl.end_shader_mode()
+
+ def _render_textures(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
+ """Render using texture copies"""
+ if not self.texture_y or not self.texture_uv or self.frame is None:
+ return
+
+ # 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:]
+
+ 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))
+ self._texture_needs_update = False
+
+ # Render with shader
+ rl.begin_shader_mode(self.shader)
+ self._update_texture_color_filtering()
+ rl.set_shader_value_texture(self.shader, self._texture1_loc, self.texture_uv)
+ rl.draw_texture_pro(self.texture_y, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
+ rl.end_shader_mode()
+
+ def _update_texture_color_filtering(self):
+ self._engaged_val[0] = 1 if ui_state.status != UIStatus.DISENGAGED else 0
+ rl.set_shader_value(self.shader, self._engaged_loc, self._engaged_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
+
+ def _ensure_connection(self) -> bool:
+ if not self.client.is_connected():
+ self.frame = None
+ self.available_streams.clear()
+
+ # Throttle connection attempts
+ current_time = rl.get_time()
+ if current_time - self.last_connection_attempt < CONNECTION_RETRY_INTERVAL:
+ return False
+ self.last_connection_attempt = current_time
+
+ if not self.client.connect(False) or not self.client.num_buffers:
+ return False
+
+ cloudlog.debug(f"Connected to {self._name} stream: {self._stream_type}, buffers: {self.client.num_buffers}")
+ self._initialize_textures()
+ self.available_streams = self.client.available_streams(self._name, block=False)
+
+ return True
+
+ def _handle_switch(self) -> None:
+ """Check if target stream is ready and switch immediately."""
+ if not self._target_client or not self._switching:
+ return
+
+ # Try to connect target if needed
+ if not self._target_client.is_connected():
+ if not self._target_client.connect(False) or not self._target_client.num_buffers:
+ return
+
+ cloudlog.debug(f"Target stream connected: {self._target_stream_type}")
+
+ # 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._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
+
+ # 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
+
+ # Initialize textures for new stream
+ self._initialize_textures()
+
+ def _initialize_textures(self):
+ self._clear_textures()
+ if not TICI:
+ self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride),
+ int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE))
+ self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2),
+ int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA))
+
+ def _clear_textures(self):
+ if self.texture_y and self.texture_y.id:
+ rl.unload_texture(self.texture_y)
+ self.texture_y = None
+
+ if self.texture_uv and self.texture_uv.id:
+ rl.unload_texture(self.texture_uv)
+ self.texture_uv = None
+
+ # Clean up EGL resources
+ if TICI:
+ for data in self.egl_images.values():
+ destroy_egl_image(data)
+ self.egl_images = {}
+
+
+if __name__ == "__main__":
+ gui_app.init_window("camera view")
+ road = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD)
+ for _ in gui_app.render():
+ road.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
diff --git a/selfdrive/ui/mici/onroad/confidence_ball.py b/selfdrive/ui/mici/onroad/confidence_ball.py
new file mode 100644
index 000000000..de792282c
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/confidence_ball.py
@@ -0,0 +1,78 @@
+import math
+import pyray as rl
+from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH
+from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.common.filter_simple import FirstOrderFilter
+
+
+def draw_circle_gradient(center_x: float, center_y: float, radius: int,
+ top: rl.Color, bottom: rl.Color) -> None:
+ # Draw a square with the gradient
+ rl.draw_rectangle_gradient_v(int(center_x - radius), int(center_y - radius),
+ radius * 2, radius * 2,
+ top, bottom)
+
+ # Paint over square with a ring
+ outer_radius = math.ceil(radius * math.sqrt(2)) + 1
+ rl.draw_ring(rl.Vector2(center_x, center_y), radius, outer_radius,
+ 0.0, 360.0,
+ 20, rl.BLACK)
+
+
+class ConfidenceBall(Widget):
+ def __init__(self, demo: bool = False):
+ super().__init__()
+ self._demo = demo
+ self._confidence_filter = FirstOrderFilter(-0.5, 0.5, 1 / gui_app.target_fps)
+
+ def update_filter(self, value: float):
+ self._confidence_filter.update(value)
+
+ def _update_state(self):
+ if self._demo:
+ return
+
+ # animate status dot in from bottom
+ if ui_state.status == UIStatus.DISENGAGED:
+ self._confidence_filter.update(-0.5)
+ else:
+ self._confidence_filter.update((1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs or [1])) *
+ (1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.steerOverrideProbs or [1])))
+
+ def _render(self, _):
+ content_rect = rl.Rectangle(
+ self.rect.x + self.rect.width - SIDE_PANEL_WIDTH,
+ self.rect.y,
+ SIDE_PANEL_WIDTH,
+ self.rect.height,
+ )
+
+ status_dot_radius = 24
+ dot_height = (1 - self._confidence_filter.x) * (content_rect.height - 2 * status_dot_radius) + status_dot_radius
+ dot_height = self._rect.y + dot_height
+
+ # confidence zones
+ if ui_state.status == UIStatus.ENGAGED or self._demo:
+ if self._confidence_filter.x > 0.5:
+ top_dot_color = rl.Color(0, 255, 204, 255)
+ bottom_dot_color = rl.Color(0, 255, 38, 255)
+ elif self._confidence_filter.x > 0.2:
+ top_dot_color = rl.Color(255, 200, 0, 255)
+ bottom_dot_color = rl.Color(255, 115, 0, 255)
+ else:
+ top_dot_color = rl.Color(255, 0, 21, 255)
+ bottom_dot_color = rl.Color(255, 0, 89, 255)
+
+ elif ui_state.status == UIStatus.OVERRIDE:
+ top_dot_color = rl.Color(255, 255, 255, 255)
+ bottom_dot_color = rl.Color(82, 82, 82, 255)
+
+ else:
+ top_dot_color = rl.Color(50, 50, 50, 255)
+ bottom_dot_color = rl.Color(13, 13, 13, 255)
+
+ draw_circle_gradient(content_rect.x + content_rect.width - status_dot_radius,
+ dot_height, status_dot_radius,
+ top_dot_color, bottom_dot_color)
diff --git a/selfdrive/ui/mici/onroad/driver_camera_dialog.py b/selfdrive/ui/mici/onroad/driver_camera_dialog.py
new file mode 100644
index 000000000..f2fa5e8fe
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/driver_camera_dialog.py
@@ -0,0 +1,241 @@
+import pyray as rl
+from cereal import log, messaging
+from msgq.visionipc import VisionStreamType
+from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
+from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
+from openpilot.selfdrive.ui.ui_state import ui_state, device
+from openpilot.selfdrive.selfdrived.events import EVENTS, ET
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.lib.multilang import tr
+from openpilot.system.ui.widgets import NavWidget
+from openpilot.system.ui.widgets.label import gui_label
+
+EventName = log.OnroadEvent.EventName
+
+EVENT_TO_INT = EventName.schema.enumerants
+
+
+class DriverCameraDialog(NavWidget):
+ def __init__(self, no_escape=False):
+ super().__init__()
+ self._camera_view = CameraView("camerad", VisionStreamType.VISION_STREAM_DRIVER)
+ self._original_calc_frame_matrix = self._camera_view._calc_frame_matrix
+ self._camera_view._calc_frame_matrix = self._calc_driver_frame_matrix
+ self.driver_state_renderer = DriverStateRenderer(lines=True)
+ self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 200, 200))
+ self.driver_state_renderer.load_icons()
+ self._pm = messaging.PubMaster(['selfdriveState'])
+ if not no_escape:
+ # TODO: this can grow unbounded, should be given some thought
+ device.add_interactive_timeout_callback(self.stop_dmonitoringmodeld)
+ self.set_back_callback(self._dismiss)
+ self.set_back_enabled(not no_escape)
+
+ # Load eye icons
+ self._eye_fill_texture = None
+ self._eye_orange_texture = None
+ self._eye_size = 74
+ self._glasses_texture = None
+ self._glasses_size = 171
+
+ self._load_eye_textures()
+
+ def stop_dmonitoringmodeld(self):
+ ui_state.params.put_bool("IsDriverViewEnabled", False)
+ gui_app.set_modal_overlay(None)
+
+ def show_event(self):
+ super().show_event()
+ ui_state.params.put_bool("IsDriverViewEnabled", True)
+ self._publish_alert_sound(None)
+ device.reset_interactive_timeout(300)
+ ui_state.params.remove("DriverTooDistracted")
+
+ def hide_event(self):
+ super().hide_event()
+ device.reset_interactive_timeout()
+
+ def _handle_mouse_release(self, _):
+ ui_state.params.remove("DriverTooDistracted")
+
+ def _dismiss(self):
+ self.stop_dmonitoringmodeld()
+
+ def close(self):
+ if self._camera_view:
+ self._camera_view.close()
+
+ def _update_state(self):
+ if self._camera_view:
+ self._camera_view._update_state()
+ # Enable driver state renderer to show Dmoji in preview
+ self.driver_state_renderer.set_should_draw(True)
+ self.driver_state_renderer.set_force_active(True)
+ super()._update_state()
+
+ def _render(self, rect):
+ rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
+ self._camera_view._render(rect)
+
+ if not self._camera_view.frame:
+ gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
+ rl.end_scissor_mode()
+ self._publish_alert_sound(None)
+ return -1
+
+ self._draw_face_detection(rect)
+
+ # Position dmoji on opposite side from driver
+ dm_state = ui_state.sm["driverMonitoringState"]
+ driver_state_rect = (
+ rect.x if dm_state.isRHD else rect.x + rect.width - self.driver_state_renderer.rect.width,
+ rect.y + (rect.height - self.driver_state_renderer.rect.height) / 2,
+ )
+ self.driver_state_renderer.set_position(*driver_state_rect)
+ self.driver_state_renderer.render()
+
+ # Render driver monitoring alerts
+ self._render_dm_alerts(rect)
+
+ rl.end_scissor_mode()
+ return -1
+
+ def _publish_alert_sound(self, dm_state):
+ """Publish selfdriveState with only alertSound field set"""
+ msg = messaging.new_message('selfdriveState')
+ if dm_state is not None and len(dm_state.events):
+ event_name = EVENT_TO_INT[dm_state.events[0].name]
+ if event_name is not None and event_name in EVENTS and ET.PERMANENT in EVENTS[event_name]:
+ msg.selfdriveState.alertSound = EVENTS[event_name][ET.PERMANENT].audible_alert
+ self._pm.send('selfdriveState', msg)
+
+ def _render_dm_alerts(self, rect: rl.Rectangle):
+ """Render driver monitoring event names"""
+ dm_state = ui_state.sm["driverMonitoringState"]
+ self._publish_alert_sound(dm_state)
+
+ gui_label(rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height),
+ f"Awareness: {dm_state.awarenessStatus * 100:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
+ color=rl.Color(0, 0, 0, 180))
+ gui_label(rect, f"Awareness: {dm_state.awarenessStatus * 100:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
+ color=rl.Color(255, 255, 255, int(255 * 0.9)))
+
+ if not dm_state.events:
+ return
+
+ # Show first event (only one should be active at a time)
+ event_name_str = str(dm_state.events[0].name).split('.')[-1]
+ alignment = rl.GuiTextAlignment.TEXT_ALIGN_RIGHT if dm_state.isRHD else rl.GuiTextAlignment.TEXT_ALIGN_LEFT
+
+ shadow_rect = rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height)
+ gui_label(shadow_rect, event_name_str, font_size=40, font_weight=FontWeight.BOLD,
+ alignment=alignment,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
+ color=rl.Color(0, 0, 0, 180))
+ gui_label(rect, event_name_str, font_size=40, font_weight=FontWeight.BOLD,
+ alignment=alignment,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
+ color=rl.Color(255, 255, 255, int(255 * 0.9)))
+
+ def _load_eye_textures(self):
+ """Lazy load eye textures"""
+ if self._eye_fill_texture is None:
+ self._eye_fill_texture = gui_app.texture("icons_mici/onroad/eye_fill.png", self._eye_size, self._eye_size)
+ if self._eye_orange_texture is None:
+ self._eye_orange_texture = gui_app.texture("icons_mici/onroad/eye_orange.png", self._eye_size, self._eye_size)
+ if self._glasses_texture is None:
+ self._glasses_texture = gui_app.texture("icons_mici/onroad/glasses.png", self._glasses_size, self._glasses_size)
+
+ 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
+ if not face_detect:
+ return
+
+ # Get face position and orientation
+ face_x, face_y = driver_data.facePosition
+ face_std = max(driver_data.faceOrientationStd[0], driver_data.faceOrientationStd[1])
+ alpha = 0.7
+ if face_std > 0.15:
+ alpha = max(0.7 - (face_std - 0.15) * 3.5, 0.0)
+
+ # use approx instead of distort_points
+ # TODO: replace with distort_points
+ tici_x = 1080.0 - 1714.0 * face_x
+ tici_y = -135.0 + (504.0 + abs(face_x) * 112.0) + (1205.0 - abs(face_x) * 724.0) * face_y
+
+ # Tici coords are relative to center, scale offset
+ offset_x = (tici_x - 1080.0) * 1.25
+ offset_y = (tici_y - 540.0) * 1.25
+
+ # Map to mici screen (scale from 2160x1080 to rect dimensions)
+ scale_x = rect.width / 2160.0
+ scale_y = rect.height / 1080.0
+ fbox_x = rect.x + rect.width / 2 + offset_x * scale_x
+ fbox_y = rect.y + rect.height / 2 + offset_y * scale_y
+ box_size = 50
+ line_thickness = 3
+
+ line_color = rl.Color(255, 255, 255, int(alpha * 255))
+ rl.draw_rectangle_rounded_lines_ex(
+ rl.Rectangle(fbox_x - box_size / 2, fbox_y - box_size / 2, box_size, box_size),
+ 35.0 / box_size / 2,
+ line_thickness,
+ line_thickness,
+ line_color,
+ )
+
+ # Draw eye indicators based on eye probabilities
+ eye_offset_x = 10
+ eye_offset_y = 10
+ eye_spacing = self._eye_size + 15
+
+ left_eye_x = rect.x + eye_offset_x
+ left_eye_y = rect.y + eye_offset_y
+ left_eye_prob = driver_data.leftEyeProb
+
+ right_eye_x = rect.x + eye_offset_x + eye_spacing
+ right_eye_y = rect.y + eye_offset_y
+ right_eye_prob = driver_data.rightEyeProb
+
+ # Draw eyes with opacity based on probability
+ for eye_x, eye_y, eye_prob in [(left_eye_x, left_eye_y, left_eye_prob), (right_eye_x, right_eye_y, right_eye_prob)]:
+ fill_opacity = eye_prob
+ orange_opacity = 1.0 - eye_prob
+
+ rl.draw_texture_v(self._eye_orange_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * orange_opacity)))
+ rl.draw_texture_v(self._eye_fill_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * fill_opacity)))
+
+ # Draw sunglasses indicator based on sunglasses probability
+ # Position glasses centered between the two eyes at top left
+ glasses_x = rect.x + eye_offset_x - 4
+ glasses_y = rect.y
+ glasses_pos = rl.Vector2(glasses_x, glasses_y)
+ glasses_prob = driver_data.sunglassesProb
+ rl.draw_texture_v(self._glasses_texture, glasses_pos, rl.Color(70, 80, 161, int(255 * glasses_prob)))
+
+ def _calc_driver_frame_matrix(self, rect: rl.Rectangle):
+ base = self._original_calc_frame_matrix(rect)
+ driver_view_ratio = 1.5
+ base[0, 0] *= driver_view_ratio
+ base[1, 1] *= driver_view_ratio
+ return base
+
+
+if __name__ == "__main__":
+ gui_app.init_window("Driver Camera View (mici)")
+
+ driver_camera_view = DriverCameraDialog()
+ try:
+ for _ in gui_app.render():
+ ui_state.update()
+ driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ finally:
+ driver_camera_view.close()
diff --git a/selfdrive/ui/mici/onroad/driver_state.py b/selfdrive/ui/mici/onroad/driver_state.py
new file mode 100644
index 000000000..369055846
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/driver_state.py
@@ -0,0 +1,227 @@
+import pyray as rl
+from collections.abc import Callable
+import numpy as np
+import math
+from cereal import log
+from openpilot.common.filter_simple import FirstOrderFilter
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.widgets import Widget
+from openpilot.selfdrive.ui.ui_state import ui_state
+
+AlertSize = log.SelfdriveState.AlertSize
+
+DEBUG = False
+
+LOOKING_CENTER_THRESHOLD_UPPER = math.radians(6)
+LOOKING_CENTER_THRESHOLD_LOWER = math.radians(3)
+
+
+class DriverStateRenderer(Widget):
+ BASE_SIZE = 60
+ LINES_ANGLE_INCREMENT = 5
+ LINES_STALE_ANGLES = 3.0 # seconds
+
+ def __init__(self, lines: bool = False, confirm_mode: bool = False, confirm_callback: Callable | None = None):
+ super().__init__()
+ self.set_rect(rl.Rectangle(0, 0, self.BASE_SIZE, self.BASE_SIZE))
+ self._lines = lines or confirm_mode
+
+ # In confirm mode, user must fill out the circle to confirm some action in the UI
+ self._confirm_mode = confirm_mode
+ self._confirm_callback = confirm_callback
+ self._confirm_angles: dict[int, float] = {} # angle: timestamp
+
+ # In line mode, track smoothed angles
+ assert 360 % self.LINES_ANGLE_INCREMENT == 0
+ self._head_angles = {i * self.LINES_ANGLE_INCREMENT: FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) for i in range(360 // self.LINES_ANGLE_INCREMENT)}
+
+ self._is_active = False
+ self._is_rhd = False
+ self._face_detected = False
+ self._should_draw = False
+ self._force_active = False
+ self._looking_center = False
+
+ self._fade_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
+ self._pitch_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps, initialized=False)
+ self._yaw_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps, initialized=False)
+ self._rotation_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps, initialized=False)
+ self._looking_center_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
+
+ # Load the driver face icons
+ self.load_icons()
+
+ def load_icons(self):
+ """Load or reload the driver face icon texture"""
+ self._dm_person = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_person.png", self._rect.width, self._rect.height)
+ self._dm_cone = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_cone.png", self._rect.width, self._rect.height)
+ center_size = round(36 / self.BASE_SIZE * self._rect.width)
+ self._dm_center = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_center.png", center_size, center_size)
+ background_size = round(52 / self.BASE_SIZE * self._rect.width)
+ self._dm_background = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_background.png", background_size, background_size)
+
+ def set_should_draw(self, should_draw: bool):
+ self._should_draw = should_draw
+
+ @property
+ def should_draw(self):
+ return (self._should_draw and ui_state.sm["selfdriveState"].alertSize == AlertSize.none and
+ ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame)
+
+ def set_force_active(self, force_active: bool):
+ """Force the dmoji to always appear active (green) regardless of actual state"""
+ self._force_active = force_active
+
+ @property
+ def effective_active(self) -> bool:
+ """Returns True if dmoji should appear active (either actually active or forced)"""
+ return bool(self._force_active or self._is_active)
+
+ def _render(self, _):
+ if DEBUG:
+ rl.draw_rectangle_lines_ex(self._rect, 1, rl.RED)
+
+ rl.draw_texture(self._dm_background,
+ int(self._rect.x + (self._rect.width - self._dm_background.width) / 2),
+ int(self._rect.y + (self._rect.height - self._dm_background.height) / 2),
+ rl.Color(255, 255, 255, int(255 * self._fade_filter.x)))
+
+ rl.draw_texture(self._dm_person, int(self._rect.x), int(self._rect.y),
+ rl.Color(255, 255, 255, int(255 * 0.9 * self._fade_filter.x)))
+
+ if self.effective_active:
+ source_rect = rl.Rectangle(0, 0, self._dm_cone.width, self._dm_cone.height)
+ dest_rect = rl.Rectangle(
+ self._rect.x + self._rect.width / 2,
+ self._rect.y + self._rect.height / 2,
+ self._dm_cone.width,
+ self._dm_cone.height,
+ )
+
+ if not self._lines:
+ rl.draw_texture_pro(
+ self._dm_cone,
+ source_rect,
+ dest_rect,
+ rl.Vector2(dest_rect.width / 2, dest_rect.height / 2),
+ self._rotation_filter.x - 90,
+ rl.Color(255, 255, 255, int(255 * self._fade_filter.x * (1 - self._looking_center_filter.x))),
+ )
+
+ rl.draw_texture_ex(
+ self._dm_center,
+ (int(self._rect.x + (self._rect.width - self._dm_center.width) / 2),
+ int(self._rect.y + (self._rect.height - self._dm_center.height) / 2)),
+ 0,
+ 1.0,
+ rl.Color(255, 255, 255, int(255 * self._fade_filter.x * self._looking_center_filter.x)),
+ )
+
+ else:
+ # remove old angles
+ now = rl.get_time()
+ self._confirm_angles = {angle: t for angle, t in self._confirm_angles.items() if now - t < self.LINES_STALE_ANGLES}
+
+ looking_center = self._looking_center_filter.x > 0.2
+ for angle, f in self._head_angles.items():
+ dst_from_current = ((angle - self._rotation_filter.x) % 360) - 180
+ target = 1.0 if abs(dst_from_current) <= self.LINES_ANGLE_INCREMENT * 5 else 0.0
+ if not self._face_detected:
+ target = 0.0
+
+ if self._confirm_mode:
+ # Extra careful to not add angles when looking near center
+ if target > 0 and not looking_center:
+ self._confirm_angles[angle] = now
+
+ # User is looking at area already confirmed, reduce target to indicate where they are
+ if angle in self._confirm_angles and target == 0:
+ target = 0.65
+
+ # Reduce all line lengths when looking center
+ if self._looking_center:
+ target = np.interp(self._looking_center_filter.x, [0.0, 1.0], [target, 0.45])
+
+ f.update(target)
+ self._draw_line(angle, f, self._looking_center and angle not in self._confirm_angles)
+
+ # if all lines placed, reset for next time and call callback
+ if self._confirm_mode:
+ if len(self._confirm_angles) >= 360 // self.LINES_ANGLE_INCREMENT:
+ self._confirm_angles = {}
+ if self._confirm_callback is not None:
+ self._confirm_callback()
+
+ def _draw_line(self, angle: int, f: FirstOrderFilter, grey: bool):
+ line_length = self._rect.width / 6
+ line_length = round(np.interp(f.x, [0.0, 1.0], [0, line_length]))
+ line_offset = self._rect.width / 2 - line_length * 2 # ensure line ends within rect
+ center_x = self._rect.x + self._rect.width / 2
+ center_y = self._rect.y + self._rect.height / 2
+ start_x = center_x + (line_offset + line_length) * math.cos(math.radians(angle))
+ start_y = center_y + (line_offset + line_length) * math.sin(math.radians(angle))
+ end_x = start_x + line_length * math.cos(math.radians(angle))
+ end_y = start_y + line_length * math.sin(math.radians(angle))
+ color = rl.Color(0, 255, 64, 255)
+
+ if grey:
+ color = rl.Color(166, 166, 166, 255)
+
+ if f.x > 0.01:
+ rl.draw_line_ex((start_x, start_y), (end_x, end_y), 12, color)
+
+ def _update_state(self):
+ sm = ui_state.sm
+
+ # Get monitoring state
+ dm_state = sm["driverMonitoringState"]
+ self._is_active = dm_state.isActiveMode
+ self._is_rhd = dm_state.isRHD
+ self._face_detected = dm_state.faceDetected
+
+ driverstate = sm["driverStateV2"]
+ driver_data = driverstate.rightDriverData if self._is_rhd else driverstate.leftDriverData
+ driver_orient = driver_data.faceOrientation
+
+ if len(driver_orient) != 3:
+ return
+
+ pitch, yaw, roll = driver_orient
+ pitch = self._pitch_filter.update(pitch)
+ yaw = self._yaw_filter.update(yaw)
+
+ # hysteresis on looking center
+ if abs(pitch) < LOOKING_CENTER_THRESHOLD_LOWER and abs(yaw) < LOOKING_CENTER_THRESHOLD_LOWER:
+ self._looking_center = True
+ elif abs(pitch) > LOOKING_CENTER_THRESHOLD_UPPER or abs(yaw) > LOOKING_CENTER_THRESHOLD_UPPER:
+ self._looking_center = False
+ self._looking_center_filter.update(1 if self._looking_center else 0)
+
+ if DEBUG:
+ pitchd = math.degrees(pitch)
+ yawd = math.degrees(yaw)
+ rolld = math.degrees(roll)
+
+ rl.draw_line_ex((0, 100), (200, 100), 3, rl.RED)
+ rl.draw_line_ex((0, 120), (200, 120), 3, rl.RED)
+ rl.draw_line_ex((0, 140), (200, 140), 3, rl.RED)
+
+ pitch_x = 100 + pitchd
+ yaw_x = 100 + yawd
+ roll_x = 100 + rolld
+ rl.draw_circle(int(pitch_x), 100, 5, rl.GREEN)
+ rl.draw_circle(int(yaw_x), 120, 5, rl.GREEN)
+ rl.draw_circle(int(roll_x), 140, 5, rl.GREEN)
+
+ # filter head rotation, handling wrap-around
+ rotation = math.degrees(math.atan2(pitch, yaw))
+ angle_diff = rotation - self._rotation_filter.x
+ angle_diff = ((angle_diff + 180) % 360) - 180
+ self._rotation_filter.update(self._rotation_filter.x + angle_diff)
+
+ if not self.should_draw:
+ self._fade_filter.update(0.0)
+ elif not self.effective_active:
+ self._fade_filter.update(0.35)
+ else:
+ self._fade_filter.update(1.0)
diff --git a/selfdrive/ui/mici/onroad/hud_renderer.py b/selfdrive/ui/mici/onroad/hud_renderer.py
new file mode 100644
index 000000000..bb5171d6e
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/hud_renderer.py
@@ -0,0 +1,287 @@
+import pyray as rl
+from dataclasses import dataclass
+from openpilot.common.constants import CV
+from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar
+from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.lib.multilang import tr
+from openpilot.system.ui.lib.text_measure import measure_text_cached
+from openpilot.system.ui.widgets import Widget
+from openpilot.common.filter_simple import FirstOrderFilter
+from cereal import log
+
+EventName = log.OnroadEvent.EventName
+
+# Constants
+SET_SPEED_NA = 255
+KM_TO_MILE = 0.621371
+CRUISE_DISABLED_CHAR = '–'
+
+SET_SPEED_PERSISTENCE = 2.5 # seconds
+
+
+@dataclass(frozen=True)
+class FontSizes:
+ current_speed: int = 176
+ speed_unit: int = 66
+ max_speed: int = 36
+ set_speed: int = 112
+
+
+@dataclass(frozen=True)
+class Colors:
+ white: rl.Color = rl.WHITE
+ disengaged: rl.Color = rl.Color(145, 155, 149, 255)
+ override: rl.Color = rl.Color(145, 155, 149, 255) # Added
+ engaged: rl.Color = rl.Color(128, 216, 166, 255)
+ disengaged_bg: rl.Color = rl.Color(0, 0, 0, 153)
+ override_bg: rl.Color = rl.Color(145, 155, 149, 204)
+ engaged_bg: rl.Color = rl.Color(128, 216, 166, 204)
+ grey: rl.Color = rl.Color(166, 166, 166, 255)
+ dark_grey: rl.Color = rl.Color(114, 114, 114, 255)
+ black_translucent: rl.Color = rl.Color(0, 0, 0, 166)
+ white_translucent: rl.Color = rl.Color(255, 255, 255, 200)
+ border_translucent: rl.Color = rl.Color(255, 255, 255, 75)
+ header_gradient_start: rl.Color = rl.Color(0, 0, 0, 114)
+ header_gradient_end: rl.Color = rl.BLANK
+
+
+FONT_SIZES = FontSizes()
+COLORS = Colors()
+
+
+class TurnIntent(Widget):
+ FADE_IN_ANGLE = 30 # degrees
+
+ def __init__(self):
+ super().__init__()
+ self._pre = False
+ self._turn_intent_direction: int = 0
+
+ self._turn_intent_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
+ self._turn_intent_rotation_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
+
+ self._txt_turn_intent_left: rl.Texture = gui_app.texture('icons_mici/turn_intent_left.png', 50, 19)
+ self._txt_turn_intent_right: rl.Texture = gui_app.texture('icons_mici/turn_intent_right.png', 50, 19)
+
+ def _render(self, _):
+ if self._turn_intent_alpha_filter.x > 1e-2:
+ turn_intent_texture = self._txt_turn_intent_right if self._turn_intent_direction == 1 else self._txt_turn_intent_left
+ src_rect = rl.Rectangle(0, 0, turn_intent_texture.width, turn_intent_texture.height)
+ dest_rect = rl.Rectangle(self._rect.x + self._rect.width / 2, self._rect.y + self._rect.height / 2,
+ turn_intent_texture.width, turn_intent_texture.height)
+
+ origin = (turn_intent_texture.width / 2, self._rect.height / 2)
+ color = rl.Color(255, 255, 255, int(255 * self._turn_intent_alpha_filter.x))
+ rl.draw_texture_pro(turn_intent_texture, src_rect, dest_rect, origin, self._turn_intent_rotation_filter.x, color)
+
+ def _update_state(self) -> None:
+ sm = ui_state.sm
+
+ left = any(e.name == EventName.preLaneChangeLeft for e in sm['onroadEvents'])
+ right = any(e.name == EventName.preLaneChangeRight for e in sm['onroadEvents'])
+ if left or right:
+ # pre lane change
+ if not self._pre:
+ self._turn_intent_rotation_filter.x = self.FADE_IN_ANGLE if left else -self.FADE_IN_ANGLE
+
+ self._pre = True
+ self._turn_intent_direction = -1 if left else 1
+ self._turn_intent_alpha_filter.update(1)
+ self._turn_intent_rotation_filter.update(0)
+ elif any(e.name == EventName.laneChange for e in sm['onroadEvents']):
+ # fade out and rotate away
+ self._pre = False
+ self._turn_intent_alpha_filter.update(0)
+
+ if self._turn_intent_direction == 0:
+ # unknown. missed pre frame?
+ self._turn_intent_rotation_filter.update(0)
+ else:
+ self._turn_intent_rotation_filter.update(self._turn_intent_direction * self.FADE_IN_ANGLE)
+ else:
+ # didn't complete lane change, just hide
+ self._pre = False
+ self._turn_intent_direction = 0
+ self._turn_intent_alpha_filter.update(0)
+ self._turn_intent_rotation_filter.update(0)
+
+
+class HudRenderer(Widget):
+ def __init__(self):
+ super().__init__()
+ """Initialize the HUD renderer."""
+ self.is_cruise_set: bool = False
+ self.is_cruise_available: bool = True
+ self.set_speed: float = SET_SPEED_NA
+ self._set_speed_changed_time: float = 0
+ self.speed: float = 0.0
+ self.v_ego_cluster_seen: bool = False
+ self._engaged: bool = False
+
+ self._can_draw_top_icons = True
+ self._show_wheel_critical = False
+
+ self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
+ self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM)
+ self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD)
+ self._font_display: rl.Font = gui_app.font(FontWeight.DISPLAY)
+
+ self._turn_intent = TurnIntent()
+ self._torque_bar = TorqueBar()
+
+ self._txt_wheel: rl.Texture = gui_app.texture('icons_mici/wheel.png', 50, 50)
+ self._txt_wheel_critical: rl.Texture = gui_app.texture('icons_mici/wheel_critical.png', 50, 50)
+ self._txt_exclamation_point: rl.Texture = gui_app.texture('icons_mici/exclamation_point.png', 44, 44)
+
+ self._wheel_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
+ self._wheel_y_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
+
+ self._set_speed_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
+
+ def set_wheel_critical_icon(self, critical: bool):
+ """Set the wheel icon to critical or normal state."""
+ self._show_wheel_critical = critical
+
+ def set_can_draw_top_icons(self, can_draw_top_icons: bool):
+ """Set whether to draw the top part of the HUD."""
+ self._can_draw_top_icons = can_draw_top_icons
+
+ def drawing_top_icons(self) -> bool:
+ # whether we're drawing any top icons currently
+ return bool(self._set_speed_alpha_filter.x > 1e-2)
+
+ 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
+ self.speed = 0.0
+ return
+
+ controls_state = sm['controlsState']
+ car_state = sm['carState']
+
+ v_cruise_cluster = car_state.vCruiseCluster
+ set_speed = (
+ controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
+ )
+ engaged = sm['selfdriveState'].enabled
+ if (set_speed != self.set_speed and engaged) or (engaged and not self._engaged):
+ self._set_speed_changed_time = rl.get_time()
+ self._engaged = engaged
+ self.set_speed = set_speed
+ self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA
+ self.is_cruise_available = self.set_speed != -1
+
+ v_ego_cluster = car_state.vEgoCluster
+ self.v_ego_cluster_seen = self.v_ego_cluster_seen or v_ego_cluster != 0.0
+ v_ego = v_ego_cluster if self.v_ego_cluster_seen else car_state.vEgo
+ 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 _render(self, rect: rl.Rectangle) -> None:
+ """Render HUD elements to the screen."""
+
+ if ui_state.sm['controlsState'].lateralControlState.which() != 'angleState':
+ self._torque_bar.render(rect)
+
+ if self.is_cruise_set:
+ self._draw_set_speed(rect)
+
+ self._draw_steering_wheel(rect)
+
+ def _draw_steering_wheel(self, rect: rl.Rectangle) -> None:
+ wheel_txt = self._txt_wheel_critical if self._show_wheel_critical else self._txt_wheel
+
+ if self._show_wheel_critical:
+ self._wheel_alpha_filter.update(255)
+ self._wheel_y_filter.update(0)
+ else:
+ if ui_state.status == UIStatus.DISENGAGED:
+ self._wheel_alpha_filter.update(0)
+ self._wheel_y_filter.update(wheel_txt.height / 2)
+ else:
+ self._wheel_alpha_filter.update(255 * 0.9)
+ self._wheel_y_filter.update(0)
+
+ # pos
+ pos_x = int(rect.x + 21 + wheel_txt.width / 2)
+ pos_y = int(rect.y + rect.height - 14 - wheel_txt.height / 2 + self._wheel_y_filter.x)
+ rotation = -ui_state.sm['carState'].steeringAngleDeg
+
+ turn_intent_margin = 25
+ self._turn_intent.render(rl.Rectangle(
+ pos_x - wheel_txt.width / 2 - turn_intent_margin,
+ pos_y - wheel_txt.height / 2 - turn_intent_margin,
+ wheel_txt.width + turn_intent_margin * 2,
+ wheel_txt.height + turn_intent_margin * 2,
+ ))
+
+ src_rect = rl.Rectangle(0, 0, wheel_txt.width, wheel_txt.height)
+ dest_rect = rl.Rectangle(pos_x, pos_y, wheel_txt.width, wheel_txt.height)
+ origin = (wheel_txt.width / 2, wheel_txt.height / 2)
+
+ # color and draw
+ color = rl.Color(255, 255, 255, int(self._wheel_alpha_filter.x))
+ rl.draw_texture_pro(wheel_txt, src_rect, dest_rect, origin, rotation, color)
+
+ if self._show_wheel_critical:
+ # Draw exclamation point icon
+ EXCLAMATION_POINT_SPACING = 10
+ exclamation_pos_x = pos_x - self._txt_exclamation_point.width / 2 + wheel_txt.width / 2 + EXCLAMATION_POINT_SPACING
+ exclamation_pos_y = pos_y - self._txt_exclamation_point.height / 2
+ rl.draw_texture(self._txt_exclamation_point, int(exclamation_pos_x), int(exclamation_pos_y), rl.WHITE)
+
+ def _draw_set_speed(self, rect: rl.Rectangle) -> None:
+ """Draw the MAX speed indicator box."""
+ x = rect.x
+ y = rect.y
+
+ alpha = self._set_speed_alpha_filter.update(0 < rl.get_time() - self._set_speed_changed_time < SET_SPEED_PERSISTENCE and
+ self._can_draw_top_icons and self._engaged)
+
+ # draw drop shadow
+ circle_radius = 162 // 2
+ rl.draw_circle_gradient(int(x + circle_radius), int(y + circle_radius), circle_radius,
+ rl.Color(0, 0, 0, int(255 / 2 * alpha)), rl.Color(0, 0, 0, 0))
+
+ set_speed_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha))
+ max_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha))
+
+ set_speed = self.set_speed
+ if self.is_cruise_set and not ui_state.is_metric:
+ set_speed *= KM_TO_MILE
+
+ set_speed_text = CRUISE_DISABLED_CHAR if not self.is_cruise_set else str(round(set_speed))
+ rl.draw_text_ex(
+ self._font_display,
+ set_speed_text,
+ rl.Vector2(x + 13 + 4, y + 3 - 8 - 3 + 4),
+ FONT_SIZES.set_speed,
+ 0,
+ set_speed_color,
+ )
+
+ max_text = tr("MAX")
+ rl.draw_text_ex(
+ self._font_semi_bold,
+ max_text,
+ rl.Vector2(x + 25, y + FONT_SIZES.set_speed - 7 + 4),
+ FONT_SIZES.max_speed,
+ 0,
+ max_color,
+ )
+
+ def _draw_current_speed(self, rect: rl.Rectangle) -> None:
+ """Draw the current vehicle speed and unit."""
+ speed_text = str(round(self.speed))
+ speed_text_size = measure_text_cached(self._font_bold, speed_text, FONT_SIZES.current_speed)
+ speed_pos = rl.Vector2(rect.x + rect.width / 2 - speed_text_size.x / 2, 180 - speed_text_size.y / 2)
+ rl.draw_text_ex(self._font_bold, speed_text, speed_pos, FONT_SIZES.current_speed, 0, COLORS.white)
+
+ unit_text = tr("km/h") if ui_state.is_metric else tr("mph")
+ 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)
diff --git a/selfdrive/ui/mici/onroad/model_renderer.py b/selfdrive/ui/mici/onroad/model_renderer.py
new file mode 100644
index 000000000..3f1badfe8
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/model_renderer.py
@@ -0,0 +1,479 @@
+import colorsys
+import numpy as np
+import pyray as rl
+from cereal import messaging, car
+from dataclasses import dataclass, field
+from openpilot.common.params import Params
+from openpilot.common.filter_simple import FirstOrderFilter
+from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT
+from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
+from openpilot.selfdrive.ui.mici.onroad import blend_colors
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
+from openpilot.system.ui.widgets import Widget
+
+CLIP_MARGIN = 500
+MIN_DRAW_DISTANCE = 10.0
+MAX_DRAW_DISTANCE = 100.0
+
+THROTTLE_COLORS = [
+ rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4)
+ rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35)
+ rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0)
+]
+
+NO_THROTTLE_COLORS = [
+ rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4)
+ rl.Color(242, 242, 242, 89), # HSLF(112/360, 0.0, 0.95, 0.35)
+ rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0)
+]
+
+LANE_LINE_COLORS = {
+ UIStatus.DISENGAGED: rl.Color(200, 200, 200, 255),
+ UIStatus.OVERRIDE: rl.Color(255, 255, 255, 255),
+ UIStatus.ENGAGED: rl.Color(0, 255, 64, 255),
+}
+
+
+@dataclass
+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)
+ chevron: list[float] = field(default_factory=list)
+ fill_alpha: int = 0
+
+
+class ModelRenderer(Widget):
+ def __init__(self):
+ super().__init__()
+ self._longitudinal_control = False
+ self._experimental_mode = False
+ self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps)
+ self._prev_allow_throttle = True
+ self._lane_line_probs = np.zeros(4, dtype=np.float32)
+ self._road_edge_stds = np.zeros(2, dtype=np.float32)
+ self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
+ self._path_offset_z = HEIGHT_INIT[0]
+
+ # Initialize ModelPoints objects
+ self._path = ModelPoints()
+ self._lane_lines = [ModelPoints() for _ in range(4)]
+ self._road_edges = [ModelPoints() for _ in range(2)]
+ self._acceleration_x = np.empty((0,), dtype=np.float32)
+
+ self._acceleration_x_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
+ self._acceleration_x_filter2 = FirstOrderFilter(0.0, 1, 1 / gui_app.target_fps)
+
+ self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
+ self._ll_color_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
+
+ # Transform matrix (3x3 for car space to screen space)
+ self._car_space_transform = np.zeros((3, 3), dtype=np.float32)
+ self._transform_dirty = True
+ self._clip_region = None
+
+ self._exp_gradient = Gradient(
+ start=(0.0, 1.0), # Bottom of path
+ end=(0.0, 0.0), # Top of path
+ colors=[],
+ stops=[],
+ )
+
+ # Get longitudinal control setting from car parameters
+ if car_params := Params().get("CarParams"):
+ cp = messaging.log_from_bytes(car_params, car.CarParams)
+ self._longitudinal_control = cp.openpilotLongitudinalControl
+
+ def set_transform(self, transform: np.ndarray):
+ self._car_space_transform = transform.astype(np.float32)
+ self._transform_dirty = True
+
+ def _render(self, rect: rl.Rectangle):
+ sm = ui_state.sm
+
+ self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque)
+
+ # 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._clip_region = rl.Rectangle(
+ rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN
+ )
+
+ # Update state
+ self._experimental_mode = sm['selfdriveState'].experimentalMode
+
+ live_calib = sm['liveCalibration']
+ self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0]
+
+ if sm.updated['carParams']:
+ self._longitudinal_control = sm['carParams'].openpilotLongitudinalControl
+
+ model = sm['modelV2']
+ radar_state = sm['radarState'] if sm.valid['radarState'] else None
+ lead_one = radar_state.leadOne if radar_state else None
+ render_lead_indicator = self._longitudinal_control and radar_state is not None
+
+ # Update model data when needed
+ model_updated = sm.updated['modelV2']
+ if model_updated or sm.updated['radarState'] or self._transform_dirty:
+ if model_updated:
+ self._update_raw_points(model)
+
+ path_x_array = self._path.raw_points[:, 0]
+ if path_x_array.size == 0:
+ return
+
+ self._update_model(lead_one, path_x_array)
+ if render_lead_indicator:
+ self._update_leads(radar_state, path_x_array)
+ self._transform_dirty = False
+
+ # Draw elements (hide when disengaged)
+ if ui_state.status != UIStatus.DISENGAGED:
+ self._draw_lane_lines()
+ self._draw_path(sm)
+
+ # if render_lead_indicator and radar_state:
+ # self._draw_lead_indicator()
+
+ def _update_raw_points(self, model):
+ """Update raw 3D points from model data"""
+ self._path.raw_points = np.array([model.position.x, model.position.y, model.position.z], dtype=np.float32).T
+
+ for i, lane_line in enumerate(model.laneLines):
+ self._lane_lines[i].raw_points = np.array([lane_line.x, lane_line.y, lane_line.z], dtype=np.float32).T
+
+ for i, road_edge in enumerate(model.roadEdges):
+ self._road_edges[i].raw_points = np.array([road_edge.x, road_edge.y, road_edge.z], dtype=np.float32).T
+
+ self._lane_line_probs = np.array(model.laneLineProbs, dtype=np.float32)
+ self._road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32)
+ self._acceleration_x = np.array(model.acceleration.x, dtype=np.float32)
+
+ def _update_leads(self, radar_state, path_x_array):
+ """Update positions of lead vehicles"""
+ self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
+ leads = [radar_state.leadOne, radar_state.leadTwo]
+
+ for i, lead_data in enumerate(leads):
+ if lead_data and lead_data.status:
+ d_rel, y_rel, v_rel = lead_data.dRel, lead_data.yRel, lead_data.vRel
+ idx = self._get_path_length_idx(path_x_array, d_rel)
+
+ # Get z-coordinate from path at the lead vehicle position
+ z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0
+ point = self._map_to_screen(d_rel, -y_rel, z + self._path_offset_z)
+ if point:
+ self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect)
+
+ def _update_model(self, lead, path_x_array):
+ """Update model visualization data based on model message"""
+ max_distance = np.clip(path_x_array[-1], MIN_DRAW_DISTANCE, MAX_DRAW_DISTANCE)
+ max_idx = self._get_path_length_idx(self._lane_lines[0].raw_points[:, 0], max_distance)
+
+ # Update lane lines using raw points
+ line_width_factor = 0.12
+ for i, lane_line in enumerate(self._lane_lines):
+ if i in (1, 2):
+ line_width_factor = 0.16
+ lane_line.projected_points = self._map_line_to_polygon(
+ lane_line.raw_points, line_width_factor * self._lane_line_probs[i], 0.0, max_idx
+ )
+
+ # Update road edges using raw points
+ for road_edge in self._road_edges:
+ road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, line_width_factor, 0.0, max_idx)
+
+ # Update path using raw points
+ if lead and lead.status:
+ lead_d = lead.dRel * 2.0
+ max_distance = np.clip(lead_d - min(lead_d * 0.35, 10.0), 0.0, max_distance)
+
+ soon_acceleration = self._acceleration_x[len(self._acceleration_x) // 4] if len(self._acceleration_x) > 0 else 0
+ self._acceleration_x_filter.update(soon_acceleration)
+ self._acceleration_x_filter2.update(soon_acceleration)
+
+ # make path width wider/thinner when initially braking/accelerating
+ if self._experimental_mode and False:
+ high_pass_acceleration = self._acceleration_x_filter.x - self._acceleration_x_filter2.x
+ y_off = np.interp(high_pass_acceleration, [-1, 0, 1], [0.9 * 2, 0.9, 0.9 / 2])
+ else:
+ y_off = 0.9
+
+ max_idx = self._get_path_length_idx(path_x_array, max_distance)
+ self._path.projected_points = self._map_line_to_polygon(
+ self._path.raw_points, y_off, self._path_offset_z, max_idx, allow_invert=False
+ )
+
+ self._update_experimental_gradient()
+
+ def _update_experimental_gradient(self):
+ """Pre-calculate experimental mode gradient colors"""
+ if not self._experimental_mode:
+ return
+
+ max_len = min(len(self._path.projected_points) // 2, len(self._acceleration_x))
+
+ segment_colors = []
+ gradient_stops = []
+
+ i = 0
+ while i < max_len:
+ # Some points (screen space) are out of frame (rect space)
+ track_y = self._path.projected_points[i][1]
+ if track_y < self._rect.y or track_y > (self._rect.y + self._rect.height):
+ i += 1
+ continue
+
+ # Calculate color based on acceleration (0 is bottom, 1 is top)
+ lin_grad_point = 1 - (track_y - self._rect.y) / self._rect.height
+
+ # speed up: 120, slow down: 0
+ path_hue = np.clip(60 + self._acceleration_x[i] * 35, 0, 120)
+
+ saturation = min(abs(self._acceleration_x[i] * 1.5), 1)
+ lightness = np.interp(saturation, [0.0, 1.0], [0.95, 0.62])
+ alpha = np.interp(lin_grad_point, [0.75 / 2.0, 0.75], [0.4, 0.0])
+
+ # Use HSL to RGB conversion
+ color = self._hsla_to_color(path_hue / 360.0, saturation, lightness, alpha)
+
+ gradient_stops.append(lin_grad_point)
+ segment_colors.append(color)
+
+ # Skip a point, unless next is last
+ i += 1 + (1 if (i + 2) < max_len else 0)
+
+ # Store the gradient in the path object
+ self._exp_gradient.colors = segment_colors
+ self._exp_gradient.stops = gradient_stops
+
+ def _update_lead_vehicle(self, d_rel, v_rel, point, rect):
+ speed_buff, lead_buff = 10.0, 40.0
+
+ # Calculate fill alpha
+ fill_alpha = 0
+ if d_rel < lead_buff:
+ fill_alpha = 255 * (1.0 - (d_rel / lead_buff))
+ if v_rel < 0:
+ fill_alpha += 255 * (-1 * (v_rel / speed_buff))
+ fill_alpha = min(fill_alpha, 255)
+
+ # Calculate size and position
+ sz = np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0) * 1
+ x = np.clip(point[0], 0.0, rect.width - sz / 2)
+ y = min(point[1], rect.height - sz * 0.6)
+
+ g_xo = sz / 5
+ g_yo = sz / 10
+
+ 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))
+
+ def _get_ll_color(self, prob: float, adjacent: bool, left: bool):
+ alpha = np.clip(prob, 0.0, 0.7)
+ if adjacent:
+ _base_color = LANE_LINE_COLORS.get(ui_state.status, LANE_LINE_COLORS[UIStatus.DISENGAGED])
+ color = rl.Color(_base_color.r, _base_color.g, _base_color.b, int(alpha * 255))
+
+ # turn adjacent lls orange if torque is high
+ torque = self._torque_filter.x
+ high_torque = abs(torque) > 0.6
+ if high_torque and (left == (torque > 0)):
+ color = blend_colors(
+ color,
+ rl.Color(255, 115, 0, int(alpha * 255)), # orange
+ np.interp(abs(torque), [0.6, 0.8], [0.0, 1.0])
+ )
+ else:
+ color = rl.Color(255, 255, 255, int(alpha * 255))
+
+ if ui_state.status == UIStatus.DISENGAGED:
+ color = rl.Color(0, 0, 0, int(alpha * 255))
+
+ return color
+
+ def _draw_lane_lines(self):
+ """Draw lane lines and road edges"""
+ """Two closest lines should be green (lane line or road edges)"""
+ for i, lane_line in enumerate(self._lane_lines):
+ if lane_line.projected_points.size == 0:
+ continue
+
+ color = self._get_ll_color(float(self._lane_line_probs[i]), i in (1, 2), i in (0, 1))
+ draw_polygon(self._rect, lane_line.projected_points, color)
+
+ for i, road_edge in enumerate(self._road_edges):
+ if road_edge.projected_points.size == 0:
+ continue
+
+ # if closest lane lines are not confident, make road edges green
+ color = self._get_ll_color(float(1.0 - self._road_edge_stds[i]), float(self._lane_line_probs[i + 1]) < 0.25, i == 0)
+ draw_polygon(self._rect, road_edge.projected_points, color)
+
+ def _draw_path(self, sm):
+ """Draw path with dynamic coloring based on mode and throttle state."""
+ if not self._path.projected_points.size:
+ return
+
+ allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control
+ self._blend_filter.update(int(allow_throttle))
+
+ if self._experimental_mode:
+ # Draw with acceleration coloring
+ if ui_state.status == UIStatus.DISENGAGED:
+ draw_polygon(self._rect, self._path.projected_points, rl.Color(0, 0, 0, 90))
+ elif len(self._exp_gradient.colors) > 1:
+ draw_polygon(self._rect, self._path.projected_points, gradient=self._exp_gradient)
+ else:
+ draw_polygon(self._rect, self._path.projected_points, rl.Color(255, 255, 255, 30))
+ else:
+ # Blend throttle/no throttle colors based on transition
+ blend_factor = round(self._blend_filter.x * 100) / 100
+ blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor)
+ gradient = Gradient(
+ start=(0.0, 1.0), # Bottom of path
+ end=(0.0, 0.0), # Top of path
+ colors=blended_colors,
+ stops=[0.0, 0.5, 1.0],
+ )
+
+ if ui_state.status == UIStatus.DISENGAGED:
+ draw_polygon(self._rect, self._path.projected_points, rl.Color(0, 0, 0, 90))
+ else:
+ draw_polygon(self._rect, self._path.projected_points, gradient=gradient)
+
+ def _draw_lead_indicator(self):
+ # Draw lead vehicles if available
+ for lead in self._lead_vehicles:
+ if not lead.glow or not lead.chevron:
+ continue
+
+ rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255))
+ rl.draw_triangle_fan(lead.chevron, len(lead.chevron), rl.Color(201, 34, 49, lead.fill_alpha))
+
+ @staticmethod
+ def _get_path_length_idx(pos_x_array: np.ndarray, path_height: float) -> int:
+ """Get the index corresponding to the given path height"""
+ if len(pos_x_array) == 0:
+ return 0
+ indices = np.where(pos_x_array <= path_height)[0]
+ return indices[-1] if indices.size > 0 else 0
+
+ def _map_to_screen(self, in_x, in_y, in_z):
+ """Project a point in car space to screen space"""
+ input_pt = np.array([in_x, in_y, in_z])
+ pt = self._car_space_transform @ input_pt
+
+ if abs(pt[2]) < 1e-6:
+ return None
+
+ x, y = pt[0] / pt[2], pt[1] / pt[2]
+
+ clip = self._clip_region
+ if not (clip.x <= x <= clip.x + clip.width and clip.y <= y <= clip.y + clip.height):
+ return None
+
+ return (x, y)
+
+ def _map_line_to_polygon(self, line: np.ndarray, y_off: float, z_off: float, max_idx: int, allow_invert: bool = True) -> np.ndarray:
+ """Convert 3D line to 2D polygon for rendering."""
+ if line.shape[0] == 0:
+ return np.empty((0, 2), dtype=np.float32)
+
+ # Slice points and filter non-negative x-coordinates
+ points = line[:max_idx + 1]
+ points = points[points[:, 0] >= 0]
+ if points.shape[0] == 0:
+ return np.empty((0, 2), dtype=np.float32)
+
+ 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
+
+ # 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
+ 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, :]
+
+ # 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)
+
+ # 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[:, keep]
+ right_screen = right_screen[:, keep]
+
+ return np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32)
+
+ @staticmethod
+ 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)
+ )
+
+ @staticmethod
+ def _blend_colors(begin_colors, end_colors, t):
+ if t >= 1.0:
+ return end_colors
+ if t <= 0.0:
+ return begin_colors
+
+ inv_t = 1.0 - t
+ return [rl.Color(
+ int(inv_t * start.r + t * end.r),
+ int(inv_t * start.g + t * end.g),
+ int(inv_t * start.b + t * end.b),
+ int(inv_t * start.a + t * end.a)
+ ) for start, end in zip(begin_colors, end_colors, strict=True)]
diff --git a/selfdrive/ui/mici/onroad/torque_bar.py b/selfdrive/ui/mici/onroad/torque_bar.py
new file mode 100644
index 000000000..1f6dffe87
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/torque_bar.py
@@ -0,0 +1,253 @@
+import math
+import time
+from functools import wraps
+from collections import OrderedDict
+
+import numpy as np
+import pyray as rl
+from opendbc.car import ACCELERATION_DUE_TO_GRAVITY
+from openpilot.selfdrive.ui.mici.onroad import blend_colors
+from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
+from openpilot.system.ui.widgets import Widget
+from openpilot.common.filter_simple import FirstOrderFilter
+
+# TODO: arc_bar_pts doesn't consider rounded end caps part of the angle span
+TORQUE_ANGLE_SPAN = 12.7
+
+DEBUG = False
+
+
+def quantized_lru_cache(maxsize=128):
+ def decorator(func):
+ cache = OrderedDict()
+ @wraps(func)
+ def wrapper(cx, cy, r_mid, thickness, a0_deg, a1_deg, **kwargs):
+ # Quantize inputs: balanced for smoothness vs cache effectiveness
+ key = (round(cx), round(cy), round(r_mid),
+ round(thickness), # 1px precision for smoother height transitions
+ round(a0_deg * 10) / 10, # 0.1° precision for smoother angle transitions
+ round(a1_deg * 10) / 10,
+ tuple(sorted(kwargs.items())))
+
+ if key in cache:
+ cache.move_to_end(key)
+ else:
+ if len(cache) >= maxsize:
+ cache.popitem(last=False)
+
+ result = func(cx, cy, r_mid, thickness, a0_deg, a1_deg, **kwargs)
+ cache[key] = result
+ return cache[key]
+ return wrapper
+ return decorator
+
+
+@quantized_lru_cache(maxsize=256)
+def arc_bar_pts(cx: float, cy: float,
+ r_mid: float, thickness: float,
+ a0_deg: float, a1_deg: float,
+ *, max_points: int = 100, cap_segs: int = 10,
+ cap_radius: float = 7, px_per_seg: float = 2.0) -> np.ndarray:
+ """Return Nx2 np.float32 points for a single closed polygon (rounded thick arc)."""
+
+ def get_cap(left: bool, a_deg: float):
+ # end cap at a1: center (a1), sweep a1→a1+180 (skip endpoints to avoid dupes)
+ # quarter arc (outer corner) at a1 with fixed pixel radius cap_radius
+
+ nx, ny = math.cos(math.radians(a_deg)), math.sin(math.radians(a_deg)) # outward normal
+ tx, ty = -ny, nx # tangent (CCW)
+
+ mx, my = cx + nx * r_mid, cy + ny * r_mid # mid-point at a1
+ if DEBUG:
+ rl.draw_circle(int(mx), int(my), 4, rl.PURPLE)
+
+ ex = mx + nx * (half - cap_radius)
+ ey = my + ny * (half - cap_radius)
+
+ if DEBUG:
+ rl.draw_circle(int(ex), int(ey), 2, rl.WHITE)
+
+ # sweep 90° in the local (t,n) frame: from outer edge toward inside
+ if not left:
+ alpha = np.deg2rad(np.linspace(90, 0, cap_segs + 2))[1:-1]
+ else:
+ alpha = np.deg2rad(np.linspace(180, 90, cap_segs + 2))[1:-1]
+ cap_end = np.c_[ex + np.cos(alpha) * cap_radius * tx + np.sin(alpha) * cap_radius * nx,
+ ey + np.cos(alpha) * cap_radius * ty + np.sin(alpha) * cap_radius * ny]
+
+ # bottom quarter (inner corner) at a1
+ ex2 = mx + nx * (-half + cap_radius)
+ ey2 = my + ny * (-half + cap_radius)
+ if DEBUG:
+ rl.draw_circle(int(ex2), int(ey2), 2, rl.WHITE)
+
+ if not left:
+ alpha2 = np.deg2rad(np.linspace(0, -90, cap_segs + 1))[:-1] # include 0 once, exclude -90
+ else:
+ alpha2 = np.deg2rad(np.linspace(90 - 90 - 90, 0 - 90 - 90, cap_segs + 1))[:-1]
+ cap_end_bot = np.c_[ex2 + np.cos(alpha2) * cap_radius * tx + np.sin(alpha2) * cap_radius * nx,
+ ey2 + np.cos(alpha2) * cap_radius * ty + np.sin(alpha2) * cap_radius * ny]
+
+ # append to the top quarter
+ if not left:
+ cap_end = np.vstack((cap_end, cap_end_bot))
+ else:
+ cap_end = np.vstack((cap_end_bot, cap_end))
+
+ return cap_end
+
+ if a1_deg < a0_deg:
+ a0_deg, a1_deg = a1_deg, a0_deg
+ half = thickness * 0.5
+
+ cap_radius = min(cap_radius, half)
+
+ span = max(1e-3, a1_deg - a0_deg)
+
+ # pick arc segment count from arc length, clamp to shader points[] budget
+ arc_len = r_mid * math.radians(span)
+ arc_segs = max(6, int(arc_len / px_per_seg))
+ max_arc = (max_points - (4 * cap_segs + 3)) // 2
+ arc_segs = max(6, min(arc_segs, max_arc))
+
+ # outer arc a0→a1
+ ang_o = np.deg2rad(np.linspace(a0_deg, a1_deg, arc_segs + 1))
+ outer = np.c_[cx + np.cos(ang_o) * (r_mid + half),
+ cy + np.sin(ang_o) * (r_mid + half)]
+
+ # end cap at a1
+ cap_end = get_cap(False, a1_deg)
+
+ # inner arc a1→a0
+ ang_i = np.deg2rad(np.linspace(a1_deg, a0_deg, arc_segs + 1))
+ inner = np.c_[cx + np.cos(ang_i) * (r_mid - half),
+ cy + np.sin(ang_i) * (r_mid - half)]
+
+ # start cap at a0
+ cap_start = get_cap(True, a0_deg)
+
+ pts = np.vstack((outer, cap_end, inner, cap_start, outer[:1])).astype(np.float32)
+
+ if DEBUG:
+ n = len(pts)
+ idx = int(time.monotonic() * 12) % max(1, n) # speed: 12 pts/sec
+ for i, (x, y) in enumerate(pts):
+ j = (i - idx) % n # rotate the gradient
+ t = j / n
+ color = rl.Color(255, int(255 * (1 - t)), int(255 * t), 255)
+ rl.draw_circle(int(x), int(y), 2, color)
+
+ return pts
+
+
+class TorqueBar(Widget):
+ def __init__(self, demo: bool = False):
+ super().__init__()
+ self._demo = demo
+ self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
+ self._torque_line_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
+
+ def update_filter(self, value: float):
+ """Update the torque filter value (for demo mode)."""
+ self._torque_filter.update(value)
+
+ def _update_state(self):
+ if self._demo:
+ return
+
+ # torque line
+ if ui_state.sm['controlsState'].lateralControlState.which() == 'angleState':
+ controls_state = ui_state.sm['controlsState']
+ car_state = ui_state.sm['carState']
+ live_parameters = ui_state.sm['liveParameters']
+ lateral_acceleration = controls_state.curvature * car_state.vEgo ** 2 - live_parameters.roll * ACCELERATION_DUE_TO_GRAVITY
+ # TODO: pull from carparams
+ max_lateral_acceleration = 3
+
+ # from selfdrived
+ actual_lateral_accel = controls_state.curvature * car_state.vEgo ** 2
+ desired_lateral_accel = controls_state.desiredCurvature * car_state.vEgo ** 2
+ accel_diff = (desired_lateral_accel - actual_lateral_accel)
+
+ self._torque_filter.update(min(max(lateral_acceleration / max_lateral_acceleration + accel_diff, -1), 1))
+ else:
+ self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque)
+
+ def _render(self, rect: rl.Rectangle) -> None:
+ # adjust y pos with torque
+ torque_line_offset = np.interp(abs(self._torque_filter.x), [0.5, 1], [22, 26])
+ torque_line_height = np.interp(abs(self._torque_filter.x), [0.5, 1], [14, 56])
+
+ # animate alpha and angle span
+ if not self._demo:
+ self._torque_line_alpha_filter.update(ui_state.status != UIStatus.DISENGAGED)
+ else:
+ self._torque_line_alpha_filter.update(1.0)
+
+ torque_line_bg_alpha = np.interp(abs(self._torque_filter.x), [0.5, 1.0], [0.25, 0.5])
+ torque_line_bg_color = rl.Color(255, 255, 255, int(255 * torque_line_bg_alpha * self._torque_line_alpha_filter.x))
+ if ui_state.status != UIStatus.ENGAGED and not self._demo:
+ torque_line_bg_color = rl.Color(255, 255, 255, int(255 * 0.15 * self._torque_line_alpha_filter.x))
+
+ # draw curved line polygon torque bar
+ torque_line_radius = 1200
+ top_angle = -90
+ torque_bg_angle_span = self._torque_line_alpha_filter.x * TORQUE_ANGLE_SPAN
+ torque_start_angle = top_angle - torque_bg_angle_span / 2
+ torque_end_angle = top_angle + torque_bg_angle_span / 2
+ # centerline radius & center (you already have these values)
+ mid_r = torque_line_radius + torque_line_height / 2
+
+ cx = rect.x + rect.width / 2 + 8 # offset 8px to right of camera feed
+ cy = rect.y + rect.height + torque_line_radius - torque_line_offset
+
+ # draw bg torque indicator line
+ bg_pts = arc_bar_pts(cx, cy, mid_r, torque_line_height, torque_start_angle, torque_end_angle)
+ draw_polygon(rect, bg_pts, color=torque_line_bg_color)
+
+ # draw torque indicator line
+ a0s = top_angle
+ a1s = a0s + torque_bg_angle_span / 2 * self._torque_filter.x
+ sl_pts = arc_bar_pts(cx, cy, mid_r, torque_line_height, a0s, a1s)
+
+ # draw beautiful gradient from center to 65% of the bg torque bar width
+ start_grad_pt = cx / rect.width
+ if self._torque_filter.x < 0:
+ end_grad_pt = (cx * (1 - 0.65) + (min(bg_pts[:, 0]) * 0.65)) / rect.width
+ else:
+ end_grad_pt = (cx * (1 - 0.65) + (max(bg_pts[:, 0]) * 0.65)) / rect.width
+
+ # fade to orange as we approach max torque
+ start_color = blend_colors(
+ rl.Color(255, 255, 255, int(255 * 0.9 * self._torque_line_alpha_filter.x)),
+ rl.Color(255, 200, 0, int(255 * self._torque_line_alpha_filter.x)), # yellow
+ max(0, abs(self._torque_filter.x) - 0.75) * 4,
+ )
+ end_color = blend_colors(
+ rl.Color(255, 255, 255, int(255 * 0.9 * self._torque_line_alpha_filter.x)),
+ rl.Color(255, 115, 0, int(255 * self._torque_line_alpha_filter.x)), # orange
+ max(0, abs(self._torque_filter.x) - 0.75) * 4,
+ )
+
+ if ui_state.status != UIStatus.ENGAGED and not self._demo:
+ start_color = end_color = rl.Color(255, 255, 255, int(255 * 0.35 * self._torque_line_alpha_filter.x))
+
+ gradient = Gradient(
+ start=(start_grad_pt, 0),
+ end=(end_grad_pt, 0),
+ colors=[
+ start_color,
+ end_color,
+ ],
+ stops=[0.0, 1.0],
+ )
+
+ draw_polygon(rect, sl_pts, gradient=gradient)
+
+ # draw center torque bar dot
+ if abs(self._torque_filter.x) < 0.5:
+ dot_y = self._rect.y + self._rect.height - torque_line_offset - torque_line_height / 2
+ rl.draw_circle(int(cx), int(dot_y), 10 // 2,
+ rl.Color(182, 182, 182, int(255 * 0.9 * self._torque_line_alpha_filter.x)))
diff --git a/selfdrive/ui/mici/widgets/button.py b/selfdrive/ui/mici/widgets/button.py
new file mode 100644
index 000000000..be08e0fee
--- /dev/null
+++ b/selfdrive/ui/mici/widgets/button.py
@@ -0,0 +1,375 @@
+import pyray as rl
+from typing import Union
+from enum import Enum
+from collections.abc import Callable
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.label import MiciLabel
+from openpilot.system.ui.widgets.scroller import DO_ZOOM
+from openpilot.system.ui.lib.text_measure import measure_text_cached
+from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
+from openpilot.common.filter_simple import BounceFilter
+
+try:
+ from openpilot.common.params import Params
+except ImportError:
+ Params = None
+
+SCROLLING_SPEED_PX_S = 50
+COMPLICATION_SIZE = 36
+LABEL_COLOR = rl.WHITE
+LABEL_HORIZONTAL_PADDING = 40
+COMPLICATION_GREY = rl.Color(0xAA, 0xAA, 0xAA, 255)
+PRESSED_SCALE = 1.15 if DO_ZOOM else 1.07
+
+
+class ScrollState(Enum):
+ PRE_SCROLL = 0
+ SCROLLING = 1
+ POST_SCROLL = 2
+
+
+class BigCircleButton(Widget):
+ def __init__(self, icon: str, red: bool = False):
+ super().__init__()
+ self._red = red
+
+ # State
+ self.set_rect(rl.Rectangle(0, 0, 180, 180))
+ self._press_state_enabled = True
+ self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
+
+ # Icons
+ self._txt_icon = gui_app.texture(icon, 64, 53)
+ self._txt_btn_disabled_bg = gui_app.texture("icons_mici/buttons/button_circle_disabled.png", 180, 180)
+
+ self._txt_btn_bg = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180)
+ self._txt_btn_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_hover.png", 180, 180)
+
+ self._txt_btn_red_bg = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180)
+ self._txt_btn_red_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_red_hover.png", 180, 180)
+
+ def set_enable_pressed_state(self, pressed: bool):
+ self._press_state_enabled = pressed
+
+ def _render(self, _):
+ # draw background
+ txt_bg = self._txt_btn_bg if not self._red else self._txt_btn_red_bg
+ if not self.enabled:
+ txt_bg = self._txt_btn_disabled_bg
+ elif self.is_pressed and self._press_state_enabled:
+ txt_bg = self._txt_btn_pressed_bg if not self._red else self._txt_btn_red_pressed_bg
+
+ scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed and self._press_state_enabled else 1.0)
+ btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
+ btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
+ rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
+
+ # draw icon
+ icon_color = rl.WHITE if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
+ rl.draw_texture(self._txt_icon, int(self._rect.x + (self._rect.width - self._txt_icon.width) / 2),
+ int(self._rect.y + (self._rect.height - self._txt_icon.height) / 2), icon_color)
+
+
+class BigCircleToggle(BigCircleButton):
+ def __init__(self, icon: str, toggle_callback: Callable = None):
+ super().__init__(icon, False)
+ self._toggle_callback = toggle_callback
+
+ # State
+ self._checked = False
+
+ # Icons
+ self._txt_toggle_enabled = gui_app.texture("icons_mici/buttons/toggle_dot_enabled.png", 66, 66)
+ self._txt_toggle_disabled = gui_app.texture("icons_mici/buttons/toggle_dot_disabled.png", 70, 70) # TODO: why discrepancy?
+
+ def set_checked(self, checked: bool):
+ self._checked = checked
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+
+ self._checked = not self._checked
+ if self._toggle_callback:
+ self._toggle_callback(self._checked)
+
+ def _render(self, _):
+ super()._render(_)
+
+ # draw status icon
+ rl.draw_texture(self._txt_toggle_enabled if self._checked else self._txt_toggle_disabled,
+ int(self._rect.x + (self._rect.width - self._txt_toggle_enabled.width) / 2),
+ int(self._rect.y + 5), rl.WHITE)
+
+
+class BigButton(Widget):
+ """A lightweight stand-in for the Qt BigButton, drawn & updated each frame."""
+
+ def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = ""):
+ super().__init__()
+ self.set_rect(rl.Rectangle(0, 0, 402, 180))
+ self.text = text
+ self.value = value
+ self.set_icon(icon)
+
+ self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
+
+ self._rotate_icon_t: float | None = None
+
+ self._label_font = gui_app.font(FontWeight.DISPLAY)
+ self._value_font = gui_app.font(FontWeight.ROMAN)
+
+ self._label = MiciLabel(text, font_size=self._get_label_font_size(), width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2),
+ font_weight=FontWeight.DISPLAY, color=LABEL_COLOR,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True)
+ self._sub_label = MiciLabel(value, font_size=COMPLICATION_SIZE, width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2),
+ font_weight=FontWeight.ROMAN, color=COMPLICATION_GREY,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True)
+
+ self._load_images()
+
+ # internal state
+ self._scroll_offset = 0 # in pixels
+ self._needs_scroll = measure_text_cached(self._label_font, text, self._get_label_font_size()).x + 25 > self._rect.width
+ self._scroll_timer = 0
+ self._scroll_state = ScrollState.PRE_SCROLL
+
+ def set_icon(self, icon: Union[str, rl.Texture]):
+ self._txt_icon = gui_app.texture(icon, 64, 64) if isinstance(icon, str) and len(icon) else icon
+
+ def set_rotate_icon(self, rotate: bool):
+ if rotate and self._rotate_icon_t is not None:
+ return
+ self._rotate_icon_t = rl.get_time() if rotate else None
+
+ def _load_images(self):
+ self._txt_default_bg = gui_app.texture("icons_mici/buttons/button_rectangle.png", 402, 180)
+ self._txt_pressed_bg = gui_app.texture("icons_mici/buttons/button_rectangle_pressed.png", 402, 180)
+ self._txt_disabled_bg = gui_app.texture("icons_mici/buttons/button_rectangle_disabled.png", 402, 180)
+ self._txt_hover_bg = gui_app.texture("icons_mici/buttons/button_rectangle_hover.png", 402, 180)
+
+ def _get_label_font_size(self):
+ if len(self.text) < 12:
+ font_size = 64
+ elif len(self.text) < 17:
+ font_size = 48
+ elif len(self.text) < 20:
+ font_size = 42
+ else:
+ font_size = 36
+
+ if self.value:
+ font_size -= 20
+
+ return font_size
+
+ def set_text(self, text: str):
+ self.text = text
+ self._label.set_text(text)
+
+ def set_value(self, value: str):
+ self.value = value
+ self._sub_label.set_text(value)
+
+ def get_value(self) -> str:
+ return self.value
+
+ def get_text(self):
+ return self.text
+
+ def _update_state(self):
+ # hold on text for a bit, scroll, hold again, reset
+ if self._needs_scroll:
+ """`dt` should be seconds since last frame (rl.get_frame_time())."""
+ # TODO: this comment is generated by GPT, prob wrong and misused
+ dt = rl.get_frame_time()
+
+ self._scroll_timer += dt
+ if self._scroll_state == ScrollState.PRE_SCROLL:
+ if self._scroll_timer < 0.5:
+ return
+ self._scroll_state = ScrollState.SCROLLING
+ self._scroll_timer = 0
+
+ elif self._scroll_state == ScrollState.SCROLLING:
+ self._scroll_offset -= SCROLLING_SPEED_PX_S * dt
+ # reset when text has completely left the button + 50 px gap
+ # TODO: use global constant for 30+30 px gap
+ # TODO: add std Widget padding option integrated into the self._rect
+ full_len = measure_text_cached(self._label_font, self.text, self._get_label_font_size()).x + 30 + 30
+ if self._scroll_offset < (self._rect.width - full_len):
+ self._scroll_state = ScrollState.POST_SCROLL
+ self._scroll_timer = 0
+
+ elif self._scroll_state == ScrollState.POST_SCROLL:
+ # wait for a bit before starting to scroll again
+ if self._scroll_timer < 0.75:
+ return
+ self._scroll_state = ScrollState.PRE_SCROLL
+ self._scroll_timer = 0
+ self._scroll_offset = 0
+
+ def _render(self, _):
+ # draw _txt_default_bg
+ txt_bg = self._txt_default_bg
+ if not self.enabled:
+ txt_bg = self._txt_disabled_bg
+ elif self.is_pressed:
+ txt_bg = self._txt_hover_bg
+
+ scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0)
+ btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
+ btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
+ rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
+
+ # LABEL ------------------------------------------------------------------
+ lx = self._rect.x + LABEL_HORIZONTAL_PADDING
+ ly = btn_y + self._rect.height - 33 # - 40# - self._get_label_font_size() / 2
+
+ if self.value:
+ self._sub_label.set_position(lx, ly)
+ ly -= self._sub_label.font_size + 9
+ self._sub_label.render()
+
+ label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
+ self._label.set_color(label_color)
+ self._label.set_position(lx, ly)
+ self._label.render()
+
+ # ICON -------------------------------------------------------------------
+ if self._txt_icon:
+ rotation = 0
+ if self._rotate_icon_t is not None:
+ rotation = (rl.get_time() - self._rotate_icon_t) * 180
+
+ # drop top right with 30px padding
+ x = self._rect.x + self._rect.width - 30 - self._txt_icon.width / 2
+ y = self._rect.y + 30 + self._txt_icon.height / 2
+ source_rec = rl.Rectangle(0, 0, self._txt_icon.width, self._txt_icon.height)
+ dest_rec = rl.Rectangle(int(x), int(y), self._txt_icon.width, self._txt_icon.height)
+ origin = rl.Vector2(self._txt_icon.width / 2, self._txt_icon.height / 2)
+ rl.draw_texture_pro(self._txt_icon, source_rec, dest_rec, origin, rotation, rl.WHITE)
+
+
+class BigToggle(BigButton):
+ def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable = None):
+ super().__init__(text, value, "")
+ self._checked = initial_state
+ self._toggle_callback = toggle_callback
+
+ self._label.set_font_size(48)
+
+ def _load_images(self):
+ super()._load_images()
+ self._txt_enabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_enabled.png", 84, 66)
+ self._txt_disabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_disabled.png", 84, 66)
+
+ def set_checked(self, checked: bool):
+ self._checked = checked
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+ self._checked = not self._checked
+ if self._toggle_callback:
+ self._toggle_callback(self._checked)
+
+ def _draw_pill(self, x: float, y: float, checked: bool):
+ # draw toggle icon top right
+ if checked:
+ rl.draw_texture(self._txt_enabled_toggle, int(x), int(y), rl.WHITE)
+ else:
+ rl.draw_texture(self._txt_disabled_toggle, int(x), int(y), rl.WHITE)
+
+ def _render(self, _):
+ super()._render(_)
+
+ x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width
+ y = self._rect.y
+ self._draw_pill(x, y, self._checked)
+
+
+class BigMultiToggle(BigToggle):
+ def __init__(self, text: str, options: list[str], toggle_callback: Callable = None,
+ select_callback: Callable = None):
+ super().__init__(text, "", toggle_callback=toggle_callback)
+ assert len(options) > 0
+ self._options = options
+ self._select_callback = select_callback
+
+ self._label.set_width(int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width))
+ # TODO: why isn't this automatic?
+ self._label.set_font_size(self._get_label_font_size())
+
+ self.set_value(self._options[0])
+
+ def _get_label_font_size(self):
+ font_size = super()._get_label_font_size()
+ return font_size - 6
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+ cur_idx = self._options.index(self.value)
+ new_idx = (cur_idx + 1) % len(self._options)
+ self.set_value(self._options[new_idx])
+ if self._select_callback:
+ self._select_callback(self.value)
+
+ def _render(self, _):
+ BigButton._render(self, _)
+
+ checked_idx = self._options.index(self.value)
+
+ x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width
+ y = self._rect.y
+
+ for i in range(len(self._options)):
+ self._draw_pill(x, y, checked_idx == i)
+ y += 35
+
+
+class BigMultiParamToggle(BigMultiToggle):
+ def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable = None,
+ select_callback: Callable = None):
+ super().__init__(text, options, toggle_callback, select_callback)
+ self._param = param
+
+ self._params = Params()
+ self._load_value()
+
+ def _load_value(self):
+ self.set_value(self._options[self._params.get(self._param) or 0])
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+ new_idx = self._options.index(self.value)
+ self._params.put_nonblocking(self._param, new_idx)
+
+
+class BigParamControl(BigToggle):
+ def __init__(self, text: str, param: str, toggle_callback: Callable = None):
+ super().__init__(text, "", toggle_callback=toggle_callback)
+ self.param = param
+ self.params = Params()
+ self.set_checked(self.params.get_bool(self.param, False))
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+ self.params.put_bool(self.param, self._checked)
+
+ def refresh(self):
+ self.set_checked(self.params.get_bool(self.param, False))
+
+
+# TODO: param control base class
+class BigCircleParamControl(BigCircleToggle):
+ def __init__(self, icon: str, param: str, toggle_callback: Callable = None):
+ super().__init__(icon, toggle_callback)
+ self._param = param
+ self.params = Params()
+ self.set_checked(self.params.get_bool(self._param, False))
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+ self.params.put_bool(self._param, self._checked)
+
+ def refresh(self):
+ self.set_checked(self.params.get_bool(self._param, False))
diff --git a/selfdrive/ui/mici/widgets/dialog.py b/selfdrive/ui/mici/widgets/dialog.py
new file mode 100644
index 000000000..d64ab65ef
--- /dev/null
+++ b/selfdrive/ui/mici/widgets/dialog.py
@@ -0,0 +1,395 @@
+import abc
+import math
+import pyray as rl
+from typing import Union
+from collections.abc import Callable
+from typing import cast
+from openpilot.selfdrive.ui.mici.widgets.side_button import SideButton
+from openpilot.system.ui.widgets import Widget, NavWidget, DialogResult
+from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label
+from openpilot.system.ui.widgets.mici_keyboard import MiciKeyboard
+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.application import gui_app, FontWeight, MousePos
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.slider import RedBigSlider, BigSlider
+from openpilot.common.filter_simple import FirstOrderFilter
+from openpilot.selfdrive.ui.mici.widgets.button import BigButton
+
+DEBUG = False
+
+PADDING = 20
+
+
+class BigDialogBase(NavWidget, abc.ABC):
+ def __init__(self, right_btn: str | None = None, right_btn_callback: Callable | None = None):
+ super().__init__()
+ self._ret = DialogResult.NO_ACTION
+ self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ self.set_back_callback(lambda: setattr(self, '_ret', DialogResult.CANCEL))
+
+ self._right_btn = None
+ if right_btn:
+ def right_btn_callback_wrapper():
+ gui_app.set_modal_overlay(None)
+ if right_btn_callback:
+ right_btn_callback()
+
+ self._right_btn = SideButton(right_btn)
+ self._right_btn.set_click_callback(right_btn_callback_wrapper)
+ # move to right side
+ self._right_btn._rect.x = self._rect.x + self._rect.width - self._right_btn._rect.width
+
+ def _render(self, _) -> DialogResult:
+ """
+ Allows `gui_app.set_modal_overlay(BigDialog(...))`.
+ The overlay runner keeps calling until result != NO_ACTION.
+ """
+ if self._right_btn:
+ self._right_btn.set_position(self._right_btn._rect.x, self._rect.y)
+ self._right_btn.render()
+
+ return self._ret
+
+
+class BigDialog(BigDialogBase):
+ def __init__(self,
+ title: str,
+ description: str,
+ right_btn: str | None = None,
+ right_btn_callback: Callable | None = None):
+ super().__init__(right_btn, right_btn_callback)
+ self._title = title
+ self._description = description
+
+ def _render(self, _) -> DialogResult:
+ super()._render(_)
+
+ # draw title
+ # TODO: we desperately need layouts
+ # TODO: coming up with these numbers manually is a pain and not scalable
+ # TODO: no clue what any of these numbers mean. VBox and HBox would remove all of this shite
+ max_width = self._rect.width - PADDING * 2
+ if self._right_btn:
+ max_width -= self._right_btn._rect.width
+
+ title_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.BOLD), self._title, 50, int(max_width)))
+ title_size = measure_text_cached(gui_app.font(FontWeight.BOLD), title_wrapped, 50)
+ text_x_offset = 0
+ title_rect = rl.Rectangle(int(self._rect.x + text_x_offset + PADDING),
+ int(self._rect.y + PADDING),
+ int(max_width),
+ int(title_size.y))
+ gui_label(title_rect, title_wrapped, 50, font_weight=FontWeight.BOLD,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
+
+ # draw description
+ desc_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.MEDIUM), self._description, 30, int(max_width)))
+ desc_size = measure_text_cached(gui_app.font(FontWeight.MEDIUM), desc_wrapped, 30)
+ desc_rect = rl.Rectangle(int(self._rect.x + text_x_offset + PADDING),
+ int(self._rect.y + self._rect.height / 3),
+ int(max_width),
+ int(desc_size.y))
+ # TODO: text align doesn't seem to work properly with newlines
+ gui_label(desc_rect, desc_wrapped, 30, font_weight=FontWeight.MEDIUM,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
+
+ return self._ret
+
+
+class BigConfirmationDialogV2(BigDialogBase):
+ def __init__(self, title: str, icon: str, red: bool = False,
+ exit_on_confirm: bool = True,
+ confirm_callback: Callable | None = None):
+ super().__init__()
+ self._confirm_callback = confirm_callback
+ self._exit_on_confirm = exit_on_confirm
+
+ icon_txt = gui_app.texture(icon, 64, 53)
+ self._slider: BigSlider | RedBigSlider
+ if red:
+ self._slider = RedBigSlider(title, icon_txt, confirm_callback=self._on_confirm)
+ else:
+ self._slider = BigSlider(title, icon_txt, confirm_callback=self._on_confirm)
+ self._slider.set_enabled(lambda: not self._swiping_away)
+
+ def _on_confirm(self):
+ if self._confirm_callback:
+ self._confirm_callback()
+ if self._exit_on_confirm:
+ self._ret = DialogResult.CONFIRM
+
+ def _update_state(self):
+ super()._update_state()
+ if self._swiping_away and not self._slider.confirmed:
+ self._slider.reset()
+
+ def _render(self, _) -> DialogResult:
+ self._slider.render(self._rect)
+ return self._ret
+
+
+class BigInputDialog(BigDialogBase):
+ BACK_TOUCH_AREA_PERCENTAGE = 0.2
+ BACKSPACE_RATE = 25 # hz
+
+ def __init__(self,
+ hint: str,
+ default_text: str = "",
+ minimum_length: int = 1,
+ confirm_callback: Callable[[str], None] = None):
+ super().__init__(None, None)
+ self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)),
+ font_weight=FontWeight.MEDIUM)
+ self._keyboard = MiciKeyboard()
+ self._keyboard.set_text(default_text)
+ self._minimum_length = minimum_length
+
+ self._backspace_held_time: float | None = None
+
+ self._backspace_img = gui_app.texture("icons_mici/settings/keyboard/backspace.png", 44, 44)
+ self._backspace_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
+
+ self._enter_img = gui_app.texture("icons_mici/settings/keyboard/confirm.png", 44, 44)
+ self._enter_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
+
+ # rects for top buttons
+ self._top_left_button_rect = rl.Rectangle(0, 0, 0, 0)
+ self._top_right_button_rect = rl.Rectangle(0, 0, 0, 0)
+
+ def confirm_callback_wrapper():
+ self._ret = DialogResult.CONFIRM
+ if confirm_callback:
+ confirm_callback(self._keyboard.text())
+ self._confirm_callback = confirm_callback_wrapper
+
+ def _update_state(self):
+ super()._update_state()
+
+ last_mouse_event = gui_app.last_mouse_event
+ if last_mouse_event.left_down and rl.check_collision_point_rec(last_mouse_event.pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 1:
+ if self._backspace_held_time is None:
+ self._backspace_held_time = rl.get_time()
+
+ if rl.get_time() - self._backspace_held_time > 0.5:
+ if gui_app.frame % round(gui_app.target_fps / self.BACKSPACE_RATE) == 0:
+ self._keyboard.backspace()
+
+ else:
+ self._backspace_held_time = None
+
+ def _render(self, _):
+ text_input_size = 35
+
+ # draw current text so far below everything. text floats left but always stays in view
+ text = self._keyboard.text()
+ candidate_char = self._keyboard.get_candidate_character()
+ text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), text + candidate_char or self._hint_label.text, text_input_size)
+ text_x = PADDING * 2 + self._enter_img.width
+
+ # text needs to move left if we're at the end where right button is
+ text_rect = rl.Rectangle(text_x,
+ int(self._rect.y + PADDING),
+ # clip width to right button when in view
+ int(self._rect.width - text_x - PADDING * 2 - self._enter_img.width + 5), # TODO: why 5?
+ int(text_size.y))
+
+ # draw rounded background for text input
+ bg_block_margin = 5
+ text_field_rect = rl.Rectangle(text_rect.x - bg_block_margin, text_rect.y - bg_block_margin,
+ text_rect.width + bg_block_margin * 2, text_input_size + bg_block_margin * 2)
+
+ # draw text input
+ # push text left with a gradient on left side if too long
+ if text_size.x > text_rect.width:
+ text_x -= text_size.x - text_rect.width
+
+ rl.begin_scissor_mode(int(text_rect.x), int(text_rect.y), int(text_rect.width), int(text_rect.height))
+ rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), text, rl.Vector2(text_x, text_rect.y), text_input_size, 0, rl.WHITE)
+
+ # draw grayed out character user is hovering over
+ if candidate_char:
+ candidate_char_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), candidate_char, text_input_size)
+ rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), candidate_char,
+ rl.Vector2(min(text_x + text_size.x, text_rect.x + text_rect.width) - candidate_char_size.x, text_rect.y),
+ text_input_size, 0, rl.Color(255, 255, 255, 128))
+
+ rl.end_scissor_mode()
+
+ # draw gradient on left side to indicate more text
+ if text_size.x > text_rect.width:
+ rl.draw_rectangle_gradient_h(int(text_rect.x), int(text_rect.y), 80, int(text_rect.height),
+ rl.BLACK, rl.BLANK)
+
+ # draw cursor
+ if text:
+ blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2
+ cursor_x = min(text_x + text_size.x + 3, text_rect.x + text_rect.width)
+ rl.draw_rectangle_rounded(rl.Rectangle(int(cursor_x), int(text_rect.y), 4, int(text_size.y)),
+ 1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha)))
+
+ # draw backspace icon with nice fade
+ self._backspace_img_alpha.update(255 * bool(text))
+ if self._backspace_img_alpha.x > 1:
+ color = rl.Color(255, 255, 255, int(self._backspace_img_alpha.x))
+ rl.draw_texture(self._backspace_img, int(self._rect.width - self._enter_img.width - 15), int(text_field_rect.y), color)
+
+ if not text and self._hint_label.text and not candidate_char:
+ # draw description if no text entered yet and not drawing candidate char
+ self._hint_label.render(text_field_rect)
+
+ # TODO: move to update state
+ # make rect take up entire area so it's easier to click
+ self._top_left_button_rect = rl.Rectangle(self._rect.x, self._rect.y, text_field_rect.x, self._rect.height - self._keyboard.get_keyboard_height())
+ self._top_right_button_rect = rl.Rectangle(text_field_rect.x + text_field_rect.width, self._rect.y,
+ self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height)
+
+ self._enter_img_alpha.update(255 if (len(text) >= self._minimum_length) else 255 * 0.35)
+ if self._enter_img_alpha.x > 1:
+ color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x))
+ rl.draw_texture(self._enter_img, int(self._rect.x + 15), int(text_field_rect.y), color)
+
+ # keyboard goes over everything
+ self._keyboard.render(self._rect)
+
+ # draw debugging rect bounds
+ if DEBUG:
+ rl.draw_rectangle_lines_ex(text_field_rect, 1, rl.Color(100, 100, 100, 255))
+ rl.draw_rectangle_lines_ex(text_rect, 1, rl.Color(0, 255, 0, 255))
+ rl.draw_rectangle_lines_ex(self._top_right_button_rect, 1, rl.Color(0, 255, 0, 255))
+ rl.draw_rectangle_lines_ex(self._top_left_button_rect, 1, rl.Color(0, 255, 0, 255))
+
+ return self._ret
+
+ def _handle_mouse_press(self, mouse_pos: MousePos):
+ super()._handle_mouse_press(mouse_pos)
+ # TODO: need to track where press was so enter and back can activate on release rather than press
+ # or turn into icon widgets :eyes_open:
+ # handle backspace icon click
+ if rl.check_collision_point_rec(mouse_pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 254:
+ self._keyboard.backspace()
+ elif rl.check_collision_point_rec(mouse_pos, self._top_left_button_rect) and self._enter_img_alpha.x > 254:
+ # handle enter icon click
+ self._confirm_callback()
+
+
+class BigDialogOptionButton(Widget):
+ def __init__(self, option: str):
+ super().__init__()
+ self.option = option
+ self.set_rect(rl.Rectangle(0, 0, int(gui_app.width / 2 + 220), 64))
+
+ self._selected = False
+
+ self._label = UnifiedLabel(option, font_size=70, text_color=rl.Color(255, 255, 255, int(255 * 0.58)),
+ font_weight=FontWeight.DISPLAY_REGULAR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
+
+ def set_selected(self, selected: bool):
+ self._selected = selected
+
+ def _render(self, _):
+ if DEBUG:
+ rl.draw_rectangle_lines_ex(self._rect, 1, rl.Color(0, 255, 0, 255))
+
+ # FIXME: offset x by -45 because scroller centers horizontally
+ if self._selected:
+ self._label.set_font_size(74)
+ self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.9)))
+ self._label.set_font_weight(FontWeight.DISPLAY)
+ else:
+ self._label.set_font_size(70)
+ self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.58)))
+ self._label.set_font_weight(FontWeight.DISPLAY_REGULAR)
+
+ self._label.render(self._rect)
+
+
+class BigMultiOptionDialog(BigDialogBase):
+ BACK_TOUCH_AREA_PERCENTAGE = 0.1
+
+ def __init__(self, options: list[str], default: str | None,
+ right_btn: str | None = 'check', right_btn_callback: Callable[[], None] = None):
+ super().__init__(right_btn, right_btn_callback=right_btn_callback)
+ self._options = options
+ if default is not None:
+ assert default in options
+
+ self._default_option: str = default or (options[0] if len(options) > 0 else "")
+ self._selected_option: str = self._default_option
+ self._last_selected_option: str = self._selected_option
+
+ self._scroller = Scroller([], horizontal=False, pad_start=100, pad_end=100, spacing=0)
+ if self._right_btn is not None:
+ self._scroller.set_enabled(lambda: not cast(Widget, self._right_btn).is_pressed)
+
+ for option in options:
+ self.add_button(BigDialogOptionButton(option))
+
+ def add_button(self, button: BigDialogOptionButton):
+ og_callback = button._click_callback
+
+ def wrapped_callback(btn=button):
+ self._on_option_selected(btn.option)
+ if og_callback:
+ og_callback()
+
+ button.set_click_callback(wrapped_callback)
+ self._scroller.add_widget(button)
+
+ def show_event(self):
+ super().show_event()
+ self._scroller.show_event()
+ self._on_option_selected(self._default_option)
+
+ def get_selected_option(self) -> str:
+ return self._selected_option
+
+ def _on_option_selected(self, option: str):
+ y_pos = 0.0
+ for btn in self._scroller._items:
+ if cast(BigDialogOptionButton, btn).option == option:
+ y_pos = btn.rect.y
+
+ self._scroller.scroll_to(y_pos, smooth=True)
+
+ def _selected_option_changed(self):
+ pass
+
+ def _update_state(self):
+ super()._update_state()
+
+ # get selection by whichever button is closest to center
+ center_y = self._rect.y + self._rect.height / 2
+ closest_btn = (None, float('inf'))
+ for btn in self._scroller._items:
+ dist_y = abs((btn.rect.y + btn.rect.height / 2) - center_y)
+ if dist_y < closest_btn[1]:
+ closest_btn = (btn, dist_y)
+
+ if closest_btn[0]:
+ for btn in self._scroller._items:
+ btn.set_selected(btn.option == closest_btn[0].option)
+ self._selected_option = closest_btn[0].option
+
+ # Signal to subclasses if selection changed
+ if self._selected_option != self._last_selected_option:
+ self._selected_option_changed()
+ self._last_selected_option = self._selected_option
+
+ def _render(self, _):
+ super()._render(_)
+ self._scroller.render(self._rect)
+
+ return self._ret
+
+
+class BigDialogButton(BigButton):
+ def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""):
+ super().__init__(text, value, icon)
+ self._description = description
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+
+ dlg = BigDialog(self.text, self._description)
+ gui_app.set_modal_overlay(dlg)
diff --git a/selfdrive/ui/mici/widgets/pairing_dialog.py b/selfdrive/ui/mici/widgets/pairing_dialog.py
new file mode 100644
index 000000000..e064205d5
--- /dev/null
+++ b/selfdrive/ui/mici/widgets/pairing_dialog.py
@@ -0,0 +1,116 @@
+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.selfdrive.ui.ui_state import ui_state
+from openpilot.system.ui.widgets import NavWidget
+from openpilot.system.ui.lib.application import FontWeight, gui_app
+from openpilot.system.ui.widgets.label import MiciLabel
+
+
+class PairingDialog(NavWidget):
+ """Dialog for device pairing with QR code."""
+
+ QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds
+
+ def __init__(self):
+ super().__init__()
+ self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
+ self._params = Params()
+ self._qr_texture: rl.Texture | None = None
+ self._last_qr_generation = float("-inf")
+
+ self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 84, 64)
+ self._pair_label = MiciLabel("pair with comma connect", 48, font_weight=FontWeight.BOLD,
+ color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=40, wrap_text=True)
+
+ def _get_pairing_url(self) -> str:
+ try:
+ dongle_id = self._params.get("DongleId") or ""
+ token = Api(dongle_id).get_token({'pair': True})
+ except Exception as e:
+ cloudlog.warning(f"Failed to get pairing token: {e}")
+ token = ""
+ return f"https://connect.comma.ai/?pair={token}"
+
+ def _generate_qr_code(self) -> None:
+ try:
+ qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0)
+ qr.add_data(self._get_pairing_url())
+ qr.make(fit=True)
+
+ pil_img = qr.make_image(fill_color="white", back_color="black").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.monotonic()
+ if current_time - self._last_qr_generation >= self.QR_REFRESH_INTERVAL:
+ self._generate_qr_code()
+ self._last_qr_generation = current_time
+
+ def _update_state(self):
+ super()._update_state()
+ if ui_state.prime_state.is_paired():
+ self._playing_dismiss_animation = True
+
+ def _render(self, rect: rl.Rectangle) -> int:
+ self._check_qr_refresh()
+
+ self._render_qr_code()
+
+ label_x = self._rect.x + 8 + self._rect.height + 24
+ self._pair_label.set_width(int(self._rect.width - label_x))
+ self._pair_label.set_position(label_x, self._rect.y + 16)
+ self._pair_label.render()
+
+ rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16),
+ 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35)))
+
+ return -1
+
+ def _render_qr_code(self) -> None:
+ if not self._qr_texture:
+ error_font = gui_app.font(FontWeight.BOLD)
+ rl.draw_text_ex(
+ error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED
+ )
+ return
+
+ scale = self._rect.height / self._qr_texture.height
+ pos = rl.Vector2(self._rect.x + 8, self._rect.y)
+ rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, 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
diff --git a/selfdrive/ui/mici/widgets/side_button.py b/selfdrive/ui/mici/widgets/side_button.py
new file mode 100644
index 000000000..4803b6d20
--- /dev/null
+++ b/selfdrive/ui/mici/widgets/side_button.py
@@ -0,0 +1,31 @@
+import pyray as rl
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.lib.application import gui_app
+
+# ---------------------------------------------------------------------------
+# Constants extracted from the original Qt style
+# ---------------------------------------------------------------------------
+# TODO: this should be corrected, but Scroller relies on this being incorrect :/
+WIDTH, HEIGHT = 112, 240
+
+
+class SideButton(Widget):
+ def __init__(self, btn_type: str):
+ super().__init__()
+ self.type = btn_type
+ self.set_rect(rl.Rectangle(0, 0, WIDTH, HEIGHT))
+
+ # load pre-rendered button images
+ if btn_type not in ("check", "back"):
+ btn_type = "back"
+ btn_img_path = f"icons_mici/buttons/button_side_{btn_type}.png"
+ btn_img_pressed_path = f"icons_mici/buttons/button_side_{btn_type}_pressed.png"
+ self._txt_btn, self._txt_btn_back = gui_app.texture(btn_img_path, 100, 224), gui_app.texture(btn_img_pressed_path, 100, 224)
+
+ def _render(self, _) -> bool:
+ x = int(self._rect.x + 12)
+ y = int(self._rect.y + (self._rect.height - self._txt_btn.height) / 2)
+ rl.draw_texture(self._txt_btn if not self.is_pressed else self._txt_btn_back,
+ x, y, rl.WHITE)
+
+ return False
diff --git a/selfdrive/ui/soundd.py b/selfdrive/ui/soundd.py
index 44116a232..d88410ada 100644
--- a/selfdrive/ui/soundd.py
+++ b/selfdrive/ui/soundd.py
@@ -12,6 +12,7 @@ from openpilot.common.utils import retry
from openpilot.common.swaglog import cloudlog
from openpilot.system import micd
+from openpilot.system.hardware import HARDWARE
SAMPLE_RATE = 48000
SAMPLE_BUFFER = 4096 # (approx 100ms)
@@ -23,6 +24,10 @@ FILTER_DT = 1. / (micd.SAMPLE_RATE / micd.FFT_SAMPLES)
AMBIENT_DB = 30 # DB where MIN_VOLUME is applied
DB_SCALE = 30 # AMBIENT_DB + DB_SCALE is where MAX_VOLUME is applied
+VOLUME_BASE = 20
+if HARDWARE.get_device_type() == "tizi":
+ VOLUME_BASE = 10
+
AudibleAlert = car.CarControl.HUDControl.AudibleAlert
@@ -39,6 +44,11 @@ sound_list: dict[int, tuple[str, int | None, float]] = {
AudibleAlert.warningSoft: ("warning_soft.wav", None, MAX_VOLUME),
AudibleAlert.warningImmediate: ("warning_immediate.wav", None, MAX_VOLUME),
}
+if HARDWARE.get_device_type() == "tizi":
+ sound_list.update({
+ AudibleAlert.engage: ("engage_tizi.wav", 1, MAX_VOLUME),
+ AudibleAlert.disengage: ("disengage_tizi.wav", 1, MAX_VOLUME),
+ })
def check_selfdrive_timeout_alert(sm):
ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
@@ -122,7 +132,7 @@ class Soundd:
def calculate_volume(self, weighted_db):
volume = ((weighted_db - AMBIENT_DB) / DB_SCALE) * (MAX_VOLUME - MIN_VOLUME) + MIN_VOLUME
- return math.pow(10, (np.clip(volume, MIN_VOLUME, MAX_VOLUME) - 1))
+ return math.pow(VOLUME_BASE, (np.clip(volume, MIN_VOLUME, MAX_VOLUME) - 1))
@retry(attempts=10, delay=3)
def get_stream(self, sd):
diff --git a/selfdrive/ui/tests/profile_onroad.py b/selfdrive/ui/tests/profile_onroad.py
index 0294125ce..b1fa4acc4 100755
--- a/selfdrive/ui/tests/profile_onroad.py
+++ b/selfdrive/ui/tests/profile_onroad.py
@@ -7,10 +7,9 @@ import numpy as np
from msgq.visionipc import VisionIpcServer, VisionStreamType
from openpilot.selfdrive.ui.ui_state import ui_state
-from openpilot.selfdrive.ui.layouts.main import MainLayout
+from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
from openpilot.system.ui.lib.application import gui_app
from openpilot.tools.lib.logreader import LogReader
-from openpilot.tools.plotjuggler.juggle import DEMO_ROUTE
FPS = 60
@@ -56,7 +55,7 @@ def patch_submaster(message_chunks):
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='Profile openpilot UI rendering and state updates')
- parser.add_argument('route', type=str, nargs='?', default=DEMO_ROUTE + "/1",
+ parser.add_argument('route', type=str, nargs='?', default="302bab07c1511180/00000006--0b9a7005f1/3",
help='Route to use for profiling')
parser.add_argument('--loop', type=int, default=1,
help='Number of times to loop the log (default: 1)')
@@ -82,8 +81,8 @@ if __name__ == "__main__":
if args.headless:
os.environ['SDL_VIDEODRIVER'] = 'dummy'
- gui_app.init_window("UI Profiling")
- main_layout = MainLayout()
+ gui_app.init_window("UI Profiling", fps=600)
+ main_layout = MiciMainLayout()
main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
print("Running...")
diff --git a/selfdrive/ui/tests/test_ui/raylib_screenshots.py b/selfdrive/ui/tests/test_ui/raylib_screenshots.py
index f36ad1bad..3d476c431 100755
--- a/selfdrive/ui/tests/test_ui/raylib_screenshots.py
+++ b/selfdrive/ui/tests/test_ui/raylib_screenshots.py
@@ -246,6 +246,7 @@ CASES = {
class TestUI:
def __init__(self):
os.environ["SCALE"] = os.getenv("SCALE", "1")
+ os.environ["BIG"] = "1"
sys.modules["mouseinfo"] = False
def setup(self):
diff --git a/selfdrive/ui/ui.py b/selfdrive/ui/ui.py
index a4b99825e..7fe0dfbbc 100755
--- a/selfdrive/ui/ui.py
+++ b/selfdrive/ui/ui.py
@@ -6,6 +6,7 @@ from openpilot.system.hardware import TICI
from openpilot.common.realtime import config_realtime_process, set_core_affinity
from openpilot.system.ui.lib.application import gui_app
from openpilot.selfdrive.ui.layouts.main import MainLayout
+from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
from openpilot.selfdrive.ui.ui_state import ui_state
@@ -14,7 +15,10 @@ def main():
config_realtime_process(0, 51)
gui_app.init_window("UI")
- main_layout = MainLayout()
+ if gui_app.big_ui():
+ main_layout = MainLayout()
+ else:
+ main_layout = MiciMainLayout()
main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
for should_render in gui_app.render():
ui_state.update()
diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py
index 8871e5de2..4a6ff9ebd 100644
--- a/selfdrive/ui/ui_state.py
+++ b/selfdrive/ui/ui_state.py
@@ -12,7 +12,7 @@ from openpilot.selfdrive.ui.lib.prime_state import PrimeState
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.hardware import HARDWARE, PC
-BACKLIGHT_OFFROAD = 50
+BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50
class UIStatus(Enum):
@@ -36,6 +36,7 @@ class UIState:
[
"modelV2",
"controlsState",
+ "onroadEvents",
"liveCalibration",
"radarState",
"deviceState",
@@ -49,6 +50,10 @@ class UIState:
"managerState",
"selfdriveState",
"longitudinalPlan",
+ "gpsLocationExternal",
+ "carOutput",
+ "carControl",
+ "liveParameters",
"rawAudioData",
]
)
@@ -64,6 +69,8 @@ class UIState:
# Core state variables
self.is_metric: bool = self.params.get_bool("IsMetric")
+ self.is_release = self.params.get_bool("IsReleaseBranch")
+ self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM")
self.started: bool = False
self.ignition: bool = False
self.recording_audio: bool = False
@@ -133,6 +140,7 @@ class UIState:
self.recording_audio = self.params.get_bool("RecordAudio") and self.started
self.is_metric = self.params.get_bool("IsMetric")
+ self.always_on_dm = self.params.get_bool("AlwaysOnDM")
def _update_status(self) -> None:
if self.started and self.sm.updated["selfdriveState"]:
@@ -181,16 +189,21 @@ class Device:
self._interaction_time: float = -1
self._interactive_timeout_callbacks: list[Callable] = []
self._prev_timed_out = False
- self._awake = False
+ self._awake: bool = True
self._offroad_brightness: int = BACKLIGHT_OFFROAD
self._last_brightness: int = 0
self._brightness_filter = FirstOrderFilter(BACKLIGHT_OFFROAD, 10.00, 1 / gui_app.target_fps)
self._brightness_thread: threading.Thread | None = None
+ @property
+ def awake(self) -> bool:
+ return self._awake
+
def reset_interactive_timeout(self, timeout: int = -1) -> None:
if timeout == -1:
- timeout = 10 if ui_state.ignition else 30
+ ignition_timeout = 10 if gui_app.big_ui() else 5
+ timeout = ignition_timeout if ui_state.ignition else 30
self._interaction_time = time.monotonic() + timeout
def add_interactive_timeout_callback(self, callback: Callable):
@@ -220,7 +233,7 @@ class Device:
else:
clipped_brightness = ((clipped_brightness + 16.0) / 116.0) ** 3.0
- clipped_brightness = float(np.clip(100 * clipped_brightness, 10, 100))
+ clipped_brightness = float(np.interp(clipped_brightness, [0, 1], [30, 100]))
brightness = round(self._brightness_filter.update(clipped_brightness))
if not self._awake:
diff --git a/system/ui/README.md b/system/ui/README.md
index 6e43c20d1..21c8ab974 100644
--- a/system/ui/README.md
+++ b/system/ui/README.md
@@ -3,6 +3,7 @@
The user interfaces here are built with [raylib](https://www.raylib.com/).
Quick start:
+* set `BIG=1` to run the comma 3X UI (comma four UI runs by default)
* set `SHOW_FPS=1` to show the FPS
* set `STRICT_MODE=1` to kill the app if it drops too much below 60fps
* set `SCALE=1.5` to scale the entire UI by 1.5x
diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py
index 16b924e44..e3370a5f7 100644
--- a/system/ui/lib/application.py
+++ b/system/ui/lib/application.py
@@ -27,6 +27,7 @@ MOUSE_THREAD_RATE = 140 # touch controller runs at 140Hz
MAX_TOUCH_SLOTS = 2
TOUCH_HISTORY_TIMEOUT = 3.0 # Seconds before touch points fade out
+BIG_UI = os.getenv("BIG", "0") == "1"
ENABLE_VSYNC = os.getenv("ENABLE_VSYNC", "0") == "1"
SHOW_FPS = os.getenv("SHOW_FPS") == "1"
SHOW_TOUCHES = os.getenv("SHOW_TOUCHES") == "1"
@@ -78,7 +79,7 @@ DEFAULT_TEXT_COLOR = rl.WHITE
# Qt draws fonts accounting for ascent/descent differently, so compensate to match old styles
# The real scales for the fonts below range from 1.212 to 1.266
-FONT_SCALE = 1.242
+FONT_SCALE = 1.242 if BIG_UI else 1.16
ASSETS_DIR = files("openpilot.selfdrive").joinpath("assets")
FONT_DIR = ASSETS_DIR.joinpath("fonts")
@@ -86,12 +87,17 @@ FONT_DIR = ASSETS_DIR.joinpath("fonts")
class FontWeight(StrEnum):
LIGHT = "Inter-Light.fnt"
- NORMAL = "Inter-Regular.fnt"
+ NORMAL = "Inter-Regular.fnt" if BIG_UI else "Inter-Medium.fnt"
MEDIUM = "Inter-Medium.fnt"
- SEMI_BOLD = "Inter-SemiBold.fnt"
BOLD = "Inter-Bold.fnt"
+ SEMI_BOLD = "Inter-SemiBold.fnt"
UNIFONT = "unifont.fnt"
+ # Small UI fonts
+ DISPLAY_REGULAR = "Inter-Regular.fnt"
+ ROMAN = "Inter-Regular.fnt"
+ DISPLAY = "Inter-Bold.fnt"
+
def font_fallback(font: rl.Font) -> rl.Font:
"""Fall back to unifont for languages that require it."""
@@ -181,10 +187,10 @@ class MouseState:
class GuiApplication:
- def __init__(self, width: int, height: int):
+ def __init__(self, width: int | None = None, height: int | None = None):
self._fonts: dict[FontWeight, rl.Font] = {}
- self._width = width
- self._height = height
+ self._width = width if width is not None else GuiApplication._default_width()
+ self._height = height if height is not None else GuiApplication._default_height()
if PC and os.getenv("SCALE") is None:
self._scale = self._calculate_auto_scale()
@@ -654,5 +660,17 @@ class GuiApplication:
# Apply 0.95 factor for window decorations/taskbar margin
return max(0.3, min(w / self._width, h / self._height) * 0.95)
+ @staticmethod
+ def _default_width() -> int:
+ return 2160 if GuiApplication.big_ui() else 536
-gui_app = GuiApplication(2160, 1080)
+ @staticmethod
+ def _default_height() -> int:
+ return 1080 if GuiApplication.big_ui() else 240
+
+ @staticmethod
+ def big_ui() -> bool:
+ return HARDWARE.get_device_type() in ('tici', 'tizi') or BIG_UI
+
+
+gui_app = GuiApplication()
diff --git a/system/ui/lib/scroll_panel2.py b/system/ui/lib/scroll_panel2.py
new file mode 100644
index 000000000..8d9caadfd
--- /dev/null
+++ b/system/ui/lib/scroll_panel2.py
@@ -0,0 +1,219 @@
+import os
+import math
+import pyray as rl
+from collections.abc import Callable
+from enum import Enum
+from typing import cast
+from openpilot.system.ui.lib.application import gui_app, MouseEvent
+from openpilot.system.hardware import TICI
+from collections import deque
+
+MIN_VELOCITY = 2 # px/s, changes from auto scroll to steady state
+MIN_VELOCITY_FOR_CLICKING = 2 * 60 # px/s, accepts clicks while auto scrolling below this velocity
+MIN_DRAG_PIXELS = 12
+AUTO_SCROLL_TC_SNAP = 0.025
+AUTO_SCROLL_TC = 0.18
+BOUNCE_RETURN_RATE = 10.0
+REJECT_DECELERATION_FACTOR = 3
+MAX_SPEED = 10000.0 # px/s
+
+DEBUG = os.getenv("DEBUG_SCROLL", "0") == "1"
+
+
+# from https://ariya.io/2011/10/flick-list-with-its-momentum-scrolling-and-deceleration
+class ScrollState(Enum):
+ STEADY = 0
+ PRESSED = 1
+ MANUAL_SCROLL = 2
+ AUTO_SCROLL = 3
+
+
+class GuiScrollPanel2:
+ def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True) -> None:
+ self._horizontal = horizontal
+ self._handle_out_of_bounds = handle_out_of_bounds
+ self._AUTO_SCROLL_TC = AUTO_SCROLL_TC_SNAP if not self._handle_out_of_bounds else AUTO_SCROLL_TC
+ self._state = ScrollState.STEADY
+ self._offset: rl.Vector2 = rl.Vector2(0, 0)
+ self._initial_click_event: MouseEvent | None = None
+ self._previous_mouse_event: MouseEvent | None = None
+ self._velocity = 0.0 # pixels per second
+ self._velocity_buffer: deque[float] = deque(maxlen=12 if TICI else 6)
+ self._enabled: bool | Callable[[], bool] = True
+
+ def set_enabled(self, enabled: bool | Callable[[], bool]) -> None:
+ self._enabled = enabled
+
+ @property
+ def enabled(self) -> bool:
+ return self._enabled() if callable(self._enabled) else self._enabled
+
+ def update(self, bounds: rl.Rectangle, content_size: float) -> float:
+ if DEBUG:
+ print('Old state:', self._state)
+
+ bounds_size = bounds.width if self._horizontal else bounds.height
+
+ for mouse_event in gui_app.mouse_events:
+ self._handle_mouse_event(mouse_event, bounds, bounds_size, content_size)
+ self._previous_mouse_event = mouse_event
+
+ self._update_state(bounds_size, content_size)
+
+ if DEBUG:
+ print('Velocity:', self._velocity)
+ print('Offset X:', self._offset.x, 'Y:', self._offset.y)
+ print('New state:', self._state)
+ print()
+ return self.get_offset()
+
+ def _update_state(self, bounds_size: float, content_size: float) -> None:
+ """Runs per render frame, independent of mouse events. Updates auto-scrolling state and velocity."""
+ if self._state == ScrollState.AUTO_SCROLL:
+ # simple exponential return if out of bounds
+ out_of_bounds = self.get_offset() > 0 or self.get_offset() < (bounds_size - content_size)
+ if out_of_bounds and self._handle_out_of_bounds:
+ if self.get_offset() < (bounds_size - content_size): # too far right
+ target = bounds_size - content_size
+ else: # too far left
+ target = 0.0
+
+ dt = rl.get_frame_time() or 1e-6
+ factor = 1.0 - math.exp(-BOUNCE_RETURN_RATE * dt)
+
+ dist = target - self.get_offset()
+ self.set_offset(self.get_offset() + dist * factor) # ease toward the edge
+ self._velocity *= (1.0 - factor) # damp any leftover fling
+
+ # Steady once we are close enough to the target
+ if abs(dist) < 1 and abs(self._velocity) < MIN_VELOCITY:
+ self.set_offset(target)
+ self._state = ScrollState.STEADY
+
+ elif abs(self._velocity) < MIN_VELOCITY:
+ self._velocity = 0.0
+ self._state = ScrollState.STEADY
+
+ # Update the offset based on the current velocity
+ dt = rl.get_frame_time()
+ self.set_offset(self.get_offset() + self._velocity * dt) # Adjust the offset based on velocity
+ alpha = 1 - (dt / (self._AUTO_SCROLL_TC + dt))
+ self._velocity *= alpha
+
+ def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float,
+ content_size: float) -> None:
+ out_of_bounds = self.get_offset() > 0 or self.get_offset() < (bounds_size - content_size)
+ if DEBUG:
+ print('Mouse event:', mouse_event)
+
+ mouse_pos = self._get_mouse_pos(mouse_event)
+
+ if not self.enabled:
+ # Reset state if not enabled
+ self._state = ScrollState.STEADY
+ self._velocity = 0.0
+ self._velocity_buffer.clear()
+
+ elif self._state == ScrollState.STEADY:
+ if rl.check_collision_point_rec(mouse_event.pos, bounds):
+ if mouse_event.left_pressed:
+ self._state = ScrollState.PRESSED
+ self._initial_click_event = mouse_event
+
+ elif self._state == ScrollState.PRESSED:
+ initial_click_pos = self._get_mouse_pos(cast(MouseEvent, self._initial_click_event))
+ diff = abs(mouse_pos - initial_click_pos)
+ if mouse_event.left_released:
+ # Special handling for down and up clicks across two frames
+ # TODO: not sure what that means or if it's accurate anymore
+ if out_of_bounds:
+ self._state = ScrollState.AUTO_SCROLL
+ elif diff <= MIN_DRAG_PIXELS:
+ self._state = ScrollState.STEADY
+ else:
+ self._state = ScrollState.MANUAL_SCROLL
+ elif diff > MIN_DRAG_PIXELS:
+ self._state = ScrollState.MANUAL_SCROLL
+
+ elif self._state == ScrollState.MANUAL_SCROLL:
+ if mouse_event.left_released:
+ # Touch rejection: when releasing finger after swiping and stopping, panel
+ # reports a few erroneous touch events with high velocity, try to ignore.
+
+ # If velocity decelerates very quickly, assume user doesn't intend to auto scroll
+ high_decel = False
+ if len(self._velocity_buffer) > 2:
+ # We limit max to first half since final few velocities can surpass first few
+ abs_velocity_buffer = [(abs(v), i) for i, v in enumerate(self._velocity_buffer)]
+ max_idx = max(abs_velocity_buffer[:len(abs_velocity_buffer) // 2])[1]
+ min_idx = min(abs_velocity_buffer)[1]
+ if DEBUG:
+ print('min_idx:', min_idx, 'max_idx:', max_idx, 'velocity buffer:', self._velocity_buffer)
+ if (abs(self._velocity_buffer[min_idx]) * REJECT_DECELERATION_FACTOR < abs(self._velocity_buffer[max_idx]) and
+ max_idx < min_idx):
+ if DEBUG:
+ print('deceleration too high, going to STEADY')
+ high_decel = True
+
+ # If final velocity is below some threshold, switch to steady state too
+ low_speed = abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING * 1.5 # plus some margin
+
+ if out_of_bounds or not (high_decel or low_speed):
+ self._state = ScrollState.AUTO_SCROLL
+ else:
+ # TODO: we should just set velocity and let autoscroll go back to steady. delays one frame but who cares
+ self._velocity = 0.0
+ self._state = ScrollState.STEADY
+ self._velocity_buffer.clear()
+ else:
+ # Update velocity for when we release the mouse button.
+ # Do not update velocity on the same frame the mouse was released
+ previous_mouse_pos = self._get_mouse_pos(cast(MouseEvent, self._previous_mouse_event))
+ delta_x = mouse_pos - previous_mouse_pos
+ self._velocity = delta_x / (mouse_event.t - cast(MouseEvent, self._previous_mouse_event).t)
+ self._velocity = max(-MAX_SPEED, min(MAX_SPEED, self._velocity))
+ self._velocity_buffer.append(self._velocity)
+
+ # rubber-banding: reduce dragging when out of bounds
+ # TODO: this drifts when dragging quickly
+ if out_of_bounds:
+ delta_x *= 0.25
+
+ # Update the offset based on the mouse movement
+ # Use internal _offset directly to preserve precision (don't round via get_offset())
+ # TODO: make get_offset return float
+ current_offset = self._offset.x if self._horizontal else self._offset.y
+ self.set_offset(current_offset + delta_x)
+
+ elif self._state == ScrollState.AUTO_SCROLL:
+ if mouse_event.left_pressed:
+ # Decide whether to click or scroll (block click if moving too fast)
+ if abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING:
+ # Traveling slow enough, click
+ self._state = ScrollState.PRESSED
+ self._initial_click_event = mouse_event
+ else:
+ # Go straight into manual scrolling to block erroneous input
+ self._state = ScrollState.MANUAL_SCROLL
+ # Reset velocity for touch down and up events that happen in back-to-back frames
+ self._velocity = 0.0
+
+ def _get_mouse_pos(self, mouse_event: MouseEvent) -> float:
+ return mouse_event.pos.x if self._horizontal else mouse_event.pos.y
+
+ def get_offset(self) -> int:
+ return round(self._offset.x if self._horizontal else self._offset.y)
+
+ def set_offset(self, value: float) -> None:
+ if self._horizontal:
+ self._offset.x = value
+ else:
+ self._offset.y = value
+
+ @property
+ def state(self) -> ScrollState:
+ return self._state
+
+ def is_touch_valid(self) -> bool:
+ # MIN_VELOCITY_FOR_CLICKING is checked in auto-scroll state
+ return bool(self._state != ScrollState.MANUAL_SCROLL)
diff --git a/system/ui/lib/shader_polygon.py b/system/ui/lib/shader_polygon.py
index 7be6638af..94af35e15 100644
--- a/system/ui/lib/shader_polygon.py
+++ b/system/ui/lib/shader_polygon.py
@@ -191,7 +191,9 @@ def triangulate(pts: np.ndarray) -> list[tuple[float, float]]:
# TODO: consider deduping close screenspace points
# interleave points to produce a triangle strip
- assert len(pts) % 2 == 0, "Interleaving expects even number of points"
+ # assert len(pts) % 2 == 0, "Interleaving expects even number of points"
+ if len(pts) % 2 != 0:
+ pts = pts[:-1]
tri_strip = []
for i in range(len(pts) // 2):
diff --git a/system/ui/lib/text_measure.py b/system/ui/lib/text_measure.py
index 544ba5b87..dee4b419f 100644
--- a/system/ui/lib/text_measure.py
+++ b/system/ui/lib/text_measure.py
@@ -5,9 +5,10 @@ from openpilot.system.ui.lib.emoji import find_emoji
_cache: dict[int, rl.Vector2] = {}
-def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: int = 0) -> rl.Vector2:
+def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: float = 0) -> rl.Vector2:
"""Caches text measurements to avoid redundant calculations."""
font = font_fallback(font)
+ spacing = round(spacing, 4)
key = hash((font.texture.id, text, font_size, spacing))
if key in _cache:
return _cache[key]
diff --git a/system/ui/lib/wifi_manager.py b/system/ui/lib/wifi_manager.py
index 4cf2ccebc..217ac5e89 100644
--- a/system/ui/lib/wifi_manager.py
+++ b/system/ui/lib/wifi_manager.py
@@ -35,7 +35,7 @@ except Exception:
TETHERING_IP_ADDRESS = "192.168.43.1"
DEFAULT_TETHERING_PASSWORD = "swagswagcomma"
SIGNAL_QUEUE_SIZE = 10
-SCAN_PERIOD_SECONDS = 10
+SCAN_PERIOD_SECONDS = 5
class SecurityType(IntEnum):
@@ -75,6 +75,7 @@ class Network:
is_connected: bool
security_type: SecurityType
is_saved: bool
+ ip_address: str = "" # TODO: implement
@classmethod
def from_dbus(cls, ssid: str, aps: list["AccessPoint"], is_saved: bool) -> "Network":
@@ -627,7 +628,7 @@ class WifiManager:
known_connections = self._get_connections()
networks = [Network.from_dbus(ssid, ap_list, ssid in known_connections) for ssid, ap_list in aps.items()]
- networks.sort(key=lambda n: (-n.is_connected, -n.strength, n.ssid.lower()))
+ networks.sort(key=lambda n: (-n.is_connected, n.ssid.lower()))
self._networks = networks
self._update_ipv4_address()
diff --git a/system/ui/lib/wrap_text.py b/system/ui/lib/wrap_text.py
index 745d37b46..3fabfbb66 100644
--- a/system/ui/lib/wrap_text.py
+++ b/system/ui/lib/wrap_text.py
@@ -3,7 +3,7 @@ from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import font_fallback
-def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -> list[str]:
+def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]:
if not word:
return []
@@ -11,7 +11,7 @@ def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -
remaining = word
while remaining:
- if measure_text_cached(font, remaining, font_size).x <= max_width:
+ if measure_text_cached(font, remaining, font_size, spacing).x <= max_width:
parts.append(remaining)
break
@@ -22,7 +22,7 @@ def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -
while left <= right:
mid = (left + right) // 2
substring = remaining[:mid]
- width = measure_text_cached(font, substring, font_size).x
+ width = measure_text_cached(font, substring, font_size, spacing).x
if width <= max_width:
best_fit = mid
@@ -40,9 +40,10 @@ def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -
_cache: dict[int, list[str]] = {}
-def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[str]:
+def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]:
font = font_fallback(font)
- key = hash((font.texture.id, text, font_size, max_width))
+ spacing = round(spacing, 4)
+ key = hash((font.texture.id, text, font_size, max_width, spacing))
if key in _cache:
return _cache[key]
@@ -69,7 +70,7 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[
current_line: list[str] = []
for word in words:
- word_width = int(measure_text_cached(font, word, font_size).x)
+ word_width = measure_text_cached(font, word, font_size, spacing).x
# Check if word alone exceeds max width (need to break the word)
if word_width > max_width:
@@ -79,12 +80,12 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[
current_line = []
# Break the long word into parts
- lines.extend(_break_long_word(font, word, font_size, max_width))
+ lines.extend(_break_long_word(font, word, font_size, max_width, spacing))
continue
# Measure the actual joined string to get accurate width (accounts for kerning, etc.)
test_line = " ".join(current_line + [word]) if current_line else word
- test_width = int(measure_text_cached(font, test_line, font_size).x)
+ test_width = measure_text_cached(font, test_line, font_size, spacing).x
# Check if word fits on current line
if test_width <= max_width:
diff --git a/system/ui/mici_reset.py b/system/ui/mici_reset.py
new file mode 100755
index 000000000..d9bb45d99
--- /dev/null
+++ b/system/ui/mici_reset.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+import os
+import sys
+import threading
+import time
+from enum import IntEnum
+
+import pyray as rl
+
+from openpilot.system.hardware import PC
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.slider import SmallSlider
+from openpilot.system.ui.widgets.button import SmallButton, FullRoundedButton
+from openpilot.system.ui.widgets.label import gui_label, gui_text_box
+
+USERDATA = "/dev/disk/by-partlabel/userdata"
+TIMEOUT = 3*60
+
+
+class ResetMode(IntEnum):
+ USER_RESET = 0 # user initiated a factory reset from openpilot
+ RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
+ FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
+
+
+class ResetState(IntEnum):
+ NONE = 0
+ RESETTING = 1
+ FAILED = 2
+
+
+class Reset(Widget):
+ def __init__(self, mode):
+ super().__init__()
+ self._mode = mode
+ self._previous_reset_state = None
+ self._reset_state = ResetState.NONE
+
+ self._cancel_button = SmallButton("cancel")
+ self._cancel_button.set_click_callback(self._cancel_callback)
+
+ self._reboot_button = FullRoundedButton("reboot")
+ self._reboot_button.set_click_callback(self._do_reboot)
+
+ self._confirm_slider = SmallSlider("reset", self._confirm)
+
+ self._render_status = True
+
+ def _cancel_callback(self):
+ self._render_status = False
+
+ def _do_reboot(self):
+ if PC:
+ return
+
+ os.system("sudo reboot")
+
+ def _do_erase(self):
+ if PC:
+ return
+
+ # Removing data and formatting
+ rm = os.system("sudo rm -rf /data/*")
+ os.system(f"sudo umount {USERDATA}")
+ fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}")
+
+ if rm == 0 or fmt == 0:
+ os.system("sudo reboot")
+ else:
+ self._reset_state = ResetState.FAILED
+
+ def start_reset(self):
+ self._reset_state = ResetState.RESETTING
+ threading.Timer(0.1, self._do_erase).start()
+
+ def _update_state(self):
+ if self._reset_state != self._previous_reset_state:
+ self._previous_reset_state = self._reset_state
+ self._timeout_st = time.monotonic()
+ elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT:
+ exit(0)
+
+ def _render(self, rect: rl.Rectangle):
+ label_rect = rl.Rectangle(rect.x + 8, rect.y + 8, rect.width, 50)
+ gui_label(label_rect, "factory reset", 48, font_weight=FontWeight.BOLD,
+ color=rl.Color(255, 255, 255, int(255 * 0.9)))
+
+ text_rect = rl.Rectangle(rect.x + 8, rect.y + 56, rect.width - 8 * 2, rect.height - 80)
+ gui_text_box(text_rect, self._get_body_text(), 36, font_weight=FontWeight.ROMAN, line_scale=0.9)
+
+ if self._reset_state != ResetState.RESETTING:
+ # fade out cancel button as slider is moved, set visible to prevent pressing invisible cancel
+ self._cancel_button.set_opacity(1.0 - self._confirm_slider.slider_percentage)
+ self._cancel_button.set_visible(self._confirm_slider.slider_percentage < 0.8)
+
+ if self._mode == ResetMode.RECOVER:
+ self._cancel_button.set_text("reboot")
+ self._cancel_button.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + rect.height - self._cancel_button.rect.height,
+ self._cancel_button.rect.width,
+ self._cancel_button.rect.height))
+ elif self._mode == ResetMode.USER_RESET and self._reset_state != ResetState.FAILED:
+ self._cancel_button.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + rect.height - self._cancel_button.rect.height,
+ self._cancel_button.rect.width,
+ self._cancel_button.rect.height))
+
+ if self._reset_state != ResetState.FAILED:
+ self._confirm_slider.render(rl.Rectangle(
+ rect.x + rect.width - self._confirm_slider.rect.width,
+ rect.y + rect.height - self._confirm_slider.rect.height,
+ self._confirm_slider.rect.width,
+ self._confirm_slider.rect.height))
+ else:
+ self._reboot_button.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + rect.height - self._reboot_button.rect.height,
+ self._reboot_button.rect.width,
+ self._reboot_button.rect.height))
+
+ return self._render_status
+
+ def _confirm(self):
+ self.start_reset()
+
+ def _get_body_text(self):
+ if self._reset_state == ResetState.RESETTING:
+ return "Resetting device... This may take up to a minute."
+ if self._reset_state == ResetState.FAILED:
+ return "Reset failed. Reboot to try again."
+ if self._mode == ResetMode.RECOVER:
+ return "Unable to mount data partition. Partition may be corrupted."
+ return "All content and settings will be erased."
+
+
+def main():
+ mode = ResetMode.USER_RESET
+ if len(sys.argv) > 1:
+ if sys.argv[1] == '--recover':
+ mode = ResetMode.RECOVER
+ elif sys.argv[1] == "--format":
+ mode = ResetMode.FORMAT
+
+ gui_app.init_window("System Reset")
+ reset = Reset(mode)
+
+ if mode == ResetMode.FORMAT:
+ reset.start_reset()
+
+ for should_render in gui_app.render():
+ if should_render:
+ if not reset.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)):
+ break
+
+
+if __name__ == "__main__":
+ main()
diff --git a/system/ui/mici_setup.py b/system/ui/mici_setup.py
new file mode 100755
index 000000000..4afd69bfa
--- /dev/null
+++ b/system/ui/mici_setup.py
@@ -0,0 +1,727 @@
+#!/usr/bin/env python3
+from abc import abstractmethod
+import os
+import re
+import threading
+import time
+import urllib.request
+import urllib.error
+from urllib.parse import urlparse
+from enum import IntEnum
+import shutil
+from collections.abc import Callable
+
+import pyray as rl
+
+from cereal import log
+from openpilot.common.utils import run_cmd
+from openpilot.system.hardware import HARDWARE
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.lib.wifi_manager import WifiManager
+from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
+from openpilot.system.ui.widgets import Widget, DialogResult
+from openpilot.system.ui.widgets.button import (IconButton, SmallButton, WideRoundedButton, SmallerRoundedButton,
+ SmallCircleIconButton, WidishRoundedButton, SmallRedPillButton,
+ FullRoundedButton)
+from openpilot.system.ui.widgets.label import UnifiedLabel
+from openpilot.system.ui.widgets.slider import LargerSlider
+from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiUIMici
+from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog
+
+NetworkType = log.DeviceState.NetworkType
+
+OPENPILOT_URL = "https://openpilot.comma.ai"
+USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
+
+CONTINUE_PATH = "/data/continue.sh"
+TMP_CONTINUE_PATH = "/data/continue.sh.new"
+INSTALL_PATH = "/data/openpilot"
+VALID_CACHE_PATH = "/data/.openpilot_cache"
+INSTALLER_SOURCE_PATH = "/usr/comma/installer"
+INSTALLER_DESTINATION_PATH = "/tmp/installer"
+INSTALLER_URL_PATH = "/tmp/installer_url"
+
+CONTINUE = """#!/usr/bin/env bash
+
+cd /data/openpilot
+exec ./launch_openpilot.sh
+"""
+
+
+class NetworkConnectivityMonitor:
+ def __init__(self, should_check: Callable[[], bool] | None = None, check_interval: float = 0.5):
+ self.network_connected = threading.Event()
+ self.wifi_connected = threading.Event()
+ self._should_check = should_check or (lambda: True)
+ self._check_interval = check_interval
+ self._stop_event = threading.Event()
+ self._thread: threading.Thread | None = None
+
+ def start(self):
+ self._stop_event.clear()
+ if self._thread is None or not self._thread.is_alive():
+ self._thread = threading.Thread(target=self._run, daemon=True)
+ self._thread.start()
+
+ def stop(self):
+ if self._thread is not None:
+ self._stop_event.set()
+ self._thread.join()
+ self._thread = None
+
+ def reset(self):
+ self.network_connected.clear()
+ self.wifi_connected.clear()
+
+ def _run(self):
+ while not self._stop_event.is_set():
+ if self._should_check():
+ try:
+ request = urllib.request.Request(OPENPILOT_URL, method="HEAD")
+ urllib.request.urlopen(request, timeout=0.5)
+ self.network_connected.set()
+ if HARDWARE.get_network_type() == NetworkType.wifi:
+ self.wifi_connected.set()
+ except Exception:
+ self.reset()
+ else:
+ self.reset()
+
+ if self._stop_event.wait(timeout=self._check_interval):
+ break
+
+
+class SetupState(IntEnum):
+ GETTING_STARTED = 0
+ NETWORK_SETUP = 1
+ NETWORK_SETUP_CUSTOM_SOFTWARE = 8
+ SOFTWARE_SELECTION = 2
+ CUSTOM_SOFTWARE = 3
+ DOWNLOADING = 4
+ DOWNLOAD_FAILED = 5
+ CUSTOM_SOFTWARE_WARNING = 6
+
+
+class StartPage(Widget):
+ def __init__(self):
+ super().__init__()
+
+ self._title = UnifiedLabel("start", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ font_weight=FontWeight.DISPLAY, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
+
+ self._start_bg_txt = gui_app.texture("icons_mici/setup/green_button.png", 520, 224)
+ self._start_bg_pressed_txt = gui_app.texture("icons_mici/setup/green_button_pressed.png", 520, 224)
+
+ def _render(self, rect: rl.Rectangle):
+ draw_x = rect.x + (rect.width - self._start_bg_txt.width) / 2
+ draw_y = rect.y + (rect.height - self._start_bg_txt.height) / 2
+ texture = self._start_bg_pressed_txt if self.is_pressed else self._start_bg_txt
+ rl.draw_texture(texture, int(draw_x), int(draw_y), rl.WHITE)
+
+ self._title.render(rect)
+
+
+class SoftwareSelectionPage(Widget):
+ def __init__(self, use_openpilot_callback: Callable,
+ use_custom_software_callback: Callable):
+ super().__init__()
+
+ self._openpilot_slider = LargerSlider("slide to use\nopenpilot", use_openpilot_callback)
+ self._custom_software_slider = LargerSlider("slide to use\ncustom software", use_custom_software_callback, green=False)
+
+ def reset(self):
+ self._openpilot_slider.reset()
+ self._custom_software_slider.reset()
+
+ def _render(self, rect: rl.Rectangle):
+ self._openpilot_slider.set_opacity(1.0 - self._custom_software_slider.slider_percentage)
+ self._custom_software_slider.set_opacity(1.0 - self._openpilot_slider.slider_percentage)
+
+ openpilot_rect = rl.Rectangle(
+ rect.x + (rect.width - self._openpilot_slider.rect.width) / 2,
+ rect.y,
+ self._openpilot_slider.rect.width,
+ rect.height / 2,
+ )
+ self._openpilot_slider.render(openpilot_rect)
+
+ custom_software_rect = rl.Rectangle(
+ rect.x + (rect.width - self._custom_software_slider.rect.width) / 2,
+ rect.y + rect.height / 2,
+ self._custom_software_slider.rect.width,
+ rect.height / 2,
+ )
+ self._custom_software_slider.render(custom_software_rect)
+
+
+class TermsHeader(Widget):
+ def __init__(self, text: str, icon_texture: rl.Texture):
+ super().__init__()
+
+ self._title = UnifiedLabel(text, 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ font_weight=FontWeight.BOLD, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
+ line_height=0.8)
+ self._icon_texture = icon_texture
+
+ self.set_rect(rl.Rectangle(0, 0, gui_app.width - 16 * 2, self._icon_texture.height))
+
+ def set_title(self, text: str):
+ self._title.set_text(text)
+
+ def set_icon(self, icon_texture: rl.Texture):
+ self._icon_texture = icon_texture
+
+ def _render(self, _):
+ rl.draw_texture_ex(self._icon_texture, rl.Vector2(self._rect.x, self._rect.y),
+ 0.0, 1.0, rl.WHITE)
+
+ # May expand outside parent rect
+ title_content_height = self._title.get_content_height(int(self._rect.width - self._icon_texture.width - 16))
+ title_rect = rl.Rectangle(
+ self._rect.x + self._icon_texture.width + 16,
+ self._rect.y + (self._rect.height - title_content_height) / 2,
+ self._rect.width - self._icon_texture.width - 16,
+ title_content_height,
+ )
+ self._title.render(title_rect)
+
+
+class TermsPage(Widget):
+ ITEM_SPACING = 20
+
+ def __init__(self, continue_callback: Callable, back_callback: Callable | None = None,
+ back_text: str = "back", continue_text: str = "accept"):
+ super().__init__()
+
+ # TODO: use Scroller
+ self._scroll_panel = GuiScrollPanel2(horizontal=False)
+
+ self._continue_text = continue_text
+ self._continue_button: WideRoundedButton | FullRoundedButton
+ if back_callback is not None:
+ self._continue_button = WideRoundedButton(continue_text)
+ else:
+ self._continue_button = FullRoundedButton(continue_text)
+ self._continue_button.set_enabled(False)
+ self._continue_button.set_opacity(0.0)
+ self._continue_button.set_touch_valid_callback(self._scroll_panel.is_touch_valid)
+ self._continue_button.set_click_callback(continue_callback)
+
+ self._enable_back = back_callback is not None
+ self._back_button = SmallButton(back_text)
+ self._back_button.set_opacity(0.0)
+ self._back_button.set_touch_valid_callback(self._scroll_panel.is_touch_valid)
+ self._back_button.set_click_callback(back_callback)
+
+ self._scroll_down_indicator = IconButton(gui_app.texture("icons_mici/setup/scroll_down_indicator.png", 64, 78))
+ self._scroll_down_indicator.set_enabled(False)
+
+ def reset(self):
+ self._scroll_panel.set_offset(0)
+ self._continue_button.set_enabled(False)
+ self._continue_button.set_opacity(0.0)
+ self._back_button.set_enabled(False)
+ self._back_button.set_opacity(0.0)
+ self._scroll_down_indicator.set_opacity(1.0)
+
+ @property
+ @abstractmethod
+ def _content_height(self):
+ pass
+
+ @property
+ def _scrolled_down_offset(self):
+ return -self._content_height + (self._continue_button.rect.height + 16 + 30)
+
+ @abstractmethod
+ def _render_content(self, scroll_offset):
+ pass
+
+ def _render(self, _):
+ scroll_offset = self._scroll_panel.update(self._rect, self._content_height + self._continue_button.rect.height + 16)
+
+ if scroll_offset <= self._scrolled_down_offset:
+ # don't show back if not enabled
+ if self._enable_back:
+ self._back_button.set_enabled(True)
+ self._back_button.set_opacity(1.0, smooth=True)
+ self._continue_button.set_enabled(True)
+ self._continue_button.set_opacity(1.0, smooth=True)
+ self._scroll_down_indicator.set_opacity(0.0, smooth=True)
+ else:
+ self._back_button.set_enabled(False)
+ self._back_button.set_opacity(0.0, smooth=True)
+ self._continue_button.set_enabled(False)
+ self._continue_button.set_opacity(0.0, smooth=True)
+ self._scroll_down_indicator.set_opacity(1.0, smooth=True)
+
+ # Render content
+ self._render_content(scroll_offset)
+
+ # black gradient at top and bottom for scrolling content
+ rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y),
+ int(self._rect.width), 20, rl.BLACK, rl.BLANK)
+ rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - 20),
+ int(self._rect.width), 20, rl.BLANK, rl.BLACK)
+
+ self._back_button.render(rl.Rectangle(
+ self._rect.x + 8,
+ self._rect.y + self._rect.height - self._back_button.rect.height,
+ self._back_button.rect.width,
+ self._back_button.rect.height,
+ ))
+
+ continue_x = self._rect.x + 8
+ if self._enable_back:
+ continue_x = self._rect.x + self._rect.width - self._continue_button.rect.width - 8
+ self._continue_button.render(rl.Rectangle(
+ continue_x,
+ self._rect.y + self._rect.height - self._continue_button.rect.height,
+ self._continue_button.rect.width,
+ self._continue_button.rect.height,
+ ))
+
+ self._scroll_down_indicator.render(rl.Rectangle(
+ self._rect.x + self._rect.width - self._scroll_down_indicator.rect.width - 8,
+ self._rect.y + self._rect.height - self._scroll_down_indicator.rect.height - 8,
+ self._scroll_down_indicator.rect.width,
+ self._scroll_down_indicator.rect.height,
+ ))
+
+
+class CustomSoftwareWarningPage(TermsPage):
+ def __init__(self, continue_callback: Callable, back_callback: Callable):
+ super().__init__(continue_callback, back_callback)
+
+ self._title_header = TermsHeader("use caution installing\n3rd party software",
+ gui_app.texture("icons_mici/setup/warning.png", 66, 60))
+ self._body = UnifiedLabel("• It has not been tested by comma.\n" +
+ "• It may not comply with relevant safety standards.\n" +
+ "• It may cause damage to your device and/or vehicle.\n", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ font_weight=FontWeight.ROMAN)
+
+ self._restore_header = TermsHeader("how to backup &\nrestore", gui_app.texture("icons_mici/setup/restore.png", 60, 60))
+ self._restore_body = UnifiedLabel("To restore your device to a factory state later, use https://flash.comma.ai",
+ 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ font_weight=FontWeight.ROMAN)
+
+ @property
+ def _content_height(self):
+ return self._restore_body.rect.y + self._restore_body.rect.height - self._scroll_panel.get_offset()
+
+ def _render_content(self, scroll_offset):
+ self._title_header.set_position(self._rect.x + 16, self._rect.y + 8 + scroll_offset)
+ self._title_header.render()
+
+ body_rect = rl.Rectangle(
+ self._rect.x + 8,
+ self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING,
+ self._rect.width - 50,
+ self._body.get_content_height(int(self._rect.width - 50)),
+ )
+ self._body.render(body_rect)
+
+ self._restore_header.set_position(self._rect.x + 16, self._body.rect.y + self._body.rect.height + self.ITEM_SPACING)
+ self._restore_header.render()
+
+ self._restore_body.render(rl.Rectangle(
+ self._rect.x + 8,
+ self._restore_header.rect.y + self._restore_header.rect.height + self.ITEM_SPACING,
+ self._rect.width - 50,
+ self._restore_body.get_content_height(int(self._rect.width - 50)),
+ ))
+
+
+class DownloadingPage(Widget):
+ def __init__(self):
+ super().__init__()
+
+ self._title_label = UnifiedLabel("downloading", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ font_weight=FontWeight.DISPLAY)
+ self._progress_label = UnifiedLabel("", 128, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.35)),
+ font_weight=FontWeight.ROMAN, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
+ self._progress = 0
+
+ def set_progress(self, progress: int):
+ self._progress = progress
+ self._progress_label.set_text(f"{progress}%")
+
+ def _render(self, rect: rl.Rectangle):
+ self._title_label.render(rl.Rectangle(
+ rect.x + 20,
+ rect.y + 10,
+ rect.width,
+ 64,
+ ))
+
+ self._progress_label.render(rl.Rectangle(
+ rect.x + 20,
+ rect.y + 20,
+ rect.width,
+ rect.height,
+ ))
+
+
+class FailedPage(Widget):
+ def __init__(self, reboot_callback: Callable, retry_callback: Callable, title: str = "download failed"):
+ super().__init__()
+
+ self._title_label = UnifiedLabel(title, 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ font_weight=FontWeight.DISPLAY)
+ self._reason_label = UnifiedLabel("", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)),
+ font_weight=FontWeight.ROMAN)
+
+ self._reboot_button = SmallRedPillButton("reboot")
+ self._reboot_button.set_click_callback(reboot_callback)
+
+ self._retry_button = WideRoundedButton("retry")
+ self._retry_button.set_click_callback(retry_callback)
+
+ def set_reason(self, reason: str):
+ self._reason_label.set_text(reason)
+
+ def _render(self, rect: rl.Rectangle):
+ self._title_label.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + 10,
+ rect.width,
+ 64,
+ ))
+
+ self._reason_label.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + 10 + 64,
+ rect.width,
+ 36,
+ ))
+
+ self._reboot_button.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + rect.height - self._reboot_button.rect.height,
+ self._reboot_button.rect.width,
+ self._reboot_button.rect.height,
+ ))
+
+ self._retry_button.render(rl.Rectangle(
+ rect.x + 8 + self._reboot_button.rect.width + 8,
+ rect.y + rect.height - self._retry_button.rect.height,
+ self._retry_button.rect.width,
+ self._retry_button.rect.height,
+ ))
+
+
+class NetworkSetupState(IntEnum):
+ MAIN = 0
+ WIFI_PANEL = 1
+
+
+class NetworkSetupPage(Widget):
+ def __init__(self, wifi_manager, continue_callback: Callable, back_callback: Callable):
+ super().__init__()
+ self._wifi_ui = WifiUIMici(wifi_manager, back_callback=lambda: self.set_state(NetworkSetupState.MAIN))
+
+ self._no_wifi_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 58, 50)
+ self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 58, 50)
+ self._waiting_text = "waiting for internet..."
+ self._network_header = TermsHeader(self._waiting_text, self._no_wifi_txt)
+
+ back_txt = gui_app.texture("icons_mici/setup/back_new.png", 37, 32)
+ self._back_button = SmallCircleIconButton(back_txt)
+ self._back_button.set_click_callback(back_callback)
+
+ self._wifi_button = SmallerRoundedButton("wifi")
+ self._wifi_button.set_click_callback(lambda: self.set_state(NetworkSetupState.WIFI_PANEL))
+
+ self._continue_button = WidishRoundedButton("continue")
+ self._continue_button.set_enabled(False)
+ self._continue_button.set_click_callback(continue_callback)
+
+ self._state = NetworkSetupState.MAIN
+
+ def set_state(self, state: NetworkSetupState):
+ self._state = state
+
+ def set_has_internet(self, has_internet: bool):
+ if has_internet:
+ self._network_header.set_title("connected to internet")
+ self._network_header.set_icon(self._wifi_full_txt)
+ self._continue_button.set_enabled(True)
+ else:
+ self._network_header.set_title(self._waiting_text)
+ self._network_header.set_icon(self._no_wifi_txt)
+ self._continue_button.set_enabled(False)
+
+ def show_event(self):
+ super().show_event()
+ self._state = NetworkSetupState.MAIN
+ self._wifi_ui.show_event()
+
+ def hide_event(self):
+ super().hide_event()
+ self._wifi_ui.hide_event()
+
+ def _render(self, _):
+ if self._state == NetworkSetupState.MAIN:
+ self._network_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16,
+ self._rect.width - 32,
+ self._network_header.rect.height,
+ ))
+
+ self._back_button.render(rl.Rectangle(
+ self._rect.x + 8,
+ self._rect.y + self._rect.height - self._back_button.rect.height,
+ self._back_button.rect.width,
+ self._back_button.rect.height,
+ ))
+
+ self._wifi_button.render(rl.Rectangle(
+ self._rect.x + 8 + self._back_button.rect.width + 10,
+ self._rect.y + self._rect.height - self._wifi_button.rect.height,
+ self._wifi_button.rect.width,
+ self._wifi_button.rect.height,
+ ))
+
+ self._continue_button.render(rl.Rectangle(
+ self._rect.x + self._rect.width - self._continue_button.rect.width - 8,
+ self._rect.y + self._rect.height - self._continue_button.rect.height,
+ self._continue_button.rect.width,
+ self._continue_button.rect.height,
+ ))
+ else:
+ self._wifi_ui.render(self._rect)
+
+
+class Setup(Widget):
+ def __init__(self):
+ super().__init__()
+ self.state = SetupState.GETTING_STARTED
+ self.failed_url = ""
+ self.failed_reason = ""
+ self.download_url = ""
+ self.download_progress = 0
+ self.download_thread = None
+ self._wifi_manager = WifiManager()
+ self._wifi_manager.set_active(True)
+ self._network_monitor = NetworkConnectivityMonitor(
+ lambda: self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE)
+ )
+
+ self._start_page = StartPage()
+ self._start_page.set_click_callback(self._getting_started_button_callback)
+
+ self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_button_callback,
+ self._network_setup_back_button_callback)
+
+ self._software_selection_page = SoftwareSelectionPage(self._software_selection_continue_button_callback,
+ self._software_selection_custom_software_button_callback)
+
+ self._download_failed_page = FailedPage(HARDWARE.reboot, self._download_failed_startover_button_callback)
+
+ self._custom_software_warning_page = CustomSoftwareWarningPage(self._software_selection_custom_software_continue,
+ self._custom_software_warning_back_button_callback)
+
+ self._downloading_page = DownloadingPage()
+
+ def _update_state(self):
+ self._wifi_manager.process_callbacks()
+
+ def _set_state(self, state: SetupState):
+ self.state = state
+ if self.state == SetupState.SOFTWARE_SELECTION:
+ self._software_selection_page.reset()
+ elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
+ self._custom_software_warning_page.reset()
+
+ if self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE):
+ self._network_setup_page.show_event()
+ self._network_monitor.reset()
+ self._network_monitor.start()
+ else:
+ self._network_setup_page.hide_event()
+ self._network_monitor.stop()
+
+ def _render(self, rect: rl.Rectangle):
+ if self.state == SetupState.GETTING_STARTED:
+ self._start_page.render(rect)
+ elif self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE):
+ self.render_network_setup(rect)
+ elif self.state == SetupState.SOFTWARE_SELECTION:
+ self._software_selection_page.render(rect)
+ elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
+ self._custom_software_warning_page.render(rect)
+ elif self.state == SetupState.CUSTOM_SOFTWARE:
+ self.render_custom_software()
+ elif self.state == SetupState.DOWNLOADING:
+ self.render_downloading(rect)
+ elif self.state == SetupState.DOWNLOAD_FAILED:
+ self._download_failed_page.render(rect)
+
+ def _custom_software_warning_back_button_callback(self):
+ self._set_state(SetupState.SOFTWARE_SELECTION)
+
+ def _custom_software_warning_continue_button_callback(self):
+ self._set_state(SetupState.CUSTOM_SOFTWARE)
+
+ def _getting_started_button_callback(self):
+ self._set_state(SetupState.SOFTWARE_SELECTION)
+
+ def _software_selection_back_button_callback(self):
+ self._set_state(SetupState.GETTING_STARTED)
+
+ def _software_selection_continue_button_callback(self):
+ self.use_openpilot()
+
+ def _software_selection_custom_software_button_callback(self):
+ self._set_state(SetupState.CUSTOM_SOFTWARE_WARNING)
+
+ def _software_selection_custom_software_continue(self):
+ self._set_state(SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE)
+
+ def _download_failed_startover_button_callback(self):
+ self._set_state(SetupState.GETTING_STARTED)
+
+ def _network_setup_back_button_callback(self):
+ self._set_state(SetupState.SOFTWARE_SELECTION)
+
+ def _network_setup_continue_button_callback(self):
+ self._network_monitor.stop()
+ if self.state == SetupState.NETWORK_SETUP:
+ self.download(OPENPILOT_URL)
+ elif self.state == SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE:
+ self._set_state(SetupState.CUSTOM_SOFTWARE)
+
+ def close(self):
+ self._network_monitor.stop()
+
+ def render_network_setup(self, rect: rl.Rectangle):
+ self._network_setup_page.render(rect)
+ self._network_setup_page.set_has_internet(self._network_monitor.network_connected.is_set())
+
+ def render_downloading(self, rect: rl.Rectangle):
+ self._downloading_page.set_progress(self.download_progress)
+ self._downloading_page.render(rect)
+
+ def render_custom_software(self):
+ def handle_keyboard_result(text):
+ url = text.strip()
+ if url:
+ self.download(url)
+
+ def handle_keyboard_exit(result):
+ if result == DialogResult.CANCEL:
+ self._set_state(SetupState.SOFTWARE_SELECTION)
+
+ keyboard = BigInputDialog("custom software URL", confirm_callback=handle_keyboard_result)
+ gui_app.set_modal_overlay(keyboard, callback=handle_keyboard_exit)
+
+ def use_openpilot(self):
+ if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH):
+ os.remove(VALID_CACHE_PATH)
+ with open(TMP_CONTINUE_PATH, "w") as f:
+ f.write(CONTINUE)
+ run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
+ shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
+ shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
+
+ # give time for installer UI to take over
+ time.sleep(0.1)
+ gui_app.request_close()
+ else:
+ self._set_state(SetupState.NETWORK_SETUP)
+
+ def download(self, url: str):
+ # autocomplete incomplete URLs
+ if re.match("^([^/.]+)/([^/]+)$", url):
+ url = f"https://installer.comma.ai/{url}"
+
+ parsed = urlparse(url, scheme='https')
+ self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl()
+
+ self._set_state(SetupState.DOWNLOADING)
+
+ self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
+ self.download_thread.start()
+
+ def _download_thread(self):
+ try:
+ import tempfile
+
+ fd, tmpfile = tempfile.mkstemp(prefix="installer_")
+
+ headers = {"User-Agent": USER_AGENT,
+ "X-openpilot-serial": HARDWARE.get_serial(),
+ "X-openpilot-device-type": HARDWARE.get_device_type()}
+ req = urllib.request.Request(self.download_url, headers=headers)
+
+ with open(tmpfile, 'wb') as f, urllib.request.urlopen(req, timeout=30) as response:
+ total_size = int(response.headers.get('content-length', 0))
+ downloaded = 0
+ block_size = 8192
+
+ while True:
+ buffer = response.read(block_size)
+ if not buffer:
+ break
+
+ downloaded += len(buffer)
+ f.write(buffer)
+
+ if total_size:
+ self.download_progress = int(downloaded * 100 / total_size)
+ self._downloading_page.set_progress(self.download_progress)
+
+ is_elf = False
+ with open(tmpfile, 'rb') as f:
+ header = f.read(4)
+ is_elf = header == b'\x7fELF'
+
+ if not is_elf:
+ self.download_failed(self.download_url, "No custom software found at this URL.")
+ return
+
+ # AGNOS might try to execute the installer before this process exits.
+ # Therefore, important to close the fd before renaming the installer.
+ os.close(fd)
+ os.rename(tmpfile, INSTALLER_DESTINATION_PATH)
+
+ with open(INSTALLER_URL_PATH, "w") as f:
+ f.write(self.download_url)
+
+ # give time for installer UI to take over
+ time.sleep(0.1)
+ gui_app.request_close()
+
+ except urllib.error.HTTPError as e:
+ if e.code == 409:
+ error_msg = "Incompatible openpilot version"
+ self.download_failed(self.download_url, error_msg)
+ except Exception:
+ error_msg = "Invalid URL"
+ self.download_failed(self.download_url, error_msg)
+
+ def download_failed(self, url: str, reason: str):
+ self.failed_url = url
+ self.failed_reason = reason
+ self._download_failed_page.set_reason(reason)
+ self._set_state(SetupState.DOWNLOAD_FAILED)
+
+
+def main():
+ try:
+ gui_app.init_window("Setup")
+ setup = Setup()
+ for should_render in gui_app.render():
+ if should_render:
+ setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ setup.close()
+ except Exception as e:
+ print(f"Setup error: {e}")
+ finally:
+ gui_app.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/system/ui/mici_updater.py b/system/ui/mici_updater.py
new file mode 100755
index 000000000..2ae2f7cc1
--- /dev/null
+++ b/system/ui/mici_updater.py
@@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+import sys
+import subprocess
+import threading
+import pyray as rl
+from enum import IntEnum
+
+from openpilot.system.hardware import HARDWARE
+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.wifi_manager import WifiManager, Network
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.label import gui_text_box, gui_label, UnifiedLabel
+from openpilot.system.ui.widgets.button import FullRoundedButton
+from openpilot.system.ui.mici_setup import NetworkSetupPage, FailedPage, NetworkConnectivityMonitor
+
+
+class Screen(IntEnum):
+ PROMPT = 0
+ WIFI = 1
+ PROGRESS = 2
+ FAILED = 3
+
+
+class Updater(Widget):
+ def __init__(self, updater_path, manifest_path):
+ super().__init__()
+ self.updater = updater_path
+ self.manifest = manifest_path
+ self.current_screen = Screen.PROMPT
+ self._current_network_strength = -1
+
+ self.progress_value = 0
+ self.progress_text = "loading"
+ self.process = None
+ self.update_thread = None
+ self._wifi_manager = WifiManager()
+ self._wifi_manager.set_active(True)
+
+ self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_callback,
+ self._network_setup_back_callback)
+
+ self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated)
+ self._network_monitor = NetworkConnectivityMonitor()
+ self._network_monitor.start()
+
+ # Buttons
+ self._continue_button = FullRoundedButton("continue")
+ self._continue_button.set_click_callback(lambda: self.set_current_screen(Screen.WIFI))
+
+ self._title_label = UnifiedLabel("update required", 48, text_color=rl.Color(255, 115, 0, 255),
+ font_weight=FontWeight.DISPLAY)
+ self._subtitle_label = UnifiedLabel("The download size is approximately 1GB.", 36,
+ text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ font_weight=FontWeight.ROMAN)
+
+ self._update_failed_page = FailedPage(HARDWARE.reboot, self._update_failed_retry_callback,
+ title="update failed")
+
+ def _network_setup_back_callback(self):
+ self.set_current_screen(Screen.PROMPT)
+
+ def _network_setup_continue_callback(self):
+ self.install_update()
+
+ def _update_failed_retry_callback(self):
+ self.set_current_screen(Screen.PROMPT)
+
+ def _on_network_updated(self, networks: list[Network]):
+ self._current_network_strength = next((net.strength for net in networks if net.is_connected), -1)
+
+ def set_current_screen(self, screen: Screen):
+ if self.current_screen != screen:
+ if screen == Screen.PROGRESS:
+ if self._network_setup_page:
+ self._network_setup_page.hide_event()
+ elif screen == Screen.WIFI:
+ if self._network_setup_page:
+ self._network_setup_page.show_event()
+ elif screen == Screen.PROMPT:
+ if self._network_setup_page:
+ self._network_setup_page.hide_event()
+ elif screen == Screen.FAILED:
+ if self._network_setup_page:
+ self._network_setup_page.hide_event()
+
+ self.current_screen = screen
+
+ def install_update(self):
+ self.set_current_screen(Screen.PROGRESS)
+ self.progress_value = 0
+ self.progress_text = "downloading"
+
+ # Start the update process in a separate thread
+ self.update_thread = threading.Thread(target=self._run_update_process)
+ self.update_thread.daemon = True
+ self.update_thread.start()
+
+ def _run_update_process(self):
+ # TODO: just import it and run in a thread without a subprocess
+ cmd = [self.updater, "--swap", self.manifest]
+ self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ text=True, bufsize=1, universal_newlines=True)
+
+ for line in self.process.stdout:
+ parts = line.strip().split(":")
+ if len(parts) == 2:
+ self.progress_text = parts[0].lower()
+ try:
+ self.progress_value = int(float(parts[1]))
+ except ValueError:
+ pass
+
+ exit_code = self.process.wait()
+ if exit_code == 0:
+ HARDWARE.reboot()
+ else:
+ self.set_current_screen(Screen.FAILED)
+
+ def render_prompt_screen(self, rect: rl.Rectangle):
+ self._title_label.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y - 5,
+ rect.width,
+ 48,
+ ))
+
+ subtitle_width = rect.width - 16
+ subtitle_height = self._subtitle_label.get_content_height(int(subtitle_width))
+ self._subtitle_label.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + 48,
+ subtitle_width,
+ subtitle_height,
+ ))
+
+ self._continue_button.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + rect.height - self._continue_button.rect.height,
+ self._continue_button.rect.width,
+ self._continue_button.rect.height,
+ ))
+
+ def render_progress_screen(self, rect: rl.Rectangle):
+ title_rect = rl.Rectangle(self._rect.x + 6, self._rect.y - 5, self._rect.width - 12, self._rect.height - 8)
+ if ' ' in self.progress_text:
+ font_size = 62
+ else:
+ font_size = 82
+ gui_text_box(title_rect, self.progress_text, font_size, font_weight=FontWeight.DISPLAY,
+ color=rl.Color(255, 255, 255, int(255 * 0.9)))
+
+ progress_value = f"{self.progress_value}%"
+ text_height = measure_text_cached(gui_app.font(FontWeight.ROMAN), progress_value, 128).y
+ progress_rect = rl.Rectangle(self._rect.x + 6, self._rect.y + self._rect.height - text_height + 18,
+ self._rect.width - 12, text_height)
+ gui_label(progress_rect, progress_value, 128, font_weight=FontWeight.ROMAN,
+ color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.35)))
+
+ def _update_state(self):
+ self._wifi_manager.process_callbacks()
+
+ def _render(self, rect: rl.Rectangle):
+ if self.current_screen == Screen.PROMPT:
+ self.render_prompt_screen(rect)
+ elif self.current_screen == Screen.WIFI:
+ self._network_setup_page.set_has_internet(self._network_monitor.network_connected.is_set())
+ self._network_setup_page.render(rect)
+ elif self.current_screen == Screen.PROGRESS:
+ self.render_progress_screen(rect)
+ elif self.current_screen == Screen.FAILED:
+ self._update_failed_page.render(rect)
+
+ def close(self):
+ self._network_monitor.stop()
+
+
+def main():
+ if len(sys.argv) < 3:
+ print("Usage: updater.py ")
+ sys.exit(1)
+
+ updater_path = sys.argv[1]
+ manifest_path = sys.argv[2]
+
+ try:
+ gui_app.init_window("System Update")
+ updater = Updater(updater_path, manifest_path)
+ for should_render in gui_app.render():
+ if should_render:
+ updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ updater.close()
+ except Exception as e:
+ print(f"Updater error: {e}")
+ finally:
+ gui_app.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/system/ui/reset.py b/system/ui/reset.py
index 3922c27aa..c32504a5b 100755
--- a/system/ui/reset.py
+++ b/system/ui/reset.py
@@ -1,135 +1,14 @@
#!/usr/bin/env python3
-import os
-import sys
-import threading
-import time
-from enum import IntEnum
-
-import pyray as rl
-
-from openpilot.system.hardware import PC
-from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
-from openpilot.system.ui.widgets import Widget
-from openpilot.system.ui.widgets.button import Button, ButtonStyle
-from openpilot.system.ui.widgets.label import gui_label, gui_text_box
-
-USERDATA = "/dev/disk/by-partlabel/userdata"
-TIMEOUT = 3*60
-
-
-class ResetMode(IntEnum):
- USER_RESET = 0 # user initiated a factory reset from openpilot
- RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
- FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
-
-
-class ResetState(IntEnum):
- NONE = 0
- CONFIRM = 1
- RESETTING = 2
- FAILED = 3
-
-
-class Reset(Widget):
- def __init__(self, mode):
- super().__init__()
- self._mode = mode
- self._previous_reset_state = None
- self._reset_state = ResetState.NONE
- self._cancel_button = Button("Cancel", self._cancel_callback)
- self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY)
- self._reboot_button = Button("Reboot", lambda: os.system("sudo reboot"))
- self._render_status = True
-
- def _cancel_callback(self):
- self._render_status = False
-
- def _do_erase(self):
- if PC:
- return
-
- # Removing data and formatting
- rm = os.system("sudo rm -rf /data/*")
- os.system(f"sudo umount {USERDATA}")
- fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}")
-
- if rm == 0 or fmt == 0:
- os.system("sudo reboot")
- else:
- self._reset_state = ResetState.FAILED
-
- def start_reset(self):
- self._reset_state = ResetState.RESETTING
- threading.Timer(0.1, self._do_erase).start()
-
- def _update_state(self):
- if self._reset_state != self._previous_reset_state:
- self._previous_reset_state = self._reset_state
- self._timeout_st = time.monotonic()
- elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT:
- exit(0)
-
- def _render(self, rect: rl.Rectangle):
- label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100 * FONT_SCALE)
- gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD)
-
- text_rect = rl.Rectangle(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100 * FONT_SCALE)
- gui_text_box(text_rect, self._get_body_text(), 90)
-
- button_height = 160
- button_spacing = 50
- button_top = rect.y + rect.height - button_height
- button_width = (rect.width - button_spacing) / 2.0
-
- if self._reset_state != ResetState.RESETTING:
- if self._mode == ResetMode.RECOVER:
- self._reboot_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
- elif self._mode == ResetMode.USER_RESET:
- self._cancel_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
-
- if self._reset_state != ResetState.FAILED:
- self._confirm_button.render(rl.Rectangle(rect.x + button_width + 50, button_top, button_width, button_height))
- else:
- self._reboot_button.render(rl.Rectangle(rect.x, button_top, rect.width, button_height))
-
- return self._render_status
-
- def _confirm(self):
- if self._reset_state == ResetState.CONFIRM:
- self.start_reset()
- else:
- self._reset_state = ResetState.CONFIRM
-
- def _get_body_text(self):
- if self._reset_state == ResetState.CONFIRM:
- return "Are you sure you want to reset your device?"
- if self._reset_state == ResetState.RESETTING:
- return "Resetting device...\nThis may take up to a minute."
- if self._reset_state == ResetState.FAILED:
- return "Reset failed. Reboot to try again."
- if self._mode == ResetMode.RECOVER:
- return "Unable to mount data partition. Partition may be corrupted. Press confirm to erase and reset your device."
- return "System reset triggered. Press confirm to erase all content and settings. Press cancel to resume boot."
+from openpilot.system.ui.lib.application import gui_app
+import openpilot.system.ui.tici_reset as tici_reset
+import openpilot.system.ui.mici_reset as mici_reset
def main():
- mode = ResetMode.USER_RESET
- if len(sys.argv) > 1:
- if sys.argv[1] == '--recover':
- mode = ResetMode.RECOVER
- elif sys.argv[1] == "--format":
- mode = ResetMode.FORMAT
-
- gui_app.init_window("System Reset", 20)
- reset = Reset(mode)
-
- if mode == ResetMode.FORMAT:
- reset.start_reset()
-
- for should_render in gui_app.render():
- if should_render:
- if not reset.render(rl.Rectangle(45, 200, gui_app.width - 90, gui_app.height - 245)):
- break
+ if gui_app.big_ui():
+ tici_reset.main()
+ else:
+ mici_reset.main()
if __name__ == "__main__":
diff --git a/system/ui/setup.py b/system/ui/setup.py
index 0045b4541..23ffc26aa 100755
--- a/system/ui/setup.py
+++ b/system/ui/setup.py
@@ -1,450 +1,14 @@
#!/usr/bin/env python3
-import os
-import re
-import threading
-import time
-import urllib.request
-import urllib.error
-from urllib.parse import urlparse
-from enum import IntEnum
-import shutil
-
-import pyray as rl
-
-from cereal import log
-from openpilot.common.utils import run_cmd
-from openpilot.system.hardware import HARDWARE
-from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
-from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
-from openpilot.system.ui.widgets import Widget
-from openpilot.system.ui.widgets.button import Button, ButtonStyle, ButtonRadio
-from openpilot.system.ui.widgets.keyboard import Keyboard
-from openpilot.system.ui.widgets.label import Label
-from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManager
-
-NetworkType = log.DeviceState.NetworkType
-
-MARGIN = 50
-TITLE_FONT_SIZE = 90
-TITLE_FONT_WEIGHT = FontWeight.MEDIUM
-NEXT_BUTTON_WIDTH = 310
-BODY_FONT_SIZE = 80
-BUTTON_HEIGHT = 160
-BUTTON_SPACING = 50
-
-OPENPILOT_URL = "https://openpilot.comma.ai"
-USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
-
-CONTINUE_PATH = "/data/continue.sh"
-TMP_CONTINUE_PATH = "/data/continue.sh.new"
-INSTALL_PATH = "/data/openpilot"
-VALID_CACHE_PATH = "/data/.openpilot_cache"
-INSTALLER_SOURCE_PATH = "/usr/comma/installer"
-INSTALLER_DESTINATION_PATH = "/tmp/installer"
-INSTALLER_URL_PATH = "/tmp/installer_url"
-
-CONTINUE = """#!/usr/bin/env bash
-
-cd /data/openpilot
-exec ./launch_openpilot.sh
-"""
-
-
-class SetupState(IntEnum):
- LOW_VOLTAGE = 0
- GETTING_STARTED = 1
- NETWORK_SETUP = 2
- SOFTWARE_SELECTION = 3
- CUSTOM_SOFTWARE = 4
- DOWNLOADING = 5
- DOWNLOAD_FAILED = 6
- CUSTOM_SOFTWARE_WARNING = 7
-
-
-class Setup(Widget):
- def __init__(self):
- super().__init__()
- self.state = SetupState.GETTING_STARTED
- self.network_check_thread = None
- self.network_connected = threading.Event()
- self.wifi_connected = threading.Event()
- self.stop_network_check_thread = threading.Event()
- self.failed_url = ""
- self.failed_reason = ""
- self.download_url = ""
- self.download_progress = 0
- self.download_thread = None
- self.wifi_ui = WifiManagerUI(WifiManager())
- self.keyboard = Keyboard()
- self.selected_radio = None
- self.warning = gui_app.texture("icons/warning.png", 150, 150)
- self.checkmark = gui_app.texture("icons/circled_check.png", 100, 100)
-
- self._low_voltage_title_label = Label("WARNING: Low Voltage", TITLE_FONT_SIZE, FontWeight.MEDIUM, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
- text_color=rl.Color(255, 89, 79, 255), text_padding=20)
- self._low_voltage_body_label = Label("Power your device in a car with a harness or proceed at your own risk.", BODY_FONT_SIZE,
- text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
- self._low_voltage_continue_button = Button("Continue", self._low_voltage_continue_button_callback)
- self._low_voltage_poweroff_button = Button("Power Off", HARDWARE.shutdown)
-
- self._getting_started_button = Button("", self._getting_started_button_callback, button_style=ButtonStyle.PRIMARY, border_radius=0)
- self._getting_started_title_label = Label("Getting Started", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
- self._getting_started_body_label = Label("Before we get on the road, let's finish installation and cover some details.",
- BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
-
- self._software_selection_openpilot_button = ButtonRadio("openpilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
- self._software_selection_custom_software_button = ButtonRadio("Custom Software", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
- self._software_selection_continue_button = Button("Continue", self._software_selection_continue_button_callback,
- button_style=ButtonStyle.PRIMARY)
- self._software_selection_continue_button.set_enabled(False)
- self._software_selection_back_button = Button("Back", self._software_selection_back_button_callback)
- self._software_selection_title_label = Label("Choose Software to Use", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
- text_padding=20)
-
- self._download_failed_reboot_button = Button("Reboot device", HARDWARE.reboot)
- self._download_failed_startover_button = Button("Start over", self._download_failed_startover_button_callback, button_style=ButtonStyle.PRIMARY)
- self._download_failed_title_label = Label("Download Failed", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
- self._download_failed_url_label = Label("", 52, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
- self._download_failed_body_label = Label("", BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
-
- self._network_setup_back_button = Button("Back", self._network_setup_back_button_callback)
- self._network_setup_continue_button = Button("Waiting for internet", self._network_setup_continue_button_callback,
- button_style=ButtonStyle.PRIMARY)
- self._network_setup_continue_button.set_enabled(False)
- self._network_setup_title_label = Label("Connect to Wi-Fi", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
-
- self._custom_software_warning_continue_button = Button("Scroll to continue", self._custom_software_warning_continue_button_callback,
- button_style=ButtonStyle.PRIMARY)
- self._custom_software_warning_continue_button.set_enabled(False)
- self._custom_software_warning_back_button = Button("Back", self._custom_software_warning_back_button_callback)
- self._custom_software_warning_title_label = Label("WARNING: Custom Software", 81, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
- text_color=rl.Color(255, 89, 79, 255),
- text_padding=60)
- self._custom_software_warning_body_label = Label("Use caution when installing third-party software.\n\n"
- + "⚠️ It has not been tested by comma.\n\n"
- + "⚠️ It may not comply with relevant safety standards.\n\n"
- + "⚠️ It may cause damage to your device and/or vehicle.\n\n"
- + "If you'd like to proceed, use https://flash.comma.ai "
- + "to restore your device to a factory state later.",
- 68, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=60)
- self._custom_software_warning_body_scroll_panel = GuiScrollPanel()
-
- self._downloading_body_label = Label("Downloading...", TITLE_FONT_SIZE, FontWeight.MEDIUM, text_padding=20)
-
- try:
- with open("/sys/class/hwmon/hwmon1/in1_input") as f:
- voltage = float(f.read().strip()) / 1000.0
- if voltage < 7:
- self.state = SetupState.LOW_VOLTAGE
- except (FileNotFoundError, ValueError):
- self.state = SetupState.LOW_VOLTAGE
-
- def _render(self, rect: rl.Rectangle):
- if self.state == SetupState.LOW_VOLTAGE:
- self.render_low_voltage(rect)
- elif self.state == SetupState.GETTING_STARTED:
- self.render_getting_started(rect)
- elif self.state == SetupState.NETWORK_SETUP:
- self.render_network_setup(rect)
- elif self.state == SetupState.SOFTWARE_SELECTION:
- self.render_software_selection(rect)
- elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
- self.render_custom_software_warning(rect)
- elif self.state == SetupState.CUSTOM_SOFTWARE:
- self.render_custom_software()
- elif self.state == SetupState.DOWNLOADING:
- self.render_downloading(rect)
- elif self.state == SetupState.DOWNLOAD_FAILED:
- self.render_download_failed(rect)
-
- def _low_voltage_continue_button_callback(self):
- self.state = SetupState.GETTING_STARTED
-
- def _custom_software_warning_back_button_callback(self):
- self.state = SetupState.SOFTWARE_SELECTION
-
- def _custom_software_warning_continue_button_callback(self):
- self.state = SetupState.NETWORK_SETUP
- self.stop_network_check_thread.clear()
- self.start_network_check()
-
- def _getting_started_button_callback(self):
- self.state = SetupState.SOFTWARE_SELECTION
-
- def _software_selection_back_button_callback(self):
- self.state = SetupState.GETTING_STARTED
-
- def _software_selection_continue_button_callback(self):
- if self._software_selection_openpilot_button.selected:
- self.use_openpilot()
- else:
- self.state = SetupState.CUSTOM_SOFTWARE_WARNING
-
- def _download_failed_startover_button_callback(self):
- self.state = SetupState.GETTING_STARTED
-
- def _network_setup_back_button_callback(self):
- self.state = SetupState.SOFTWARE_SELECTION
-
- def _network_setup_continue_button_callback(self):
- self.stop_network_check_thread.set()
- if self._software_selection_openpilot_button.selected:
- self.download(OPENPILOT_URL)
- else:
- self.state = SetupState.CUSTOM_SOFTWARE
-
- def render_low_voltage(self, rect: rl.Rectangle):
- rl.draw_texture(self.warning, int(rect.x + 150), int(rect.y + 110), rl.WHITE)
-
- self._low_voltage_title_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100, rect.width - 500 - 150, TITLE_FONT_SIZE * FONT_SCALE))
- self._low_voltage_body_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 150, rect.width - 500, BODY_FONT_SIZE * FONT_SCALE * 3))
-
- button_width = (rect.width - MARGIN * 3) / 2
- button_y = rect.height - MARGIN - BUTTON_HEIGHT
- self._low_voltage_poweroff_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
- self._low_voltage_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT))
-
- def render_getting_started(self, rect: rl.Rectangle):
- self._getting_started_title_label.render(rl.Rectangle(rect.x + 165, rect.y + 280, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE))
- self._getting_started_body_label.render(rl.Rectangle(rect.x + 165, rect.y + 280 + TITLE_FONT_SIZE * FONT_SCALE, rect.width - 500,
- BODY_FONT_SIZE * FONT_SCALE * 3))
-
- btn_rect = rl.Rectangle(rect.width - NEXT_BUTTON_WIDTH, 0, NEXT_BUTTON_WIDTH, rect.height)
- self._getting_started_button.render(btn_rect)
- triangle = gui_app.texture("images/button_continue_triangle.png", 54, int(btn_rect.height))
- rl.draw_texture_v(triangle, rl.Vector2(btn_rect.x + btn_rect.width / 2 - triangle.width / 2, btn_rect.height / 2 - triangle.height / 2), rl.WHITE)
-
- def check_network_connectivity(self):
- while not self.stop_network_check_thread.is_set():
- if self.state == SetupState.NETWORK_SETUP:
- try:
- urllib.request.urlopen(OPENPILOT_URL, timeout=2)
- self.network_connected.set()
- if HARDWARE.get_network_type() == NetworkType.wifi:
- self.wifi_connected.set()
- else:
- self.wifi_connected.clear()
- except Exception:
- self.network_connected.clear()
- time.sleep(1)
-
- def start_network_check(self):
- if self.network_check_thread is None or not self.network_check_thread.is_alive():
- self.network_check_thread = threading.Thread(target=self.check_network_connectivity, daemon=True)
- self.network_check_thread.start()
-
- def close(self):
- if self.network_check_thread is not None:
- self.stop_network_check_thread.set()
- self.network_check_thread.join()
-
- def render_network_setup(self, rect: rl.Rectangle):
- self._network_setup_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE))
-
- wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN + 25, rect.width - MARGIN * 2,
- rect.height - TITLE_FONT_SIZE * FONT_SCALE - 25 - BUTTON_HEIGHT - MARGIN * 3)
- rl.draw_rectangle_rounded(wifi_rect, 0.05, 10, rl.Color(51, 51, 51, 255))
- wifi_content_rect = rl.Rectangle(wifi_rect.x + MARGIN, wifi_rect.y, wifi_rect.width - MARGIN * 2, wifi_rect.height)
- self.wifi_ui.render(wifi_content_rect)
-
- button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
- button_y = rect.height - BUTTON_HEIGHT - MARGIN
-
- self._network_setup_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
-
- # Check network connectivity status
- continue_enabled = self.network_connected.is_set()
- self._network_setup_continue_button.set_enabled(continue_enabled)
- continue_text = ("Continue" if self.wifi_connected.is_set() else "Continue without Wi-Fi") if continue_enabled else "Waiting for internet"
- self._network_setup_continue_button.set_text(continue_text)
- self._network_setup_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
-
- def render_software_selection(self, rect: rl.Rectangle):
- self._software_selection_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE))
-
- radio_height = 230
- radio_spacing = 30
-
- self._software_selection_continue_button.set_enabled(False)
-
- openpilot_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2, rect.width - MARGIN * 2, radio_height)
- self._software_selection_openpilot_button.render(openpilot_rect)
-
- if self._software_selection_openpilot_button.selected:
- self._software_selection_continue_button.set_enabled(True)
- self._software_selection_custom_software_button.selected = False
-
- custom_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2 + radio_height + radio_spacing, rect.width - MARGIN * 2,
- radio_height)
- self._software_selection_custom_software_button.render(custom_rect)
-
- if self._software_selection_custom_software_button.selected:
- self._software_selection_continue_button.set_enabled(True)
- self._software_selection_openpilot_button.selected = False
-
- button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
- button_y = rect.height - BUTTON_HEIGHT - MARGIN
-
- self._software_selection_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
- self._software_selection_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
-
- def render_downloading(self, rect: rl.Rectangle):
- self._downloading_body_label.render(rl.Rectangle(rect.x, rect.y + rect.height / 2 - TITLE_FONT_SIZE * FONT_SCALE / 2, rect.width,
- TITLE_FONT_SIZE * FONT_SCALE))
-
- def render_download_failed(self, rect: rl.Rectangle):
- self._download_failed_title_label.render(rl.Rectangle(rect.x + 117, rect.y + 185, rect.width - 117, TITLE_FONT_SIZE * FONT_SCALE))
- self._download_failed_url_label.set_text(self.failed_url)
- self._download_failed_url_label.render(rl.Rectangle(rect.x + 117, rect.y + 185 + TITLE_FONT_SIZE * FONT_SCALE + 67, rect.width - 117 - 100, 64))
-
- self._download_failed_body_label.set_text(self.failed_reason)
- self._download_failed_body_label.render(rl.Rectangle(rect.x + 117, rect.y, rect.width - 117 - 100, rect.height))
-
- button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
- button_y = rect.height - BUTTON_HEIGHT - MARGIN
- self._download_failed_reboot_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
- self._download_failed_startover_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
-
- def render_custom_software_warning(self, rect: rl.Rectangle):
- warn_rect = rl.Rectangle(rect.x, rect.y, rect.width, 1500)
- offset = self._custom_software_warning_body_scroll_panel.update(rect, warn_rect)
-
- button_width = (rect.width - MARGIN * 3) / 2
- button_y = rect.height - MARGIN - BUTTON_HEIGHT
-
- rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(button_y - BODY_FONT_SIZE * FONT_SCALE))
- y_offset = rect.y + offset
- self._custom_software_warning_title_label.render(rl.Rectangle(rect.x + 50, y_offset + 150, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE))
- self._custom_software_warning_body_label.render(rl.Rectangle(rect.x + 50, y_offset + 200, rect.width - 50, BODY_FONT_SIZE * FONT_SCALE * 3))
- rl.end_scissor_mode()
-
- self._custom_software_warning_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
- self._custom_software_warning_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT))
- if offset < (rect.height - warn_rect.height):
- self._custom_software_warning_continue_button.set_enabled(True)
- self._custom_software_warning_continue_button.set_text("Continue")
-
- def render_custom_software(self):
- def handle_keyboard_result(result):
- # Enter pressed
- if result == 1:
- url = self.keyboard.text
- self.keyboard.clear()
- if url:
- self.download(url)
-
- # Cancel pressed
- elif result == 0:
- self.state = SetupState.SOFTWARE_SELECTION
-
- self.keyboard.reset(min_text_size=1)
- self.keyboard.set_title("Enter URL", "for Custom Software")
- gui_app.set_modal_overlay(self.keyboard, callback=handle_keyboard_result)
-
- def use_openpilot(self):
- if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH):
- os.remove(VALID_CACHE_PATH)
- with open(TMP_CONTINUE_PATH, "w") as f:
- f.write(CONTINUE)
- run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
- shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
- shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
-
- # give time for installer UI to take over
- time.sleep(0.1)
- gui_app.request_close()
- else:
- self.state = SetupState.NETWORK_SETUP
- self.stop_network_check_thread.clear()
- self.start_network_check()
-
- def download(self, url: str):
- # autocomplete incomplete URLs
- if re.match("^([^/.]+)/([^/]+)$", url):
- url = f"https://installer.comma.ai/{url}"
-
- parsed = urlparse(url, scheme='https')
- self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl()
-
- self.state = SetupState.DOWNLOADING
-
- self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
- self.download_thread.start()
-
- def _download_thread(self):
- try:
- import tempfile
-
- fd, tmpfile = tempfile.mkstemp(prefix="installer_")
-
- headers = {"User-Agent": USER_AGENT,
- "X-openpilot-serial": HARDWARE.get_serial(),
- "X-openpilot-device-type": HARDWARE.get_device_type()}
- req = urllib.request.Request(self.download_url, headers=headers)
-
- with open(tmpfile, 'wb') as f, urllib.request.urlopen(req, timeout=30) as response:
- total_size = int(response.headers.get('content-length', 0))
- downloaded = 0
- block_size = 8192
-
- while True:
- buffer = response.read(block_size)
- if not buffer:
- break
-
- downloaded += len(buffer)
- f.write(buffer)
-
- if total_size:
- self.download_progress = int(downloaded * 100 / total_size)
-
- is_elf = False
- with open(tmpfile, 'rb') as f:
- header = f.read(4)
- is_elf = header == b'\x7fELF'
-
- if not is_elf:
- self.download_failed(self.download_url, "No custom software found at this URL.")
- return
-
- # AGNOS might try to execute the installer before this process exits.
- # Therefore, important to close the fd before renaming the installer.
- os.close(fd)
- os.rename(tmpfile, INSTALLER_DESTINATION_PATH)
-
- with open(INSTALLER_URL_PATH, "w") as f:
- f.write(self.download_url)
-
- # give time for installer UI to take over
- time.sleep(0.1)
- gui_app.request_close()
-
- except urllib.error.HTTPError as e:
- if e.code == 409:
- error_msg = e.read().decode("utf-8")
- self.download_failed(self.download_url, error_msg)
- except Exception:
- error_msg = "Ensure the entered URL is valid, and the device's internet connection is good."
- self.download_failed(self.download_url, error_msg)
-
- def download_failed(self, url: str, reason: str):
- self.failed_url = url
- self.failed_reason = reason
- self.state = SetupState.DOWNLOAD_FAILED
+from openpilot.system.ui.lib.application import gui_app
+import openpilot.system.ui.tici_setup as tici_setup
+import openpilot.system.ui.mici_setup as mici_setup
def main():
- try:
- gui_app.init_window("Setup", 20)
- setup = Setup()
- for should_render in gui_app.render():
- if should_render:
- setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
- setup.close()
- except Exception as e:
- print(f"Setup error: {e}")
- finally:
- gui_app.close()
+ if gui_app.big_ui():
+ tici_setup.main()
+ else:
+ mici_setup.main()
if __name__ == "__main__":
diff --git a/system/ui/spinner.py b/system/ui/spinner.py
index eb33f0834..2a48b3889 100755
--- a/system/ui/spinner.py
+++ b/system/ui/spinner.py
@@ -9,11 +9,20 @@ from openpilot.system.ui.text import wrap_text
from openpilot.system.ui.widgets import Widget
# Constants
-PROGRESS_BAR_WIDTH = 1000
-PROGRESS_BAR_HEIGHT = 20
+if gui_app.big_ui():
+ PROGRESS_BAR_WIDTH = 1000
+ PROGRESS_BAR_HEIGHT = 20
+ TEXTURE_SIZE = 360
+ WRAPPED_SPACING = 50
+ CENTERED_SPACING = 150
+else:
+ PROGRESS_BAR_WIDTH = 268
+ PROGRESS_BAR_HEIGHT = 10
+ TEXTURE_SIZE = 140
+ WRAPPED_SPACING = 10
+ CENTERED_SPACING = 20
DEGREES_PER_SECOND = 360.0 # one full rotation per second
MARGIN_H = 100
-TEXTURE_SIZE = 360
FONT_SIZE = 96
LINE_HEIGHT = 104
DARKGRAY = (55, 55, 55, 255)
@@ -43,12 +52,12 @@ class Spinner(Widget):
def _render(self, rect: rl.Rectangle):
if self._wrapped_lines:
# Calculate total height required for spinner and text
- spacing = 50
+ spacing = WRAPPED_SPACING
total_height = TEXTURE_SIZE + spacing + len(self._wrapped_lines) * LINE_HEIGHT
center_y = (rect.height - total_height) / 2.0 + TEXTURE_SIZE / 2.0
else:
# Center spinner vertically
- spacing = 150
+ spacing = CENTERED_SPACING
center_y = rect.height / 2.0
y_pos = center_y + TEXTURE_SIZE / 2.0 + spacing
diff --git a/system/ui/text.py b/system/ui/text.py
index 707b30983..17e8a507c 100755
--- a/system/ui/text.py
+++ b/system/ui/text.py
@@ -3,17 +3,24 @@ import re
import sys
import pyray as rl
from openpilot.system.hardware import HARDWARE, PC
-from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.lib.application import BIG_UI, gui_app
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
-MARGIN = 50
-SPACING = 40
-FONT_SIZE = 72
-LINE_HEIGHT = 80
-BUTTON_SIZE = rl.Vector2(310, 160)
+if BIG_UI:
+ MARGIN = 50
+ SPACING = 40
+ FONT_SIZE = 72
+ LINE_HEIGHT = 80
+ BUTTON_SIZE = rl.Vector2(310, 160)
+else:
+ MARGIN = 20
+ SPACING = 30
+ FONT_SIZE = 25
+ LINE_HEIGHT = 25
+ BUTTON_SIZE = rl.Vector2(150, 80)
DEMO_TEXT = """This is a sample text that will be wrapped and scrolled if necessary.
The text is long enough to demonstrate scrolling and word wrapping.""" * 30
@@ -31,7 +38,7 @@ def wrap_text(text, font_size, max_width):
continue
indent = re.match(r"^\s*", paragraph).group()
current_line = indent
- words = re.split(r"(\s+)", paragraph[len(indent):])
+ words = re.split(r"(\s+|-)", paragraph[len(indent):])
while len(words):
word = words.pop(0)
test_line = current_line + word + (words.pop(0) if words else "")
@@ -57,7 +64,7 @@ class TextWindow(Widget):
self._scroll_panel._offset_filter_y.x = -max(self._content_rect.height - self._textarea_rect.height, 0)
button_text = "Exit" if PC else "Reboot"
- self._button = Button(button_text, click_callback=self._on_button_clicked, button_style=ButtonStyle.TRANSPARENT_WHITE_BORDER)
+ self._button = Button(button_text, click_callback=self._on_button_clicked, button_style=ButtonStyle.TRANSPARENT_WHITE_BORDER, font_size=FONT_SIZE)
@staticmethod
def _on_button_clicked():
diff --git a/system/ui/tici_reset.py b/system/ui/tici_reset.py
new file mode 100755
index 000000000..3922c27aa
--- /dev/null
+++ b/system/ui/tici_reset.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+import os
+import sys
+import threading
+import time
+from enum import IntEnum
+
+import pyray as rl
+
+from openpilot.system.hardware import PC
+from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.button import Button, ButtonStyle
+from openpilot.system.ui.widgets.label import gui_label, gui_text_box
+
+USERDATA = "/dev/disk/by-partlabel/userdata"
+TIMEOUT = 3*60
+
+
+class ResetMode(IntEnum):
+ USER_RESET = 0 # user initiated a factory reset from openpilot
+ RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
+ FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
+
+
+class ResetState(IntEnum):
+ NONE = 0
+ CONFIRM = 1
+ RESETTING = 2
+ FAILED = 3
+
+
+class Reset(Widget):
+ def __init__(self, mode):
+ super().__init__()
+ self._mode = mode
+ self._previous_reset_state = None
+ self._reset_state = ResetState.NONE
+ self._cancel_button = Button("Cancel", self._cancel_callback)
+ self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY)
+ self._reboot_button = Button("Reboot", lambda: os.system("sudo reboot"))
+ self._render_status = True
+
+ def _cancel_callback(self):
+ self._render_status = False
+
+ def _do_erase(self):
+ if PC:
+ return
+
+ # Removing data and formatting
+ rm = os.system("sudo rm -rf /data/*")
+ os.system(f"sudo umount {USERDATA}")
+ fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}")
+
+ if rm == 0 or fmt == 0:
+ os.system("sudo reboot")
+ else:
+ self._reset_state = ResetState.FAILED
+
+ def start_reset(self):
+ self._reset_state = ResetState.RESETTING
+ threading.Timer(0.1, self._do_erase).start()
+
+ def _update_state(self):
+ if self._reset_state != self._previous_reset_state:
+ self._previous_reset_state = self._reset_state
+ self._timeout_st = time.monotonic()
+ elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT:
+ exit(0)
+
+ def _render(self, rect: rl.Rectangle):
+ label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100 * FONT_SCALE)
+ gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD)
+
+ text_rect = rl.Rectangle(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100 * FONT_SCALE)
+ gui_text_box(text_rect, self._get_body_text(), 90)
+
+ button_height = 160
+ button_spacing = 50
+ button_top = rect.y + rect.height - button_height
+ button_width = (rect.width - button_spacing) / 2.0
+
+ if self._reset_state != ResetState.RESETTING:
+ if self._mode == ResetMode.RECOVER:
+ self._reboot_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
+ elif self._mode == ResetMode.USER_RESET:
+ self._cancel_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
+
+ if self._reset_state != ResetState.FAILED:
+ self._confirm_button.render(rl.Rectangle(rect.x + button_width + 50, button_top, button_width, button_height))
+ else:
+ self._reboot_button.render(rl.Rectangle(rect.x, button_top, rect.width, button_height))
+
+ return self._render_status
+
+ def _confirm(self):
+ if self._reset_state == ResetState.CONFIRM:
+ self.start_reset()
+ else:
+ self._reset_state = ResetState.CONFIRM
+
+ def _get_body_text(self):
+ if self._reset_state == ResetState.CONFIRM:
+ return "Are you sure you want to reset your device?"
+ if self._reset_state == ResetState.RESETTING:
+ return "Resetting device...\nThis may take up to a minute."
+ if self._reset_state == ResetState.FAILED:
+ return "Reset failed. Reboot to try again."
+ if self._mode == ResetMode.RECOVER:
+ return "Unable to mount data partition. Partition may be corrupted. Press confirm to erase and reset your device."
+ return "System reset triggered. Press confirm to erase all content and settings. Press cancel to resume boot."
+
+
+def main():
+ mode = ResetMode.USER_RESET
+ if len(sys.argv) > 1:
+ if sys.argv[1] == '--recover':
+ mode = ResetMode.RECOVER
+ elif sys.argv[1] == "--format":
+ mode = ResetMode.FORMAT
+
+ gui_app.init_window("System Reset", 20)
+ reset = Reset(mode)
+
+ if mode == ResetMode.FORMAT:
+ reset.start_reset()
+
+ for should_render in gui_app.render():
+ if should_render:
+ if not reset.render(rl.Rectangle(45, 200, gui_app.width - 90, gui_app.height - 245)):
+ break
+
+
+if __name__ == "__main__":
+ main()
diff --git a/system/ui/tici_setup.py b/system/ui/tici_setup.py
new file mode 100755
index 000000000..bf64361be
--- /dev/null
+++ b/system/ui/tici_setup.py
@@ -0,0 +1,451 @@
+#!/usr/bin/env python3
+import os
+import re
+import threading
+import time
+import urllib.request
+import urllib.error
+from urllib.parse import urlparse
+from enum import IntEnum
+import shutil
+
+import pyray as rl
+
+from cereal import log
+from openpilot.common.utils import run_cmd
+from openpilot.system.hardware import HARDWARE
+from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
+from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.button import Button, ButtonStyle, ButtonRadio
+from openpilot.system.ui.widgets.keyboard import Keyboard
+from openpilot.system.ui.widgets.label import Label
+from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManager
+
+NetworkType = log.DeviceState.NetworkType
+
+MARGIN = 50
+TITLE_FONT_SIZE = 90
+TITLE_FONT_WEIGHT = FontWeight.MEDIUM
+NEXT_BUTTON_WIDTH = 310
+BODY_FONT_SIZE = 80
+BUTTON_HEIGHT = 160
+BUTTON_SPACING = 50
+
+OPENPILOT_URL = "https://openpilot.comma.ai"
+USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
+
+CONTINUE_PATH = "/data/continue.sh"
+TMP_CONTINUE_PATH = "/data/continue.sh.new"
+INSTALL_PATH = "/data/openpilot"
+VALID_CACHE_PATH = "/data/.openpilot_cache"
+INSTALLER_SOURCE_PATH = "/usr/comma/installer"
+INSTALLER_DESTINATION_PATH = "/tmp/installer"
+INSTALLER_URL_PATH = "/tmp/installer_url"
+
+CONTINUE = """#!/usr/bin/env bash
+
+cd /data/openpilot
+exec ./launch_openpilot.sh
+"""
+
+
+class SetupState(IntEnum):
+ LOW_VOLTAGE = 0
+ GETTING_STARTED = 1
+ NETWORK_SETUP = 2
+ SOFTWARE_SELECTION = 3
+ CUSTOM_SOFTWARE = 4
+ DOWNLOADING = 5
+ DOWNLOAD_FAILED = 6
+ CUSTOM_SOFTWARE_WARNING = 7
+
+
+class Setup(Widget):
+ def __init__(self):
+ super().__init__()
+ self.state = SetupState.GETTING_STARTED
+ self.network_check_thread = None
+ self.network_connected = threading.Event()
+ self.wifi_connected = threading.Event()
+ self.stop_network_check_thread = threading.Event()
+ self.failed_url = ""
+ self.failed_reason = ""
+ self.download_url = ""
+ self.download_progress = 0
+ self.download_thread = None
+ self.wifi_ui = WifiManagerUI(WifiManager())
+ self.keyboard = Keyboard()
+ self.selected_radio = None
+ self.warning = gui_app.texture("icons/warning.png", 150, 150)
+ self.checkmark = gui_app.texture("icons/circled_check.png", 100, 100)
+
+ self._low_voltage_title_label = Label("WARNING: Low Voltage", TITLE_FONT_SIZE, FontWeight.MEDIUM, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ text_color=rl.Color(255, 89, 79, 255), text_padding=20)
+ self._low_voltage_body_label = Label("Power your device in a car with a harness or proceed at your own risk.", BODY_FONT_SIZE,
+ text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
+ self._low_voltage_continue_button = Button("Continue", self._low_voltage_continue_button_callback)
+ self._low_voltage_poweroff_button = Button("Power Off", HARDWARE.shutdown)
+
+ self._getting_started_button = Button("", self._getting_started_button_callback, button_style=ButtonStyle.PRIMARY, border_radius=0)
+ self._getting_started_title_label = Label("Getting Started", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
+ self._getting_started_body_label = Label("Before we get on the road, let's finish installation and cover some details.",
+ BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
+
+ self._software_selection_openpilot_button = ButtonRadio("openpilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
+ self._software_selection_custom_software_button = ButtonRadio("Custom Software", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
+ self._software_selection_continue_button = Button("Continue", self._software_selection_continue_button_callback,
+ button_style=ButtonStyle.PRIMARY)
+ self._software_selection_continue_button.set_enabled(False)
+ self._software_selection_back_button = Button("Back", self._software_selection_back_button_callback)
+ self._software_selection_title_label = Label("Choose Software to Use", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ text_padding=20)
+
+ self._download_failed_reboot_button = Button("Reboot device", HARDWARE.reboot)
+ self._download_failed_startover_button = Button("Start over", self._download_failed_startover_button_callback, button_style=ButtonStyle.PRIMARY)
+ self._download_failed_title_label = Label("Download Failed", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
+ self._download_failed_url_label = Label("", 52, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
+ self._download_failed_body_label = Label("", BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
+
+ self._network_setup_back_button = Button("Back", self._network_setup_back_button_callback)
+ self._network_setup_continue_button = Button("Waiting for internet", self._network_setup_continue_button_callback,
+ button_style=ButtonStyle.PRIMARY)
+ self._network_setup_continue_button.set_enabled(False)
+ self._network_setup_title_label = Label("Connect to Wi-Fi", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
+
+ self._custom_software_warning_continue_button = Button("Scroll to continue", self._custom_software_warning_continue_button_callback,
+ button_style=ButtonStyle.PRIMARY)
+ self._custom_software_warning_continue_button.set_enabled(False)
+ self._custom_software_warning_back_button = Button("Back", self._custom_software_warning_back_button_callback)
+ self._custom_software_warning_title_label = Label("WARNING: Custom Software", 81, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ text_color=rl.Color(255, 89, 79, 255),
+ text_padding=60)
+ self._custom_software_warning_body_label = Label("Use caution when installing third-party software.\n\n"
+ + "⚠️ It has not been tested by comma.\n\n"
+ + "⚠️ It may not comply with relevant safety standards.\n\n"
+ + "⚠️ It may cause damage to your device and/or vehicle.\n\n"
+ + "If you'd like to proceed, use https://flash.comma.ai "
+ + "to restore your device to a factory state later.",
+ 68, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=60)
+ self._custom_software_warning_body_scroll_panel = GuiScrollPanel()
+
+ self._downloading_body_label = Label("Downloading...", TITLE_FONT_SIZE, FontWeight.MEDIUM, text_padding=20)
+
+ try:
+ with open("/sys/class/hwmon/hwmon1/in1_input") as f:
+ voltage = float(f.read().strip()) / 1000.0
+ if voltage < 7:
+ self.state = SetupState.LOW_VOLTAGE
+ except (FileNotFoundError, ValueError):
+ self.state = SetupState.LOW_VOLTAGE
+
+ def _render(self, rect: rl.Rectangle):
+ if self.state == SetupState.LOW_VOLTAGE:
+ self.render_low_voltage(rect)
+ elif self.state == SetupState.GETTING_STARTED:
+ self.render_getting_started(rect)
+ elif self.state == SetupState.NETWORK_SETUP:
+ self.render_network_setup(rect)
+ elif self.state == SetupState.SOFTWARE_SELECTION:
+ self.render_software_selection(rect)
+ elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
+ self.render_custom_software_warning(rect)
+ elif self.state == SetupState.CUSTOM_SOFTWARE:
+ self.render_custom_software()
+ elif self.state == SetupState.DOWNLOADING:
+ self.render_downloading(rect)
+ elif self.state == SetupState.DOWNLOAD_FAILED:
+ self.render_download_failed(rect)
+
+ def _low_voltage_continue_button_callback(self):
+ self.state = SetupState.GETTING_STARTED
+
+ def _custom_software_warning_back_button_callback(self):
+ self.state = SetupState.SOFTWARE_SELECTION
+
+ def _custom_software_warning_continue_button_callback(self):
+ self.state = SetupState.NETWORK_SETUP
+ self.stop_network_check_thread.clear()
+ self.start_network_check()
+
+ def _getting_started_button_callback(self):
+ self.state = SetupState.SOFTWARE_SELECTION
+
+ def _software_selection_back_button_callback(self):
+ self.state = SetupState.GETTING_STARTED
+
+ def _software_selection_continue_button_callback(self):
+ if self._software_selection_openpilot_button.selected:
+ self.use_openpilot()
+ else:
+ self.state = SetupState.CUSTOM_SOFTWARE_WARNING
+
+ def _download_failed_startover_button_callback(self):
+ self.state = SetupState.GETTING_STARTED
+
+ def _network_setup_back_button_callback(self):
+ self.state = SetupState.SOFTWARE_SELECTION
+
+ def _network_setup_continue_button_callback(self):
+ self.stop_network_check_thread.set()
+ if self._software_selection_openpilot_button.selected:
+ self.download(OPENPILOT_URL)
+ else:
+ self.state = SetupState.CUSTOM_SOFTWARE
+
+ def render_low_voltage(self, rect: rl.Rectangle):
+ rl.draw_texture(self.warning, int(rect.x + 150), int(rect.y + 110), rl.WHITE)
+
+ self._low_voltage_title_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100, rect.width - 500 - 150, TITLE_FONT_SIZE * FONT_SCALE))
+ self._low_voltage_body_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 150, rect.width - 500, BODY_FONT_SIZE * FONT_SCALE * 3))
+
+ button_width = (rect.width - MARGIN * 3) / 2
+ button_y = rect.height - MARGIN - BUTTON_HEIGHT
+ self._low_voltage_poweroff_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
+ self._low_voltage_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT))
+
+ def render_getting_started(self, rect: rl.Rectangle):
+ self._getting_started_title_label.render(rl.Rectangle(rect.x + 165, rect.y + 280, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE))
+ self._getting_started_body_label.render(rl.Rectangle(rect.x + 165, rect.y + 280 + TITLE_FONT_SIZE * FONT_SCALE, rect.width - 500,
+ BODY_FONT_SIZE * FONT_SCALE * 3))
+
+ btn_rect = rl.Rectangle(rect.width - NEXT_BUTTON_WIDTH, 0, NEXT_BUTTON_WIDTH, rect.height)
+ self._getting_started_button.render(btn_rect)
+ triangle = gui_app.texture("images/button_continue_triangle.png", 54, int(btn_rect.height))
+ rl.draw_texture_v(triangle, rl.Vector2(btn_rect.x + btn_rect.width / 2 - triangle.width / 2, btn_rect.height / 2 - triangle.height / 2), rl.WHITE)
+
+ def check_network_connectivity(self):
+ while not self.stop_network_check_thread.is_set():
+ if self.state == SetupState.NETWORK_SETUP:
+ try:
+ urllib.request.urlopen(OPENPILOT_URL, timeout=2)
+ self.network_connected.set()
+ if HARDWARE.get_network_type() == NetworkType.wifi:
+ self.wifi_connected.set()
+ else:
+ self.wifi_connected.clear()
+ except Exception:
+ self.network_connected.clear()
+ time.sleep(1)
+
+ def start_network_check(self):
+ if self.network_check_thread is None or not self.network_check_thread.is_alive():
+ self.network_check_thread = threading.Thread(target=self.check_network_connectivity, daemon=True)
+ self.network_check_thread.start()
+
+ def close(self):
+ if self.network_check_thread is not None:
+ self.stop_network_check_thread.set()
+ self.network_check_thread.join()
+
+ def render_network_setup(self, rect: rl.Rectangle):
+ self._network_setup_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE))
+
+ wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN + 25, rect.width - MARGIN * 2,
+ rect.height - TITLE_FONT_SIZE * FONT_SCALE - 25 - BUTTON_HEIGHT - MARGIN * 3)
+ rl.draw_rectangle_rounded(wifi_rect, 0.05, 10, rl.Color(51, 51, 51, 255))
+ wifi_content_rect = rl.Rectangle(wifi_rect.x + MARGIN, wifi_rect.y, wifi_rect.width - MARGIN * 2, wifi_rect.height)
+ self.wifi_ui.render(wifi_content_rect)
+
+ button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
+ button_y = rect.height - BUTTON_HEIGHT - MARGIN
+
+ self._network_setup_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
+
+ # Check network connectivity status
+ continue_enabled = self.network_connected.is_set()
+ self._network_setup_continue_button.set_enabled(continue_enabled)
+ continue_text = ("Continue" if self.wifi_connected.is_set() else "Continue without Wi-Fi") if continue_enabled else "Waiting for internet"
+ self._network_setup_continue_button.set_text(continue_text)
+ self._network_setup_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
+
+ def render_software_selection(self, rect: rl.Rectangle):
+ self._software_selection_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE))
+
+ radio_height = 230
+ radio_spacing = 30
+
+ self._software_selection_continue_button.set_enabled(False)
+
+ openpilot_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2, rect.width - MARGIN * 2, radio_height)
+ self._software_selection_openpilot_button.render(openpilot_rect)
+
+ if self._software_selection_openpilot_button.selected:
+ self._software_selection_continue_button.set_enabled(True)
+ self._software_selection_custom_software_button.selected = False
+
+ custom_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2 + radio_height + radio_spacing, rect.width - MARGIN * 2,
+ radio_height)
+ self._software_selection_custom_software_button.render(custom_rect)
+
+ if self._software_selection_custom_software_button.selected:
+ self._software_selection_continue_button.set_enabled(True)
+ self._software_selection_openpilot_button.selected = False
+
+ button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
+ button_y = rect.height - BUTTON_HEIGHT - MARGIN
+
+ self._software_selection_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
+ self._software_selection_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
+
+ def render_downloading(self, rect: rl.Rectangle):
+ self._downloading_body_label.render(rl.Rectangle(rect.x, rect.y + rect.height / 2 - TITLE_FONT_SIZE * FONT_SCALE / 2, rect.width,
+ TITLE_FONT_SIZE * FONT_SCALE))
+
+ def render_download_failed(self, rect: rl.Rectangle):
+ self._download_failed_title_label.render(rl.Rectangle(rect.x + 117, rect.y + 185, rect.width - 117, TITLE_FONT_SIZE * FONT_SCALE))
+ self._download_failed_url_label.set_text(self.failed_url)
+ self._download_failed_url_label.render(rl.Rectangle(rect.x + 117, rect.y + 185 + TITLE_FONT_SIZE * FONT_SCALE + 67, rect.width - 117 - 100, 64))
+
+ self._download_failed_body_label.set_text(self.failed_reason)
+ self._download_failed_body_label.render(rl.Rectangle(rect.x + 117, rect.y, rect.width - 117 - 100, rect.height))
+
+ button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
+ button_y = rect.height - BUTTON_HEIGHT - MARGIN
+ self._download_failed_reboot_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
+ self._download_failed_startover_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
+
+ def render_custom_software_warning(self, rect: rl.Rectangle):
+ warn_rect = rl.Rectangle(rect.x, rect.y, rect.width, 1500)
+ offset = self._custom_software_warning_body_scroll_panel.update(rect, warn_rect)
+
+ button_width = (rect.width - MARGIN * 3) / 2
+ button_y = rect.height - MARGIN - BUTTON_HEIGHT
+
+ rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(button_y - BODY_FONT_SIZE * FONT_SCALE))
+ y_offset = rect.y + offset
+ self._custom_software_warning_title_label.render(rl.Rectangle(rect.x + 50, y_offset + 150, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE))
+ self._custom_software_warning_body_label.render(rl.Rectangle(rect.x + 50, y_offset + 400, rect.width - 50, BODY_FONT_SIZE * FONT_SCALE * 3))
+ rl.end_scissor_mode()
+
+ self._custom_software_warning_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
+ self._custom_software_warning_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT))
+ if offset < (rect.height - warn_rect.height):
+ self._custom_software_warning_continue_button.set_enabled(True)
+ self._custom_software_warning_continue_button.set_text("Continue")
+
+ def render_custom_software(self):
+ def handle_keyboard_result(result):
+ # Enter pressed
+ if result == 1:
+ url = self.keyboard.text
+ self.keyboard.clear()
+ if url:
+ self.download(url)
+
+ # Cancel pressed
+ elif result == 0:
+ self.state = SetupState.SOFTWARE_SELECTION
+
+ self.keyboard.reset(min_text_size=1)
+ self.keyboard.set_title("Enter URL", "for Custom Software")
+ gui_app.set_modal_overlay(self.keyboard, callback=handle_keyboard_result)
+
+ def use_openpilot(self):
+ if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH):
+ os.remove(VALID_CACHE_PATH)
+ with open(TMP_CONTINUE_PATH, "w") as f:
+ f.write(CONTINUE)
+ run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
+ shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
+ shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
+
+ # give time for installer UI to take over
+ time.sleep(0.1)
+ gui_app.request_close()
+ else:
+ self.state = SetupState.NETWORK_SETUP
+ self.stop_network_check_thread.clear()
+ self.start_network_check()
+
+ def download(self, url: str):
+ # autocomplete incomplete URLs
+ if re.match("^([^/.]+)/([^/]+)$", url):
+ url = f"https://installer.comma.ai/{url}"
+
+ parsed = urlparse(url, scheme='https')
+ self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl()
+
+ self.state = SetupState.DOWNLOADING
+
+ self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
+ self.download_thread.start()
+
+ def _download_thread(self):
+ try:
+ import tempfile
+
+ fd, tmpfile = tempfile.mkstemp(prefix="installer_")
+
+ headers = {"User-Agent": USER_AGENT,
+ "X-openpilot-serial": HARDWARE.get_serial(),
+ "X-openpilot-device-type": HARDWARE.get_device_type()}
+ req = urllib.request.Request(self.download_url, headers=headers)
+
+ with open(tmpfile, 'wb') as f, urllib.request.urlopen(req, timeout=30) as response:
+ total_size = int(response.headers.get('content-length', 0))
+ downloaded = 0
+ block_size = 8192
+
+ while True:
+ buffer = response.read(block_size)
+ if not buffer:
+ break
+
+ downloaded += len(buffer)
+ f.write(buffer)
+
+ if total_size:
+ self.download_progress = int(downloaded * 100 / total_size)
+
+ is_elf = False
+ with open(tmpfile, 'rb') as f:
+ header = f.read(4)
+ is_elf = header == b'\x7fELF'
+
+ if not is_elf:
+ self.download_failed(self.download_url, "No custom software found at this URL.")
+ return
+
+ # AGNOS might try to execute the installer before this process exits.
+ # Therefore, important to close the fd before renaming the installer.
+ os.close(fd)
+ os.rename(tmpfile, INSTALLER_DESTINATION_PATH)
+
+ with open(INSTALLER_URL_PATH, "w") as f:
+ f.write(self.download_url)
+
+ # give time for installer UI to take over
+ time.sleep(0.1)
+ gui_app.request_close()
+
+ except urllib.error.HTTPError as e:
+ if e.code == 409:
+ error_msg = e.read().decode("utf-8")
+ self.download_failed(self.download_url, error_msg)
+ except Exception:
+ error_msg = "Ensure the entered URL is valid, and the device's internet connection is good."
+ self.download_failed(self.download_url, error_msg)
+
+ def download_failed(self, url: str, reason: str):
+ self.failed_url = url
+ self.failed_reason = reason
+ self.state = SetupState.DOWNLOAD_FAILED
+
+
+def main():
+ try:
+ gui_app.init_window("Setup", 20)
+ setup = Setup()
+ for should_render in gui_app.render():
+ if should_render:
+ setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ setup.close()
+ except Exception as e:
+ print(f"Setup error: {e}")
+ finally:
+ gui_app.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/system/ui/tici_updater.py b/system/ui/tici_updater.py
new file mode 100755
index 000000000..2e1a8687e
--- /dev/null
+++ b/system/ui/tici_updater.py
@@ -0,0 +1,173 @@
+#!/usr/bin/env python3
+import sys
+import subprocess
+import threading
+import pyray as rl
+from enum import IntEnum
+
+from openpilot.system.hardware import HARDWARE
+from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
+from openpilot.system.ui.lib.wifi_manager import WifiManager
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.button import Button, ButtonStyle
+from openpilot.system.ui.widgets.label import gui_text_box, gui_label
+from openpilot.system.ui.widgets.network import WifiManagerUI
+
+# Constants
+MARGIN = 50
+BUTTON_HEIGHT = 160
+BUTTON_WIDTH = 400
+PROGRESS_BAR_HEIGHT = 72
+TITLE_FONT_SIZE = 80
+BODY_FONT_SIZE = 65
+BACKGROUND_COLOR = rl.BLACK
+PROGRESS_BG_COLOR = rl.Color(41, 41, 41, 255)
+PROGRESS_COLOR = rl.Color(54, 77, 239, 255)
+
+
+class Screen(IntEnum):
+ PROMPT = 0
+ WIFI = 1
+ PROGRESS = 2
+
+
+class Updater(Widget):
+ def __init__(self, updater_path, manifest_path):
+ super().__init__()
+ self.updater = updater_path
+ self.manifest = manifest_path
+ self.current_screen = Screen.PROMPT
+
+ self.progress_value = 0
+ self.progress_text = "Loading..."
+ self.show_reboot_button = False
+ self.process = None
+ self.update_thread = None
+ self.wifi_manager_ui = WifiManagerUI(WifiManager())
+
+ # Buttons
+ self._wifi_button = Button("Connect to Wi-Fi", click_callback=lambda: self.set_current_screen(Screen.WIFI))
+ self._install_button = Button("Install", click_callback=self.install_update, button_style=ButtonStyle.PRIMARY)
+ self._back_button = Button("Back", click_callback=lambda: self.set_current_screen(Screen.PROMPT))
+ self._reboot_button = Button("Reboot", click_callback=lambda: HARDWARE.reboot())
+
+ def set_current_screen(self, screen: Screen):
+ self.current_screen = screen
+
+ def install_update(self):
+ self.set_current_screen(Screen.PROGRESS)
+ self.progress_value = 0
+ self.progress_text = "Downloading..."
+ self.show_reboot_button = False
+
+ # Start the update process in a separate thread
+ self.update_thread = threading.Thread(target=self._run_update_process)
+ self.update_thread.daemon = True
+ self.update_thread.start()
+
+ def _run_update_process(self):
+ # TODO: just import it and run in a thread without a subprocess
+ cmd = [self.updater, "--swap", self.manifest]
+ self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ text=True, bufsize=1, universal_newlines=True)
+
+ for line in self.process.stdout:
+ parts = line.strip().split(":")
+ if len(parts) == 2:
+ self.progress_text = parts[0]
+ try:
+ self.progress_value = int(float(parts[1]))
+ except ValueError:
+ pass
+
+ exit_code = self.process.wait()
+ if exit_code == 0:
+ HARDWARE.reboot()
+ else:
+ self.progress_text = "Update failed"
+ self.show_reboot_button = True
+
+ def render_prompt_screen(self, rect: rl.Rectangle):
+ # Title
+ title_rect = rl.Rectangle(MARGIN + 50, 250, rect.width - MARGIN * 2 - 100, TITLE_FONT_SIZE * FONT_SCALE)
+ gui_label(title_rect, "Update Required", TITLE_FONT_SIZE, font_weight=FontWeight.BOLD)
+
+ # Description
+ desc_text = ("An operating system update is required. Connect your device to Wi-Fi for the fastest update experience. " +
+ "The download size is approximately 1GB.")
+
+ desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE * FONT_SCALE + 75, rect.width - MARGIN * 2 - 100, BODY_FONT_SIZE * FONT_SCALE * 4)
+ gui_text_box(desc_rect, desc_text, BODY_FONT_SIZE)
+
+ # Buttons at the bottom
+ button_y = rect.height - MARGIN - BUTTON_HEIGHT
+ button_width = (rect.width - MARGIN * 3) // 2
+
+ # WiFi button
+ wifi_button_rect = rl.Rectangle(MARGIN, button_y, button_width, BUTTON_HEIGHT)
+ self._wifi_button.render(wifi_button_rect)
+
+ # Install button
+ install_button_rect = rl.Rectangle(MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT)
+ self._install_button.render(install_button_rect)
+
+ def render_wifi_screen(self, rect: rl.Rectangle):
+ # Draw the Wi-Fi manager UI
+ wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2,
+ rect.height - BUTTON_HEIGHT - MARGIN * 3)
+ rl.draw_rectangle_rounded(wifi_rect, 0.035, 10, rl.Color(51, 51, 51, 255))
+ wifi_content_rect = rl.Rectangle(wifi_rect.x + 50, wifi_rect.y, wifi_rect.width - 100, wifi_rect.height)
+ self.wifi_manager_ui.render(wifi_content_rect)
+
+ back_button_rect = rl.Rectangle(MARGIN, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
+ self._back_button.render(back_button_rect)
+
+ def render_progress_screen(self, rect: rl.Rectangle):
+ title_rect = rl.Rectangle(MARGIN + 100, 330, rect.width - MARGIN * 2 - 200, 100)
+ gui_label(title_rect, self.progress_text, 90, font_weight=FontWeight.SEMI_BOLD)
+
+ # Progress bar
+ bar_rect = rl.Rectangle(MARGIN + 100, 330 + 100 + 100, rect.width - MARGIN * 2 - 200, PROGRESS_BAR_HEIGHT)
+ rl.draw_rectangle_rounded(bar_rect, 0.5, 10, PROGRESS_BG_COLOR)
+
+ # Calculate the width of the progress chunk
+ progress_width = (bar_rect.width * self.progress_value) / 100
+ if progress_width > 0:
+ progress_rect = rl.Rectangle(bar_rect.x, bar_rect.y, progress_width, bar_rect.height)
+ rl.draw_rectangle_rounded(progress_rect, 0.5, 10, PROGRESS_COLOR)
+
+ # Show reboot button if needed
+ if self.show_reboot_button:
+ reboot_rect = rl.Rectangle(MARGIN + 100, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
+ self._reboot_button.render(reboot_rect)
+
+ def _render(self, rect: rl.Rectangle):
+ if self.current_screen == Screen.PROMPT:
+ self.render_prompt_screen(rect)
+ elif self.current_screen == Screen.WIFI:
+ self.render_wifi_screen(rect)
+ elif self.current_screen == Screen.PROGRESS:
+ self.render_progress_screen(rect)
+
+
+def main():
+ if len(sys.argv) < 3:
+ print("Usage: updater.py ")
+ sys.exit(1)
+
+ updater_path = sys.argv[1]
+ manifest_path = sys.argv[2]
+
+ try:
+ gui_app.init_window("System Update")
+ updater = Updater(updater_path, manifest_path)
+ for should_render in gui_app.render():
+ if should_render:
+ updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ finally:
+ # Make sure we clean up even if there's an error
+ gui_app.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/system/ui/updater.py b/system/ui/updater.py
index 2e1a8687e..42d12d909 100755
--- a/system/ui/updater.py
+++ b/system/ui/updater.py
@@ -1,172 +1,14 @@
#!/usr/bin/env python3
-import sys
-import subprocess
-import threading
-import pyray as rl
-from enum import IntEnum
-
-from openpilot.system.hardware import HARDWARE
-from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
-from openpilot.system.ui.lib.wifi_manager import WifiManager
-from openpilot.system.ui.widgets import Widget
-from openpilot.system.ui.widgets.button import Button, ButtonStyle
-from openpilot.system.ui.widgets.label import gui_text_box, gui_label
-from openpilot.system.ui.widgets.network import WifiManagerUI
-
-# Constants
-MARGIN = 50
-BUTTON_HEIGHT = 160
-BUTTON_WIDTH = 400
-PROGRESS_BAR_HEIGHT = 72
-TITLE_FONT_SIZE = 80
-BODY_FONT_SIZE = 65
-BACKGROUND_COLOR = rl.BLACK
-PROGRESS_BG_COLOR = rl.Color(41, 41, 41, 255)
-PROGRESS_COLOR = rl.Color(54, 77, 239, 255)
-
-
-class Screen(IntEnum):
- PROMPT = 0
- WIFI = 1
- PROGRESS = 2
-
-
-class Updater(Widget):
- def __init__(self, updater_path, manifest_path):
- super().__init__()
- self.updater = updater_path
- self.manifest = manifest_path
- self.current_screen = Screen.PROMPT
-
- self.progress_value = 0
- self.progress_text = "Loading..."
- self.show_reboot_button = False
- self.process = None
- self.update_thread = None
- self.wifi_manager_ui = WifiManagerUI(WifiManager())
-
- # Buttons
- self._wifi_button = Button("Connect to Wi-Fi", click_callback=lambda: self.set_current_screen(Screen.WIFI))
- self._install_button = Button("Install", click_callback=self.install_update, button_style=ButtonStyle.PRIMARY)
- self._back_button = Button("Back", click_callback=lambda: self.set_current_screen(Screen.PROMPT))
- self._reboot_button = Button("Reboot", click_callback=lambda: HARDWARE.reboot())
-
- def set_current_screen(self, screen: Screen):
- self.current_screen = screen
-
- def install_update(self):
- self.set_current_screen(Screen.PROGRESS)
- self.progress_value = 0
- self.progress_text = "Downloading..."
- self.show_reboot_button = False
-
- # Start the update process in a separate thread
- self.update_thread = threading.Thread(target=self._run_update_process)
- self.update_thread.daemon = True
- self.update_thread.start()
-
- def _run_update_process(self):
- # TODO: just import it and run in a thread without a subprocess
- cmd = [self.updater, "--swap", self.manifest]
- self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
- text=True, bufsize=1, universal_newlines=True)
-
- for line in self.process.stdout:
- parts = line.strip().split(":")
- if len(parts) == 2:
- self.progress_text = parts[0]
- try:
- self.progress_value = int(float(parts[1]))
- except ValueError:
- pass
-
- exit_code = self.process.wait()
- if exit_code == 0:
- HARDWARE.reboot()
- else:
- self.progress_text = "Update failed"
- self.show_reboot_button = True
-
- def render_prompt_screen(self, rect: rl.Rectangle):
- # Title
- title_rect = rl.Rectangle(MARGIN + 50, 250, rect.width - MARGIN * 2 - 100, TITLE_FONT_SIZE * FONT_SCALE)
- gui_label(title_rect, "Update Required", TITLE_FONT_SIZE, font_weight=FontWeight.BOLD)
-
- # Description
- desc_text = ("An operating system update is required. Connect your device to Wi-Fi for the fastest update experience. " +
- "The download size is approximately 1GB.")
-
- desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE * FONT_SCALE + 75, rect.width - MARGIN * 2 - 100, BODY_FONT_SIZE * FONT_SCALE * 4)
- gui_text_box(desc_rect, desc_text, BODY_FONT_SIZE)
-
- # Buttons at the bottom
- button_y = rect.height - MARGIN - BUTTON_HEIGHT
- button_width = (rect.width - MARGIN * 3) // 2
-
- # WiFi button
- wifi_button_rect = rl.Rectangle(MARGIN, button_y, button_width, BUTTON_HEIGHT)
- self._wifi_button.render(wifi_button_rect)
-
- # Install button
- install_button_rect = rl.Rectangle(MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT)
- self._install_button.render(install_button_rect)
-
- def render_wifi_screen(self, rect: rl.Rectangle):
- # Draw the Wi-Fi manager UI
- wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2,
- rect.height - BUTTON_HEIGHT - MARGIN * 3)
- rl.draw_rectangle_rounded(wifi_rect, 0.035, 10, rl.Color(51, 51, 51, 255))
- wifi_content_rect = rl.Rectangle(wifi_rect.x + 50, wifi_rect.y, wifi_rect.width - 100, wifi_rect.height)
- self.wifi_manager_ui.render(wifi_content_rect)
-
- back_button_rect = rl.Rectangle(MARGIN, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
- self._back_button.render(back_button_rect)
-
- def render_progress_screen(self, rect: rl.Rectangle):
- title_rect = rl.Rectangle(MARGIN + 100, 330, rect.width - MARGIN * 2 - 200, 100)
- gui_label(title_rect, self.progress_text, 90, font_weight=FontWeight.SEMI_BOLD)
-
- # Progress bar
- bar_rect = rl.Rectangle(MARGIN + 100, 330 + 100 + 100, rect.width - MARGIN * 2 - 200, PROGRESS_BAR_HEIGHT)
- rl.draw_rectangle_rounded(bar_rect, 0.5, 10, PROGRESS_BG_COLOR)
-
- # Calculate the width of the progress chunk
- progress_width = (bar_rect.width * self.progress_value) / 100
- if progress_width > 0:
- progress_rect = rl.Rectangle(bar_rect.x, bar_rect.y, progress_width, bar_rect.height)
- rl.draw_rectangle_rounded(progress_rect, 0.5, 10, PROGRESS_COLOR)
-
- # Show reboot button if needed
- if self.show_reboot_button:
- reboot_rect = rl.Rectangle(MARGIN + 100, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
- self._reboot_button.render(reboot_rect)
-
- def _render(self, rect: rl.Rectangle):
- if self.current_screen == Screen.PROMPT:
- self.render_prompt_screen(rect)
- elif self.current_screen == Screen.WIFI:
- self.render_wifi_screen(rect)
- elif self.current_screen == Screen.PROGRESS:
- self.render_progress_screen(rect)
+from openpilot.system.ui.lib.application import gui_app
+import openpilot.system.ui.tici_updater as tici_updater
+import openpilot.system.ui.mici_updater as mici_updater
def main():
- if len(sys.argv) < 3:
- print("Usage: updater.py ")
- sys.exit(1)
-
- updater_path = sys.argv[1]
- manifest_path = sys.argv[2]
-
- try:
- gui_app.init_window("System Update")
- updater = Updater(updater_path, manifest_path)
- for should_render in gui_app.render():
- if should_render:
- updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
- finally:
- # Make sure we clean up even if there's an error
- gui_app.close()
+ if gui_app.big_ui():
+ tici_updater.main()
+ else:
+ mici_updater.main()
if __name__ == "__main__":
diff --git a/system/ui/widgets/__init__.py b/system/ui/widgets/__init__.py
index 562cde39e..95858ec1b 100644
--- a/system/ui/widgets/__init__.py
+++ b/system/ui/widgets/__init__.py
@@ -2,7 +2,15 @@ import abc
import pyray as rl
from enum import IntEnum
from collections.abc import Callable
-from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS
+from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
+from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS, MouseEvent
+
+try:
+ from openpilot.selfdrive.ui.ui_state import device
+except ImportError:
+ class Device:
+ awake = True
+ device = Device() # type: ignore
class DialogResult(IntEnum):
@@ -23,6 +31,7 @@ class Widget(abc.ABC):
self._touch_valid_callback: Callable[[], bool] | None = None
self._click_callback: Callable[[], None] | None = None
self._multi_touch = False
+ self.__was_awake = True
@property
def rect(self) -> rl.Rectangle:
@@ -71,7 +80,7 @@ class Widget(abc.ABC):
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
+ self._rect = rl.Rectangle(x, y, self._rect.width, self._rect.height)
if changed:
self._update_layout_rects()
@@ -94,7 +103,7 @@ class Widget(abc.ABC):
ret = self._render(self._rect)
# Keep track of whether mouse down started within the widget's rectangle
- if self.enabled:
+ if self.enabled and self.__was_awake:
for mouse_event in gui_app.mouse_events:
if not self._multi_touch and mouse_event.slot != 0:
continue
@@ -106,6 +115,7 @@ class Widget(abc.ABC):
self._handle_mouse_press(mouse_event.pos)
self.__is_pressed[mouse_event.slot] = True
self.__tracking_is_pressed[mouse_event.slot] = True
+ self._handle_mouse_event(mouse_event)
# Callback such as scroll panel signifies user is scrolling
elif not self._touch_valid():
@@ -113,6 +123,7 @@ class Widget(abc.ABC):
self.__tracking_is_pressed[mouse_event.slot] = False
elif mouse_event.left_released:
+ self._handle_mouse_event(mouse_event)
if self.__is_pressed[mouse_event.slot] and rl.check_collision_point_rec(mouse_event.pos, self._hit_rect):
self._handle_mouse_release(mouse_event.pos)
self.__is_pressed[mouse_event.slot] = False
@@ -122,10 +133,14 @@ class Widget(abc.ABC):
elif rl.check_collision_point_rec(mouse_event.pos, self._hit_rect):
if self.__tracking_is_pressed[mouse_event.slot]:
self.__is_pressed[mouse_event.slot] = True
+ self._handle_mouse_event(mouse_event)
# Mouse/touch left our rect but may come back into focus later
elif not rl.check_collision_point_rec(mouse_event.pos, self._hit_rect):
self.__is_pressed[mouse_event.slot] = False
+ self._handle_mouse_event(mouse_event)
+
+ self.__was_awake = device.awake
return ret
@@ -149,9 +164,206 @@ class Widget(abc.ABC):
self._click_callback()
return False
+ def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
+ """Optionally handle mouse events. This is called before rendering."""
+ # Default implementation does nothing, can be overridden by subclasses
+
def show_event(self):
"""Optionally handle show event. Parent must manually call this"""
def hide_event(self):
"""Optionally handle hide event. Parent must manually call this"""
+
+SWIPE_AWAY_THRESHOLD = 80 # px to dismiss after releasing
+START_DISMISSING_THRESHOLD = 40 # px to start dismissing while dragging
+BLOCK_SWIPE_AWAY_THRESHOLD = 60 # px horizontal movement to block swipe away
+
+NAV_BAR_MARGIN = 6
+NAV_BAR_WIDTH = 205
+NAV_BAR_HEIGHT = 8
+
+DISMISS_PUSH_OFFSET = 50 + NAV_BAR_MARGIN + NAV_BAR_HEIGHT # px extra to push down when dismissing
+DISMISS_TIME_SECONDS = 1.5
+
+
+class NavBar(Widget):
+ def __init__(self):
+ super().__init__()
+ self.set_rect(rl.Rectangle(0, 0, NAV_BAR_WIDTH, NAV_BAR_HEIGHT))
+ self._alpha = 1.0
+ self._alpha_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
+ self._fade_time = 0.0
+
+ def set_alpha(self, alpha: float) -> None:
+ self._alpha = alpha
+ self._fade_time = rl.get_time()
+
+ def show_event(self):
+ super().show_event()
+ self._alpha = 1.0
+ self._alpha_filter.x = 1.0
+ self._fade_time = rl.get_time()
+
+ def _render(self, _):
+ if rl.get_time() - self._fade_time > DISMISS_TIME_SECONDS:
+ self._alpha = 0.0
+ alpha = self._alpha_filter.update(self._alpha)
+
+ # white bar with black border
+ rl.draw_rectangle_rounded(self._rect, 1.0, 6, rl.Color(255, 255, 255, int(255 * 0.9 * alpha)))
+ rl.draw_rectangle_rounded_lines_ex(self._rect, 1.0, 6, 2, rl.Color(0, 0, 0, int(255 * 0.3 * alpha)))
+
+
+class NavWidget(Widget, abc.ABC):
+ """
+ A full screen widget that supports back navigation by swiping down from the top.
+ """
+ BACK_TOUCH_AREA_PERCENTAGE = 0.65
+
+ def __init__(self):
+ super().__init__()
+ self._back_callback: Callable[[], None] | None = None
+ self._back_button_start_pos: MousePos | None = None
+ self._swiping_away = False # currently swiping away
+ self._can_swipe_away = True # swipe away is blocked after certain horizontal movement
+
+ self._pos_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps, bounce=1)
+ self._playing_dismiss_animation = False
+ self._trigger_animate_in = False
+ self._back_enabled: bool | Callable[[], bool] = True
+ self._nav_bar = NavBar()
+
+ self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
+
+ self._set_up = False
+
+ @property
+ def back_enabled(self) -> bool:
+ return self._back_enabled() if callable(self._back_enabled) else self._back_enabled
+
+ def set_back_enabled(self, enabled: bool | Callable[[], bool]) -> None:
+ self._back_enabled = enabled
+
+ def set_back_callback(self, callback: Callable[[], None]) -> None:
+ self._back_callback = callback
+
+ def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
+ super()._handle_mouse_event(mouse_event)
+
+ if not self.back_enabled:
+ self._back_button_start_pos = None
+ self._swiping_away = False
+ self._can_swipe_away = True
+ return
+
+ if mouse_event.left_pressed:
+ # user is able to swipe away if starting near top of screen, or anywhere if scroller is at top
+ self._pos_filter.update_alpha(0.04)
+ in_dismiss_area = mouse_event.pos.y < self._rect.height * self.BACK_TOUCH_AREA_PERCENTAGE
+
+ scroller_at_top = False
+ # TODO: -20? snapping in WiFi dialog can make offset not be positive at the top
+ if hasattr(self, '_scroller'):
+ scroller_at_top = self._scroller.scroll_panel.get_offset() >= -20 and not self._scroller._horizontal
+ elif hasattr(self, '_scroll_panel'):
+ scroller_at_top = self._scroll_panel.get_offset() >= -20 and not self._scroll_panel._horizontal
+
+ if in_dismiss_area or scroller_at_top:
+ self._can_swipe_away = True
+ self._back_button_start_pos = mouse_event.pos
+
+ elif mouse_event.left_down:
+ if self._back_button_start_pos is not None:
+ # block swiping away if too much horizontal or upward movement
+ horizontal_movement = abs(mouse_event.pos.x - self._back_button_start_pos.x) > BLOCK_SWIPE_AWAY_THRESHOLD
+ upward_movement = mouse_event.pos.y - self._back_button_start_pos.y < -BLOCK_SWIPE_AWAY_THRESHOLD
+ if not self._swiping_away and (horizontal_movement or upward_movement):
+ self._can_swipe_away = False
+ self._back_button_start_pos = None
+
+ # block horizontal swiping if now swiping away
+ if self._can_swipe_away:
+ if mouse_event.pos.y - self._back_button_start_pos.y > START_DISMISSING_THRESHOLD: # type: ignore
+ self._swiping_away = True
+
+ elif mouse_event.left_released:
+ self._pos_filter.update_alpha(0.1)
+ # if far enough, trigger back navigation callback
+ if self._back_button_start_pos is not None:
+ if mouse_event.pos.y - self._back_button_start_pos.y > SWIPE_AWAY_THRESHOLD:
+ self._playing_dismiss_animation = True
+
+ self._back_button_start_pos = None
+ self._swiping_away = False
+
+ def _update_state(self):
+ super()._update_state()
+
+ # Disable self's scroller while swiping away
+ if not self._set_up:
+ self._set_up = True
+ if hasattr(self, '_scroller'):
+ original_enabled = self._scroller._enabled
+ self._scroller.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else
+ original_enabled))
+ elif hasattr(self, '_scroll_panel'):
+ original_enabled = self._scroll_panel.enabled
+ self._scroll_panel.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else
+ original_enabled))
+
+ if self._trigger_animate_in:
+ self._pos_filter.x = self._rect.height
+ self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT
+ self._trigger_animate_in = False
+
+ new_y = 0.0
+
+ if self._back_button_start_pos is not None:
+ last_mouse_event = gui_app.last_mouse_event
+ # push entire widget as user drags it away
+ new_y = max(last_mouse_event.pos.y - self._back_button_start_pos.y, 0)
+ if new_y < SWIPE_AWAY_THRESHOLD:
+ new_y /= 2 # resistance until mouse release would dismiss widget
+
+ if self._swiping_away:
+ self._nav_bar.set_alpha(1.0)
+
+ if self._playing_dismiss_animation:
+ new_y = self._rect.height + DISMISS_PUSH_OFFSET
+
+ new_y = round(self._pos_filter.update(new_y))
+ if abs(new_y) < 1 and self._pos_filter.velocity.x == 0.0:
+ new_y = self._pos_filter.x = 0.0
+
+ if new_y > self._rect.height + DISMISS_PUSH_OFFSET - 10:
+ if self._back_callback is not None:
+ self._back_callback()
+
+ self._playing_dismiss_animation = False
+ self._back_button_start_pos = None
+ self._swiping_away = False
+
+ self.set_position(self._rect.x, new_y)
+
+ def render(self, rect: rl.Rectangle = None) -> bool | int | None:
+ ret = super().render(rect)
+
+ if self.back_enabled:
+ bar_x = self._rect.x + (self._rect.width - self._nav_bar.rect.width) / 2
+ if self._back_button_start_pos is not None or self._playing_dismiss_animation:
+ self._nav_bar_y_filter.x = NAV_BAR_MARGIN + self._pos_filter.x
+ else:
+ self._nav_bar_y_filter.update(NAV_BAR_MARGIN)
+
+ self._nav_bar.set_position(bar_x, round(self._nav_bar_y_filter.x))
+ self._nav_bar.render()
+
+ return ret
+
+ def show_event(self):
+ super().show_event()
+ # FIXME: we don't know the height of the rect at first show_event since it's before the first render :(
+ # so we need this hacky bool for now
+ self._trigger_animate_in = True
+ self._nav_bar.show_event()
diff --git a/system/ui/widgets/button.py b/system/ui/widgets/button.py
index df7a52d1c..34b2a51a4 100644
--- a/system/ui/widgets/button.py
+++ b/system/ui/widgets/button.py
@@ -3,9 +3,10 @@ from enum import IntEnum
import pyray as rl
-from openpilot.system.ui.lib.application import FontWeight, MousePos
+from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.widgets import Widget
-from openpilot.system.ui.widgets.label import Label
+from openpilot.system.ui.widgets.label import Label, UnifiedLabel
+from openpilot.common.filter_simple import FirstOrderFilter
class ButtonStyle(IntEnum):
@@ -175,9 +176,121 @@ class IconButton(Widget):
def __init__(self, texture: rl.Texture):
super().__init__()
self._texture = texture
+ self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
+ self.set_rect(rl.Rectangle(0, 0, self._texture.width, self._texture.height))
+
+ def set_opacity(self, opacity: float, smooth: bool = False):
+ if smooth:
+ self._opacity_filter.update(opacity)
+ else:
+ self._opacity_filter.x = opacity
def _render(self, rect: rl.Rectangle):
- color = rl.Color(180, 180, 180, 150) if self.is_pressed else rl.WHITE
+ color = rl.Color(180, 180, 180, int(150 * self._opacity_filter.x)) if self.is_pressed else rl.WHITE
+ if not self.enabled:
+ color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.35 * self._opacity_filter.x))
draw_x = rect.x + (rect.width - self._texture.width) / 2
draw_y = rect.y + (rect.height - self._texture.height) / 2
rl.draw_texture(self._texture, int(draw_x), int(draw_y), color)
+
+
+class SmallCircleIconButton(Widget):
+ def __init__(self, icon_txt: rl.Texture):
+ super().__init__()
+ self.set_rect(rl.Rectangle(0, 0, 100, 100))
+ self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
+ self._icon_bg_txt = gui_app.texture("icons_mici/setup/small_button.png", 100, 100)
+ self._icon_bg_pressed_txt = gui_app.texture("icons_mici/setup/small_button_pressed.png", 100, 100)
+ self._icon_txt = icon_txt
+
+ def set_opacity(self, opacity: float, smooth: bool = False):
+ if smooth:
+ self._opacity_filter.update(opacity)
+ else:
+ self._opacity_filter.x = opacity
+
+ def _render(self, _):
+ bg_txt = self._icon_bg_pressed_txt if self.is_pressed else self._icon_bg_txt
+ white = rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))
+ rl.draw_texture(bg_txt, int(self.rect.x), int(self.rect.y), white)
+ icon_x = self.rect.x + (self.rect.width - self._icon_txt.width) / 2
+ icon_y = self.rect.y + (self.rect.height - self._icon_txt.height) / 2
+ rl.draw_texture(self._icon_txt, int(icon_x), int(icon_y), white)
+
+
+class SmallButton(Widget):
+ def __init__(self, text: str):
+ super().__init__()
+ self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
+
+ self._load_assets()
+
+ self._label = UnifiedLabel(text, 36, font_weight=FontWeight.MEDIUM,
+ text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
+
+ self._bg_disabled_txt = None
+
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 194, 100))
+ self._bg_txt = gui_app.texture("icons_mici/setup/reset/small_button.png", 194, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/small_button_pressed.png", 194, 100)
+
+ def set_text(self, text: str):
+ self._label.set_text(text)
+
+ def set_opacity(self, opacity: float, smooth: bool = False):
+ if smooth:
+ self._opacity_filter.update(opacity)
+ else:
+ self._opacity_filter.x = opacity
+
+ def _render(self, _):
+ if not self.enabled and self._bg_disabled_txt is not None:
+ rl.draw_texture(self._bg_disabled_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
+ elif self.is_pressed:
+ rl.draw_texture(self._bg_pressed_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
+ else:
+ rl.draw_texture(self._bg_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
+
+ opacity = 0.9 if self.enabled else 0.35
+ self._label.set_color(rl.Color(255, 255, 255, int(255 * opacity * self._opacity_filter.x)))
+ self._label.render(self._rect)
+
+
+class SmallRedPillButton(SmallButton):
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 194, 100))
+ self._bg_txt = gui_app.texture("icons_mici/setup/small_red_pill.png", 194, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/setup/small_red_pill_pressed.png", 194, 100)
+
+
+class SmallerRoundedButton(SmallButton):
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 150, 100))
+ self._bg_txt = gui_app.texture("icons_mici/setup/smaller_button.png", 150, 100)
+ self._bg_disabled_txt = gui_app.texture("icons_mici/setup/smaller_button_disabled.png", 150, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/setup/smaller_button_pressed.png", 150, 100)
+
+
+class WideRoundedButton(SmallButton):
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 316, 100))
+ self._bg_txt = gui_app.texture("icons_mici/setup/medium_button_bg.png", 316, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/setup/medium_button_pressed_bg.png", 316, 100)
+
+
+class WidishRoundedButton(SmallButton):
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 250, 100))
+ self._bg_txt = gui_app.texture("icons_mici/setup/widish_button.png", 250, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/setup/widish_button_pressed.png", 250, 100)
+ self._bg_disabled_txt = gui_app.texture("icons_mici/setup/widish_button_disabled.png", 250, 100)
+
+
+class FullRoundedButton(SmallButton):
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 520, 100))
+ self._bg_txt = gui_app.texture("icons_mici/setup/reset/wide_button.png", 520, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/wide_button_pressed.png", 520, 100)
diff --git a/system/ui/widgets/confirm_dialog.py b/system/ui/widgets/confirm_dialog.py
index 8c5ae0aa0..97618660b 100644
--- a/system/ui/widgets/confirm_dialog.py
+++ b/system/ui/widgets/confirm_dialog.py
@@ -6,7 +6,7 @@ from openpilot.system.ui.widgets.button import ButtonStyle, Button
from openpilot.system.ui.widgets.label import Label
from openpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType
from openpilot.system.ui.widgets import Widget
-from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.scroller_tici import Scroller
OUTER_MARGIN = 200
RICH_OUTER_MARGIN = 100
diff --git a/system/ui/widgets/label.py b/system/ui/widgets/label.py
index 8d33ac2fd..35e2708e6 100644
--- a/system/ui/widgets/label.py
+++ b/system/ui/widgets/label.py
@@ -1,14 +1,15 @@
+from enum import IntEnum
from collections.abc import Callable
from itertools import zip_longest
from typing import Union
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR, FONT_SCALE
+from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.utils import GuiStyleContext
from openpilot.system.ui.lib.emoji import find_emoji, emoji_tex
from openpilot.system.ui.lib.wrap_text import wrap_text
-from openpilot.system.ui.widgets import Widget
ICON_PADDING = 15
@@ -20,6 +21,171 @@ def _resolve_value(value, default=""):
return value if value is not None else default
+class ScrollState(IntEnum):
+ STARTING = 0
+ SCROLLING = 1
+
+
+# TODO: merge anything new here to master
+class MiciLabel(Widget):
+ def __init__(self,
+ text: str,
+ font_size: int = DEFAULT_TEXT_SIZE,
+ width: int = None,
+ color: rl.Color = DEFAULT_TEXT_COLOR,
+ font_weight: FontWeight = FontWeight.NORMAL,
+ alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
+ spacing: int = 0,
+ line_height: int = None,
+ elide_right: bool = True,
+ wrap_text: bool = False,
+ scroll: bool = False):
+ super().__init__()
+ self.text = text
+ self.wrapped_text: list[str] = []
+ self.font_size = font_size
+ self.width = width
+ self.color = color
+ self.font_weight = font_weight
+ self.alignment = alignment
+ self.alignment_vertical = alignment_vertical
+ self.spacing = spacing
+ self.line_height = line_height if line_height is not None else font_size
+ self.elide_right = elide_right
+ self.wrap_text = wrap_text
+ self._height = 0
+
+ # Scroll state
+ self.scroll = scroll
+ self._needs_scroll = False
+ self._scroll_offset = 0
+ self._scroll_pause_t: float | None = None
+ self._scroll_state: ScrollState = ScrollState.STARTING
+
+ assert not (self.scroll and self.wrap_text), "Cannot enable both scroll and wrap_text"
+ assert not (self.scroll and self.elide_right), "Cannot enable both scroll and elide_right"
+
+ self.set_text(text)
+
+ @property
+ def text_height(self):
+ return self._height
+
+ def set_font_size(self, font_size: int):
+ self.font_size = font_size
+ self.set_text(self.text)
+
+ def set_width(self, width: int):
+ self.width = width
+ self._rect.width = width
+ self.set_text(self.text)
+
+ def set_text(self, txt: str):
+ self.text = txt
+ text_size = measure_text_cached(gui_app.font(self.font_weight), self.text, self.font_size, self.spacing)
+ if self.width is not None:
+ self._rect.width = self.width
+ else:
+ self._rect.width = text_size.x
+
+ if self.wrap_text:
+ self.wrapped_text = wrap_text(gui_app.font(self.font_weight), self.text, self.font_size, int(self._rect.width))
+ self._height = len(self.wrapped_text) * self.line_height
+ elif self.scroll:
+ self._needs_scroll = self.scroll and text_size.x > self._rect.width
+ self._rect.height = text_size.y
+
+ def set_color(self, color: rl.Color):
+ self.color = color
+
+ def set_font_weight(self, font_weight: FontWeight):
+ self.font_weight = font_weight
+ self.set_text(self.text)
+
+ def _render(self, rect: rl.Rectangle):
+ # Only scissor when we know there is a single scrolling line
+ if self._needs_scroll:
+ rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
+
+ font = gui_app.font(self.font_weight)
+
+ text_y_offset = 0
+ # Draw the text in the specified rectangle
+ lines = self.wrapped_text or [self.text]
+ if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
+ lines = lines[::-1]
+
+ for display_text in lines:
+ text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)
+
+ # Elide text to fit within the rectangle
+ if self.elide_right and text_size.x > rect.width:
+ ellipsis = "..."
+ left, right = 0, len(display_text)
+ while left < right:
+ mid = (left + right) // 2
+ candidate = display_text[:mid] + ellipsis
+ candidate_size = measure_text_cached(font, candidate, self.font_size, self.spacing)
+ if candidate_size.x <= rect.width:
+ left = mid + 1
+ else:
+ right = mid
+ display_text = display_text[: left - 1] + ellipsis if left > 0 else ellipsis
+ text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)
+
+ # Handle scroll state
+ elif self.scroll and self._needs_scroll:
+ if self._scroll_state == ScrollState.STARTING:
+ if self._scroll_pause_t is None:
+ self._scroll_pause_t = rl.get_time() + 2.0
+ if rl.get_time() >= self._scroll_pause_t:
+ self._scroll_state = ScrollState.SCROLLING
+ self._scroll_pause_t = None
+
+ elif self._scroll_state == ScrollState.SCROLLING:
+ self._scroll_offset -= 0.8 / 60. * gui_app.target_fps
+ # don't fully hide
+ if self._scroll_offset <= -text_size.x - self._rect.width / 3:
+ self._scroll_offset = 0
+ self._scroll_state = ScrollState.STARTING
+ self._scroll_pause_t = None
+
+ # Calculate horizontal position based on alignment
+ text_x = rect.x + {
+ rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0,
+ rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2,
+ rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x,
+ }.get(self.alignment, 0) + self._scroll_offset
+
+ # Calculate vertical position based on alignment
+ text_y = rect.y + {
+ rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0,
+ rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2,
+ rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y,
+ }.get(self.alignment_vertical, 0)
+ text_y += text_y_offset
+
+ rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x), text_y), self.font_size, self.spacing, self.color)
+ # Draw 2nd instance for scrolling
+ if self._needs_scroll and self._scroll_state != ScrollState.STARTING:
+ text2_scroll_offset = text_size.x + self._rect.width / 3
+ rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x + text2_scroll_offset), text_y), self.font_size, self.spacing, self.color)
+ if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
+ text_y_offset -= self.line_height
+ else:
+ text_y_offset += self.line_height
+
+ if self._needs_scroll:
+ # draw black fade on left and right
+ fade_width = 20
+ rl.draw_rectangle_gradient_h(int(rect.x + rect.width - fade_width), int(rect.y), fade_width, int(rect.height), rl.Color(0, 0, 0, 0), rl.BLACK)
+ if self._scroll_state != ScrollState.STARTING:
+ rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), fade_width, int(rect.height), rl.BLACK, rl.Color(0, 0, 0, 0))
+
+ rl.end_scissor_mode()
+
+
# TODO: This should be a Widget class
def gui_label(
rect: rl.Rectangle,
@@ -65,6 +231,7 @@ def gui_label(
}.get(alignment_vertical, 0)
# Draw the text in the specified rectangle
+ # TODO: add wrapping and proper centering for multiline text
rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color)
@@ -76,11 +243,12 @@ def gui_text_box(
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
font_weight: FontWeight = FontWeight.NORMAL,
+ line_scale: float = 1.0,
):
styles = [
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(color)),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, round(font_size * FONT_SCALE)),
- (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE)),
+ (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE * line_scale)),
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_ALIGNMENT, alignment),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, alignment_vertical),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_WRAP_MODE, rl.GuiTextWrapMode.TEXT_WRAP_WORD)
@@ -107,6 +275,7 @@ class Label(Widget):
text_color: rl.Color = DEFAULT_TEXT_COLOR,
icon: Union[rl.Texture, None] = None,
elide_right: bool = False,
+ line_scale=1.0,
):
super().__init__()
@@ -119,6 +288,7 @@ class Label(Widget):
self._text_color = text_color
self._icon = icon
self._elide_right = elide_right
+ self._line_scale = line_scale
self._text = text
self.set_text(text)
@@ -217,4 +387,339 @@ class Label(Widget):
line_pos.x += self._font_size * FONT_SCALE
prev_index = end
rl.draw_text_ex(self._font, text[prev_index:], line_pos, self._font_size, 0, self._text_color)
- text_pos.y += text_size.y or self._font_size * FONT_SCALE
+ text_pos.y += (text_size.y or self._font_size * FONT_SCALE) * self._line_scale
+
+
+class UnifiedLabel(Widget):
+ """
+ Unified label widget that combines functionality from gui_label, gui_text_box, Label, and MiciLabel.
+
+ Supports:
+ - Emoji rendering
+ - Text wrapping
+ - Automatic eliding (single-line or multiline)
+ - Proper multiline vertical alignment
+ - Height calculation for layout purposes
+ """
+ def __init__(self,
+ text: str | Callable[[], str],
+ font_size: int = DEFAULT_TEXT_SIZE,
+ font_weight: FontWeight = FontWeight.NORMAL,
+ text_color: rl.Color = DEFAULT_TEXT_COLOR,
+ alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
+ text_padding: int = 0,
+ max_width: int | None = None,
+ elide: bool = True,
+ wrap_text: bool = True,
+ line_height: float = 1.0,
+ letter_spacing: float = 0.0):
+ super().__init__()
+ self._text = text
+ self._font_size = font_size
+ self._font_weight = font_weight
+ self._font = gui_app.font(self._font_weight)
+ self._text_color = text_color
+ self._alignment = alignment
+ self._alignment_vertical = alignment_vertical
+ self._text_padding = text_padding
+ self._max_width = max_width
+ self._elide = elide
+ self._wrap_text = wrap_text
+ self._line_height = line_height * 0.9
+ self._letter_spacing = letter_spacing # 0.1 = 10%
+ self._spacing_pixels = font_size * letter_spacing
+
+ # Cached data
+ self._cached_text: str | None = None
+ self._cached_wrapped_lines: list[str] = []
+ self._cached_line_sizes: list[rl.Vector2] = []
+ self._cached_line_emojis: list[list[tuple[int, int, str]]] = []
+ self._cached_total_height: float | None = None
+ self._cached_width: int = -1
+
+ # If max_width is set, initialize rect size for Scroller support
+ if max_width is not None:
+ self._rect.width = max_width
+ self._rect.height = self.get_content_height(max_width)
+
+ def set_text(self, text: str | Callable[[], str]):
+ """Update the text content."""
+ self._text = text
+ self._cached_text = None # Invalidate cache
+
+ @property
+ def text(self) -> str:
+ """Get the current text content."""
+ return str(_resolve_value(self._text))
+
+ def set_text_color(self, color: rl.Color):
+ """Update the text color."""
+ self._text_color = color
+
+ def set_color(self, color: rl.Color):
+ """Update the text color (alias for set_text_color)."""
+ self.set_text_color(color)
+
+ def set_font_size(self, size: int):
+ """Update the font size."""
+ self._font_size = size
+ self._spacing_pixels = size * self._letter_spacing # Recalculate spacing
+ self._cached_text = None # Invalidate cache
+
+ def set_letter_spacing(self, letter_spacing: float):
+ """Update letter spacing (as percentage, e.g., 0.1 = 10%)."""
+ self._letter_spacing = letter_spacing
+ self._spacing_pixels = self._font_size * letter_spacing
+ self._cached_text = None # Invalidate cache
+
+ def set_font_weight(self, font_weight: FontWeight):
+ """Update the font weight."""
+ if self._font_weight != font_weight:
+ self._font_weight = font_weight
+ self._font = gui_app.font(self._font_weight)
+ self._cached_text = None # Invalidate cache
+
+ def set_alignment(self, alignment: int):
+ """Update the horizontal text alignment."""
+ self._alignment = alignment
+
+ def set_alignment_vertical(self, alignment_vertical: int):
+ """Update the vertical text alignment."""
+ self._alignment_vertical = alignment_vertical
+
+ def set_max_width(self, max_width: int | None):
+ """Set the maximum width constraint for wrapping/eliding."""
+ if self._max_width != max_width:
+ self._max_width = max_width
+ self._cached_text = None # Invalidate cache
+ # Update rect size for Scroller support
+ if max_width is not None:
+ self._rect.width = max_width
+ self._rect.height = self.get_content_height(max_width)
+
+ def _update_text_cache(self, available_width: int):
+ """Update cached text processing data."""
+ text = self.text
+
+ # Check if cache is still valid
+ if (self._cached_text == text and
+ self._cached_width == available_width and
+ self._cached_wrapped_lines):
+ return
+
+ self._cached_text = text
+ self._cached_width = available_width
+
+ # Determine wrapping width
+ content_width = available_width - (self._text_padding * 2)
+ if content_width <= 0:
+ content_width = 1
+
+ # Wrap text if enabled
+ if self._wrap_text:
+ self._cached_wrapped_lines = wrap_text(self._font, text, self._font_size, content_width, self._spacing_pixels)
+ else:
+ # Split by newlines but don't wrap
+ self._cached_wrapped_lines = text.split('\n') if text else [""]
+
+ # Elide lines if needed (for width constraint)
+ self._cached_wrapped_lines = [self._elide_line(line, content_width) for line in self._cached_wrapped_lines]
+
+ # Process each line: measure and find emojis
+ self._cached_line_sizes = []
+ self._cached_line_emojis = []
+
+ for line in self._cached_wrapped_lines:
+ emojis = find_emoji(line)
+ self._cached_line_emojis.append(emojis)
+ # Empty lines should still have height (use font size as line height)
+ if not line:
+ size = rl.Vector2(0, self._font_size * FONT_SCALE)
+ else:
+ size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
+ self._cached_line_sizes.append(size)
+
+ # Calculate total height
+ # Each line contributes its measured height * line_height (matching Label's behavior)
+ # This includes spacing to the next line
+ if self._cached_line_sizes:
+ # Match the rendering logic: first line doesn't get line_height scaling
+ total_height = 0.0
+ for idx, size in enumerate(self._cached_line_sizes):
+ if idx == 0:
+ total_height += size.y
+ else:
+ total_height += size.y * self._line_height
+ self._cached_total_height = total_height
+ else:
+ self._cached_total_height = 0.0
+
+ def _elide_line(self, line: str, max_width: int, force: bool = False) -> str:
+ """Elide a single line if it exceeds max_width. If force is True, always elide even if it fits."""
+ if not self._elide and not force:
+ return line
+
+ text_size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
+ if text_size.x <= max_width and not force:
+ return line
+
+ ellipsis = "..."
+ # If force=True and line fits, just append ellipsis without truncating
+ if force and text_size.x <= max_width:
+ ellipsis_size = measure_text_cached(self._font, ellipsis, self._font_size, self._spacing_pixels)
+ if text_size.x + ellipsis_size.x <= max_width:
+ return line + ellipsis
+ # If line + ellipsis doesn't fit, need to truncate
+ # Fall through to binary search below
+
+ left, right = 0, len(line)
+ while left < right:
+ mid = (left + right) // 2
+ candidate = line[:mid] + ellipsis
+ candidate_size = measure_text_cached(self._font, candidate, self._font_size, self._spacing_pixels)
+ if candidate_size.x <= max_width:
+ left = mid + 1
+ else:
+ right = mid
+ return line[:left - 1] + ellipsis if left > 0 else ellipsis
+
+ def get_content_height(self, max_width: int) -> float:
+ """
+ Returns the height needed for text at given max_width.
+ Similar to HtmlRenderer.get_total_height().
+ """
+ # Use max_width if provided, otherwise use self._max_width or a default
+ width = max_width if max_width > 0 else (self._max_width if self._max_width else 1000)
+ self._update_text_cache(width)
+
+ if self._cached_total_height is not None:
+ return self._cached_total_height
+ return 0.0
+
+ def _render(self, rect: rl.Rectangle):
+ """Render the label."""
+ if rect.width <= 0 or rect.height <= 0:
+ return
+
+ # Determine available width
+ available_width = rect.width
+ if self._max_width is not None:
+ available_width = min(available_width, self._max_width)
+
+ # Update text cache
+ self._update_text_cache(int(available_width))
+
+ if not self._cached_wrapped_lines:
+ return
+
+ # Calculate which lines fit in the available height
+ visible_lines: list[str] = []
+ visible_sizes: list[rl.Vector2] = []
+ visible_emojis: list[list[tuple[int, int, str]]] = []
+
+ current_height = 0.0
+ broke_early = False
+ for line, size, emojis in zip(
+ self._cached_wrapped_lines,
+ self._cached_line_sizes,
+ self._cached_line_emojis,
+ strict=True):
+
+ # Calculate height needed for this line
+ # Each line contributes its height * line_height (matching Label's behavior)
+ line_height_needed = size.y * self._line_height
+
+ # Check if this line fits
+ if current_height + line_height_needed > rect.height:
+ # This line doesn't fit
+ if len(visible_lines) == 0:
+ # First line doesn't fit by height - still show it (will be clipped by scissor if needed)
+ # Continue to add this line below
+ pass
+ else:
+ # We have visible lines and this one doesn't fit - mark that we broke early
+ broke_early = True
+ break
+
+ visible_lines.append(line)
+ visible_sizes.append(size)
+ visible_emojis.append(emojis)
+
+ current_height += line_height_needed
+
+ # If we broke early (there are more lines that don't fit) and elide is enabled, elide the last visible line
+ if broke_early and len(visible_lines) > 0 and self._elide:
+ content_width = int(available_width - (self._text_padding * 2))
+ if content_width <= 0:
+ content_width = 1
+
+ last_line_idx = len(visible_lines) - 1
+ last_line = visible_lines[last_line_idx]
+ # Force elide the last line to show "..." even if it fits in width (to indicate more content)
+ elided = self._elide_line(last_line, content_width, force=True)
+ visible_lines[last_line_idx] = elided
+ visible_sizes[last_line_idx] = measure_text_cached(self._font, elided, self._font_size, self._spacing_pixels)
+
+ if not visible_lines:
+ return
+
+ # Calculate total visible text block height
+ # First line is not changed by line_height scaling
+ total_visible_height = 0.0
+ for idx, size in enumerate(visible_sizes):
+ if idx == 0:
+ total_visible_height += size.y
+ else:
+ total_visible_height += size.y * self._line_height
+
+ # Calculate vertical alignment offset
+ if self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP:
+ start_y = rect.y
+ elif self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
+ start_y = rect.y + rect.height - total_visible_height
+ else: # TEXT_ALIGN_MIDDLE
+ start_y = rect.y + (rect.height - total_visible_height) / 2
+
+ # Render each line
+ current_y = start_y
+ for idx, (line, size, emojis) in enumerate(zip(visible_lines, visible_sizes, visible_emojis, strict=True)):
+ # Calculate horizontal position
+ if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
+ line_x = rect.x + self._text_padding
+ elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
+ line_x = rect.x + (rect.width - size.x) / 2
+ elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
+ line_x = rect.x + rect.width - size.x - self._text_padding
+ else:
+ line_x = rect.x + self._text_padding
+
+ # Render line with emojis
+ line_pos = rl.Vector2(line_x, current_y)
+ prev_index = 0
+
+ for start, end, emoji in emojis:
+ # Draw text before emoji
+ text_before = line[prev_index:start]
+ if text_before:
+ rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, self._spacing_pixels, self._text_color)
+ width_before = measure_text_cached(self._font, text_before, self._font_size, self._spacing_pixels)
+ line_pos.x += width_before.x
+
+ # Draw emoji
+ tex = emoji_tex(emoji)
+ emoji_scale = self._font_size / tex.height * FONT_SCALE
+ rl.draw_texture_ex(tex, line_pos, 0.0, emoji_scale, self._text_color)
+ # Emoji width is font_size * FONT_SCALE (as per measure_text_cached)
+ line_pos.x += self._font_size * FONT_SCALE
+ prev_index = end
+
+ # Draw remaining text after last emoji
+ text_after = line[prev_index:]
+ if text_after:
+ rl.draw_text_ex(self._font, text_after, line_pos, self._font_size, self._spacing_pixels, self._text_color)
+
+ # Move to next line (if not last line)
+ if idx < len(visible_lines) - 1:
+ # Use current line's height * line_height for spacing to next line
+ current_y += size.y * self._line_height
diff --git a/system/ui/widgets/mici_keyboard.py b/system/ui/widgets/mici_keyboard.py
new file mode 100644
index 000000000..2eb24cdb9
--- /dev/null
+++ b/system/ui/widgets/mici_keyboard.py
@@ -0,0 +1,388 @@
+from enum import IntEnum
+import pyray as rl
+import numpy as np
+from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent
+from openpilot.system.ui.lib.text_measure import measure_text_cached
+from openpilot.system.ui.widgets import Widget
+from openpilot.common.filter_simple import BounceFilter
+
+CHAR_FONT_SIZE = 42
+CHAR_NEAR_FONT_SIZE = CHAR_FONT_SIZE * 2
+SELECTED_CHAR_FONT_SIZE = 128
+CHAR_CAPS_FONT_SIZE = 38 # TODO: implement this
+NUMBER_LAYER_SWITCH_FONT_SIZE = 24
+KEYBOARD_COLUMN_PADDING = 33
+KEYBOARD_ROW_PADDING = {0: 44, 1: 33, 2: 44} # TODO: 2 should be 116 with extra control keys added in
+
+KEY_TOUCH_AREA_OFFSET = 10 # px
+KEY_DRAG_HYSTERESIS = 5 # px
+KEY_MIN_ANIMATION_TIME = 0.075 # s
+
+DEBUG = False
+ANIMATION_SCALE = 0.65
+
+
+def zip_repeat(a, b):
+ la, lb = len(a), len(b)
+ for i in range(max(la, lb)):
+ yield (a[i] if i < la else a[-1],
+ b[i] if i < lb else b[-1])
+
+
+def fast_euclidean_distance(dx, dy):
+ # https://en.wikibooks.org/wiki/Algorithms/Distance_approximations
+ max_d, min_d = abs(dx), abs(dy)
+ if max_d < min_d:
+ max_d, min_d = min_d, max_d
+ return 0.941246 * max_d + 0.41 * min_d
+
+
+class Key(Widget):
+ def __init__(self, char: str):
+ super().__init__()
+ self.char = char
+ self._font = gui_app.font(FontWeight.SEMI_BOLD)
+ self._x_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
+ self._y_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
+ self._size_filter = BounceFilter(CHAR_FONT_SIZE, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
+ self._alpha_filter = BounceFilter(1.0, 0.075 * ANIMATION_SCALE, 1 / gui_app.target_fps)
+
+ self._color = rl.Color(255, 255, 255, 255)
+
+ self._position_initialized = False
+ self.original_position = rl.Vector2(0, 0)
+
+ def set_position(self, x: float, y: float, smooth: bool = True):
+ # TODO: swipe up from NavWidget has the keys lag behind other elements a bit
+ if not self._position_initialized:
+ self._x_filter.x = x
+ self._y_filter.x = y
+ # keep track of original position so dragging around feels consistent. also move touch area down a bit
+ self.original_position = rl.Vector2(x, y + KEY_TOUCH_AREA_OFFSET)
+ self._position_initialized = True
+
+ if not smooth:
+ self._x_filter.x = x
+ self._y_filter.x = y
+
+ self._rect.x = self._x_filter.update(x)
+ self._rect.y = self._y_filter.update(y)
+
+ def set_alpha(self, alpha: float):
+ self._alpha_filter.update(alpha)
+
+ def get_position(self) -> tuple[float, float]:
+ return self._rect.x, self._rect.y
+
+ def _update_state(self):
+ self._color.a = min(int(255 * self._alpha_filter.x), 255)
+
+ def _render(self, _):
+ # center char at rect position
+ text_size = measure_text_cached(self._font, self.char, self._get_font_size())
+ x = self._rect.x + self._rect.width / 2 - text_size.x / 2
+ y = self._rect.y + self._rect.height / 2 - text_size.y / 2
+ rl.draw_text_ex(self._font, self.char, (x, y), self._get_font_size(), 0, self._color)
+
+ if DEBUG:
+ rl.draw_circle(int(self._rect.x), int(self._rect.y), 5, rl.RED) # Debug: draw circle around key
+ rl.draw_rectangle_lines_ex(self._rect, 2, rl.RED)
+
+ def set_font_size(self, size: float):
+ self._size_filter.update(size)
+
+ def _get_font_size(self) -> int:
+ return int(round(self._size_filter.x))
+
+
+class SmallKey(Key):
+ def __init__(self, chars: str):
+ super().__init__(chars)
+ self._size_filter.x = NUMBER_LAYER_SWITCH_FONT_SIZE
+
+ def set_font_size(self, size: float):
+ self._size_filter.update(size * (NUMBER_LAYER_SWITCH_FONT_SIZE / CHAR_FONT_SIZE))
+
+
+class IconKey(Key):
+ def __init__(self, icon: str, vertical_align: str = "center", char: str = ""):
+ super().__init__(char)
+ self._icon = gui_app.texture(icon, 38, 38)
+ self._vertical_align = vertical_align
+
+ def set_icon(self, icon: str):
+ self._icon = gui_app.texture(icon, 38, 38)
+
+ def _render(self, _):
+ scale = np.interp(self._size_filter.x, [CHAR_FONT_SIZE, CHAR_NEAR_FONT_SIZE], [1, 1.5])
+
+ if self._vertical_align == "center":
+ dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2,
+ self._rect.y + (self._rect.height - self._icon.height * scale) / 2,
+ self._icon.width * scale, self._icon.height * scale)
+ src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
+ rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color)
+
+ rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color)
+
+ elif self._vertical_align == "bottom":
+ dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2, self._rect.y,
+ self._icon.width * scale, self._icon.height * scale)
+ src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
+ rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color)
+
+ if DEBUG:
+ rl.draw_circle(int(self._rect.x), int(self._rect.y), 5, rl.RED) # Debug: draw circle around key
+ rl.draw_rectangle_lines_ex(self._rect, 2, rl.RED)
+
+
+class CapsState(IntEnum):
+ LOWER = 0
+ UPPER = 1
+ LOCK = 2
+
+
+class MiciKeyboard(Widget):
+ def __init__(self):
+ super().__init__()
+
+ lower_chars = [
+ "qwertyuiop",
+ "asdfghjkl",
+ "zxcvbnm",
+ ]
+ upper_chars = ["".join([char.upper() for char in row]) for row in lower_chars]
+ special_chars = [
+ "1234567890",
+ "-/:;()$&@\"",
+ "~.,?!'#%",
+ ]
+ super_special_chars = [
+ "1234567890",
+ "`[]{}^*+=_",
+ "\\|<>¥€£•",
+ ]
+
+ self._lower_keys = [[Key(char) for char in row] for row in lower_chars]
+ self._upper_keys = [[Key(char) for char in row] for row in upper_chars]
+ self._special_keys = [[Key(char) for char in row] for row in special_chars]
+ self._super_special_keys = [[Key(char) for char in row] for row in super_special_chars]
+
+ # control keys
+ self._space_key = IconKey("icons_mici/settings/keyboard/space.png", char=" ", vertical_align="bottom")
+ self._caps_key = IconKey("icons_mici/settings/keyboard/caps_lower.png")
+ # these two are in different places on some layouts
+ self._123_key, self._123_key2 = SmallKey("123"), SmallKey("123")
+ self._abc_key = SmallKey("abc")
+ self._super_special_key = SmallKey("#+=")
+
+ # insert control keys
+ for keys in (self._lower_keys, self._upper_keys):
+ keys[2].insert(0, self._caps_key)
+ keys[2].append(self._123_key)
+
+ for keys in (self._lower_keys, self._upper_keys, self._special_keys, self._super_special_keys):
+ keys[1].append(self._space_key)
+
+ for keys in (self._special_keys, self._super_special_keys):
+ keys[2].append(self._abc_key)
+
+ self._special_keys[2].insert(0, self._super_special_key)
+ self._super_special_keys[2].insert(0, self._123_key2)
+
+ # set initial keys
+ self._current_keys: list[list[Key]] = []
+ self._set_keys(self._lower_keys)
+ self._caps_state = CapsState.LOWER
+ self._initialized = False
+
+ self._load_images()
+
+ self._closest_key: tuple[Key | None, float] = None, float('inf')
+ self._selected_key_t: float | None = None # time key was initially selected
+ self._unselect_key_t: float | None = None # time to unselect key after release
+ self._dragging_on_keyboard = False
+
+ self._text: str = ""
+
+ self._bg_scale_filter = BounceFilter(1.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
+
+ def get_candidate_character(self) -> str:
+ # return str of character about to be added to text
+ key = self._closest_key[0]
+ return key.char if key is not None and key.__class__ is Key and self._dragging_on_keyboard else ""
+
+ def get_keyboard_height(self) -> int:
+ return int(self._txt_bg.height)
+
+ def _load_images(self):
+ self._txt_bg = gui_app.texture("icons_mici/settings/keyboard/keyboard_background.png", 520, 170, keep_aspect_ratio=False)
+
+ def _set_keys(self, keys: list[list[Key]]):
+ # inherit previous keys' positions to fix switching animation
+ for current_row, row in zip(self._current_keys, keys, strict=False):
+ # not all layouts have the same number of keys
+ for current_key, key in zip_repeat(current_row, row):
+ current_pos = current_key.get_position()
+ key.set_position(current_pos[0], current_pos[1], smooth=False)
+
+ self._current_keys = keys
+
+ def set_text(self, text: str):
+ self._text = text
+
+ def text(self) -> str:
+ return self._text
+
+ def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
+ keyboard_pos_y = self._rect.y + self._rect.height - self._txt_bg.height
+ if mouse_event.left_pressed:
+ if mouse_event.pos.y > keyboard_pos_y:
+ self._dragging_on_keyboard = True
+ elif mouse_event.left_released:
+ self._dragging_on_keyboard = False
+
+ if mouse_event.left_down and self._dragging_on_keyboard:
+ self._closest_key = self._get_closest_key()
+ if self._selected_key_t is None:
+ self._selected_key_t = rl.get_time()
+
+ # unselect key temporarily if mouse goes above keyboard
+ if mouse_event.pos.y <= keyboard_pos_y:
+ self._closest_key = (None, float('inf'))
+
+ if DEBUG:
+ print('HANDLE MOUSE EVENT', mouse_event, self._closest_key[0].char if self._closest_key[0] else 'None')
+
+ def _get_closest_key(self) -> tuple[Key | None, float]:
+ closest_key: tuple[Key | None, float] = (None, float('inf'))
+ for row in self._current_keys:
+ for key in row:
+ mouse_pos = gui_app.last_mouse_event.pos
+ # approximate distance for comparison is accurate enough
+ dist = abs(key.original_position.x - mouse_pos.x) + abs(key.original_position.y - mouse_pos.y)
+ if dist < closest_key[1]:
+ if self._closest_key[0] is None or key is self._closest_key[0] or dist < self._closest_key[1] - KEY_DRAG_HYSTERESIS:
+ closest_key = (key, dist)
+ return closest_key
+
+ def _set_uppercase(self, cycle: bool):
+ self._set_keys(self._upper_keys if cycle else self._lower_keys)
+ if not cycle:
+ self._caps_state = CapsState.LOWER
+ self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lower.png")
+ else:
+ if self._caps_state == CapsState.LOWER:
+ self._caps_state = CapsState.UPPER
+ self._caps_key.set_icon("icons_mici/settings/keyboard/caps_upper.png")
+ elif self._caps_state == CapsState.UPPER:
+ self._caps_state = CapsState.LOCK
+ self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lock.png")
+ else:
+ self._set_uppercase(False)
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ if self._closest_key[0] is not None:
+ if self._closest_key[0] == self._caps_key:
+ self._set_uppercase(True)
+ elif self._closest_key[0] in (self._123_key, self._123_key2):
+ self._set_keys(self._special_keys)
+ elif self._closest_key[0] == self._abc_key:
+ self._set_uppercase(False)
+ elif self._closest_key[0] == self._super_special_key:
+ self._set_keys(self._super_special_keys)
+ else:
+ self._text += self._closest_key[0].char
+
+ # Reset caps state
+ if self._caps_state == CapsState.UPPER:
+ self._set_uppercase(False)
+
+ # ensure minimum selected animation time
+ key_selected_dt = rl.get_time() - (self._selected_key_t or 0)
+ cur_t = rl.get_time()
+ self._unselect_key_t = cur_t + KEY_MIN_ANIMATION_TIME if (key_selected_dt < KEY_MIN_ANIMATION_TIME) else cur_t
+
+ def backspace(self):
+ if self._text:
+ self._text = self._text[:-1]
+
+ def space(self):
+ self._text += ' '
+
+ def _update_state(self):
+ # unselect key after animation plays
+ if self._unselect_key_t is not None and rl.get_time() > self._unselect_key_t:
+ self._closest_key = (None, float('inf'))
+ self._unselect_key_t = None
+ self._selected_key_t = None
+
+ def _lay_out_keys(self, bg_x, bg_y, keys: list[list[Key]]):
+ key_rect = rl.Rectangle(bg_x, bg_y, self._txt_bg.width, self._txt_bg.height)
+ for row_idx, row in enumerate(keys):
+ padding = KEYBOARD_ROW_PADDING[row_idx]
+ step_y = (key_rect.height - 2 * KEYBOARD_COLUMN_PADDING) / (len(keys) - 1)
+ for key_idx, key in enumerate(row):
+ key_x = key_rect.x + padding + key_idx * ((key_rect.width - 2 * padding) / (len(row) - 1))
+ key_y = key_rect.y + KEYBOARD_COLUMN_PADDING + row_idx * step_y
+
+ if self._closest_key[0] is None:
+ key.set_alpha(1.0)
+ key.set_font_size(CHAR_FONT_SIZE)
+ elif key == self._closest_key[0]:
+ # push key up with a max and inward so user can see key easier
+ key_y = max(key_y - 120, 40)
+ key_x += np.interp(key_x, [self._rect.x, self._rect.x + self._rect.width], [100, -100])
+ key.set_alpha(1.0)
+ key.set_font_size(SELECTED_CHAR_FONT_SIZE)
+
+ # draw black circle behind selected key
+ rl.draw_circle_gradient(int(key_x + key.rect.width / 2), int(key_y + key.rect.height / 2),
+ SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, 225), rl.BLANK)
+ else:
+ # move other keys away from selected key a bit
+ dx = key.original_position.x - self._closest_key[0].original_position.x
+ dy = key.original_position.y - self._closest_key[0].original_position.y
+ distance_from_selected_key = fast_euclidean_distance(dx, dy)
+
+ inv = 1 / (distance_from_selected_key or 1.0)
+ ux = dx * inv
+ uy = dy * inv
+
+ # NOTE: hardcode to 20 to get entire keyboard to move
+ push_pixels = np.interp(distance_from_selected_key, [0, 250], [20, 0])
+ key_x += ux * push_pixels
+ key_y += uy * push_pixels
+
+ # TODO: slow enough to use an approximation or nah? also caching might work
+ font_size = np.interp(distance_from_selected_key, [0, 150], [CHAR_NEAR_FONT_SIZE, CHAR_FONT_SIZE])
+
+ key_alpha = np.interp(distance_from_selected_key, [0, 100], [1.0, 0.35])
+ key.set_alpha(key_alpha)
+ key.set_font_size(font_size)
+
+ # TODO: I like the push amount, so we should clip the pos inside the keyboard rect
+ key.set_position(key_x, key_y)
+
+ def _render(self, _):
+ # draw bg
+ bg_x = self._rect.x + (self._rect.width - self._txt_bg.width) / 2
+ bg_y = self._rect.y + self._rect.height - self._txt_bg.height
+
+ scale = self._bg_scale_filter.update(1.0307692307692307 if self._closest_key[0] is not None else 1.0)
+ src_rec = rl.Rectangle(0, 0, self._txt_bg.width, self._txt_bg.height)
+ dest_rec = rl.Rectangle(self._rect.x + self._rect.width / 2 - self._txt_bg.width * scale / 2, bg_y,
+ self._txt_bg.width * scale, self._txt_bg.height)
+
+ rl.draw_texture_pro(self._txt_bg, src_rec, dest_rec, rl.Vector2(0, 0), 0.0, rl.WHITE)
+
+ # draw keys
+ if not self._initialized:
+ for keys in (self._lower_keys, self._upper_keys, self._special_keys, self._super_special_keys):
+ self._lay_out_keys(bg_x, bg_y, keys)
+ self._initialized = True
+
+ self._lay_out_keys(bg_x, bg_y, self._current_keys)
+ for row in self._current_keys:
+ for key in row:
+ key.render()
diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py
index 592c9de97..f41a04c24 100644
--- a/system/ui/widgets/network.py
+++ b/system/ui/widgets/network.py
@@ -12,7 +12,7 @@ from openpilot.system.ui.widgets.button import ButtonStyle, Button
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.label import gui_label
-from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item
# These are only used for AdvancedNetworkSettings, standalone apps just need WifiManagerUI
diff --git a/system/ui/widgets/option_dialog.py b/system/ui/widgets/option_dialog.py
index 3b2201164..62578d1cf 100644
--- a/system/ui/widgets/option_dialog.py
+++ b/system/ui/widgets/option_dialog.py
@@ -4,7 +4,7 @@ from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_label
-from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.scroller_tici import Scroller
# Constants
MARGIN = 50
diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py
index a843010d5..fb7f635be 100644
--- a/system/ui/widgets/scroller.py
+++ b/system/ui/widgets/scroller.py
@@ -1,10 +1,21 @@
import pyray as rl
-from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
+import numpy as np
+from collections.abc import Callable
+
+from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState
from openpilot.system.ui.widgets import Widget
-ITEM_SPACING = 40
+ITEM_SPACING = 20
LINE_COLOR = rl.GRAY
LINE_PADDING = 40
+ANIMATION_SCALE = 0.6
+
+MIN_ZOOM_ANIMATION_TIME = 0.075 # seconds
+DO_ZOOM = False
+DO_JELLO = False
+SCROLL_BAR = False
class LineSeparator(Widget):
@@ -23,21 +34,131 @@ class LineSeparator(Widget):
class Scroller(Widget):
- def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True):
+ def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = True, spacing: int = ITEM_SPACING,
+ line_separator: bool = False, pad_start: int = ITEM_SPACING, pad_end: int = ITEM_SPACING):
super().__init__()
self._items: list[Widget] = []
+ self._horizontal = horizontal
+ self._snap_items = snap_items
self._spacing = spacing
self._line_separator = LineSeparator() if line_separator else None
+ self._pad_start = pad_start
self._pad_end = pad_end
- self.scroll_panel = GuiScrollPanel()
+ self._reset_scroll_at_show = True
+
+ self._scrolling_to: float | None = None
+ self._scroll_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
+ self._zoom_filter = FirstOrderFilter(1.0, 0.2, 1 / gui_app.target_fps)
+ self._zoom_out_t: float = 0.0
+
+ self._item_pos_filter = BounceFilter(0.0, 0.05, 1 / gui_app.target_fps)
+
+ # when not pressed, snap to closest item to be center
+ self._scroll_snap_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
+
+ self.scroll_panel = GuiScrollPanel2(self._horizontal, handle_out_of_bounds=not self._snap_items)
+ self._scroll_enabled: bool | Callable[[], bool] = True
+
+ self._txt_scroll_indicator = gui_app.texture("icons_mici/settings/vertical_scroll_indicator.png", 40, 80)
for item in items:
self.add_widget(item)
+ def set_reset_scroll_at_show(self, scroll: bool):
+ self._reset_scroll_at_show = scroll
+
+ def scroll_to(self, pos: float, smooth: bool = False):
+ # already there
+ if abs(pos) < 1:
+ return
+
+ # FIXME: the padding correction doesn't seem correct
+ scroll_offset = self.scroll_panel.get_offset() - pos + self._pad_end
+ if smooth:
+ self._scrolling_to = scroll_offset
+ else:
+ self.scroll_panel.set_offset(scroll_offset)
+
+ @property
+ def is_auto_scrolling(self) -> bool:
+ return self._scrolling_to is not None
+
def add_widget(self, item: Widget) -> None:
self._items.append(item)
- item.set_touch_valid_callback(self.scroll_panel.is_touch_valid)
+ item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled)
+
+ def set_scrolling_enabled(self, enabled: bool | Callable[[], bool]) -> None:
+ """Set whether scrolling is enabled (does not affect widget enabled state)."""
+ self._scroll_enabled = enabled
+
+ def _update_state(self):
+ if DO_ZOOM:
+ if self._scrolling_to is not None or self.scroll_panel.state != ScrollState.STEADY:
+ self._zoom_out_t = rl.get_time() + MIN_ZOOM_ANIMATION_TIME
+ self._zoom_filter.update(0.85)
+ else:
+ if self._zoom_out_t is not None:
+ if rl.get_time() > self._zoom_out_t:
+ self._zoom_filter.update(1.0)
+ else:
+ self._zoom_filter.update(0.85)
+
+ # Cancel auto-scroll if user starts manually scrolling
+ if self._scrolling_to is not None and (self.scroll_panel.state == ScrollState.PRESSED or self.scroll_panel.state == ScrollState.MANUAL_SCROLL):
+ self._scrolling_to = None
+
+ if self._scrolling_to is not None:
+ self._scroll_filter.update(self._scrolling_to)
+ self.scroll_panel.set_offset(self._scroll_filter.x)
+
+ if abs(self._scroll_filter.x - self._scrolling_to) < 1:
+ self.scroll_panel.set_offset(self._scrolling_to)
+ self._scrolling_to = None
+ else:
+ # keep current scroll position up to date
+ self._scroll_filter.x = self.scroll_panel.get_offset()
+
+ def _get_scroll(self, visible_items: list[Widget], content_size: float) -> float:
+ scroll_enabled = self._scroll_enabled() if callable(self._scroll_enabled) else self._scroll_enabled
+ self.scroll_panel.set_enabled(scroll_enabled and self.enabled)
+ self.scroll_panel.update(self._rect, content_size)
+ if not self._snap_items:
+ return self.scroll_panel.get_offset()
+
+ # Snap closest item to center
+ center_pos = self._rect.x + self._rect.width / 2 if self._horizontal else self._rect.y + self._rect.height / 2
+ closest_delta_pos = float('inf')
+ scroll_snap_idx: int | None = None
+ for idx, item in enumerate(visible_items):
+ if self._horizontal:
+ delta_pos = (item.rect.x + item.rect.width / 2) - center_pos
+ else:
+ delta_pos = (item.rect.y + item.rect.height / 2) - center_pos
+ if abs(delta_pos) < abs(closest_delta_pos):
+ closest_delta_pos = delta_pos
+ scroll_snap_idx = idx
+
+ if scroll_snap_idx is not None:
+ snap_item = visible_items[scroll_snap_idx]
+ if self.is_pressed:
+ # no snapping until released
+ self._scroll_snap_filter.x = 0
+ else:
+ # TODO: this doesn't handle two small buttons at the edges well
+ if self._horizontal:
+ snap_delta_pos = (center_pos - (snap_item.rect.x + snap_item.rect.width / 2)) / 10
+ snap_delta_pos = min(snap_delta_pos, -self.scroll_panel.get_offset() / 10)
+ snap_delta_pos = max(snap_delta_pos, (self._rect.width - self.scroll_panel.get_offset() - content_size) / 10)
+ else:
+ snap_delta_pos = (center_pos - (snap_item.rect.y + snap_item.rect.height / 2)) / 10
+ snap_delta_pos = min(snap_delta_pos, -self.scroll_panel.get_offset() / 10)
+ snap_delta_pos = max(snap_delta_pos, (self._rect.height - self.scroll_panel.get_offset() - content_size) / 10)
+ self._scroll_snap_filter.update(snap_delta_pos)
+
+ self.scroll_panel.set_offset(self.scroll_panel.get_offset() + self._scroll_snap_filter.x)
+
+ return self.scroll_panel.get_offset()
def _render(self, _):
# TODO: don't draw items that are not in the viewport
@@ -49,38 +170,78 @@ class Scroller(Widget):
for i in range(1, len(visible_items)):
visible_items.insert(l - i, self._line_separator)
- 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.update(self._rect, rl.Rectangle(0, 0, self._rect.width, content_height))
+ content_size = sum(item.rect.width if self._horizontal else item.rect.height for item in visible_items)
+ content_size += self._spacing * (len(visible_items) - 1)
+ content_size += self._pad_start + self._pad_end
+
+ scroll_offset = self._get_scroll(visible_items, content_size)
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
+ self._item_pos_filter.update(scroll_offset)
- # 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)
+ cur_pos = 0
+ for idx, item in enumerate(visible_items):
+ spacing = self._spacing if (idx > 0) else self._pad_start
+ # Nicely lay out items horizontally/vertically
+ if self._horizontal:
+ x = self._rect.x + cur_pos + spacing
+ y = self._rect.y + (self._rect.height - item.rect.height) / 2
+ cur_pos += item.rect.width + spacing
+ else:
+ x = self._rect.x + (self._rect.width - item.rect.width) / 2
+ y = self._rect.y + cur_pos + spacing
+ cur_pos += item.rect.height + spacing
# Consider scroll
- y += scroll
+ if self._horizontal:
+ x += scroll_offset
+ else:
+ y += scroll_offset
+
+ # Add some jello effect when scrolling
+ if DO_JELLO:
+ if self._horizontal:
+ cx = self._rect.x + self._rect.width / 2
+ jello_offset = scroll_offset - np.interp(x + item.rect.width / 2,
+ [self._rect.x, cx, self._rect.x + self._rect.width],
+ [self._item_pos_filter.x, scroll_offset, self._item_pos_filter.x])
+ x -= np.clip(jello_offset, -20, 20)
+ else:
+ cy = self._rect.y + self._rect.height / 2
+ jello_offset = scroll_offset - np.interp(y + item.rect.height / 2,
+ [self._rect.y, cy, self._rect.y + self._rect.height],
+ [self._item_pos_filter.x, scroll_offset, self._item_pos_filter.x])
+ y -= np.clip(jello_offset, -20, 20)
# Update item state
- item.set_position(x, y)
+ item.set_position(round(x), round(y)) # round to prevent jumping when settling
item.set_parent_rect(self._rect)
+
+ # Scale each element around its own origin when scrolling
+ scale = self._zoom_filter.x
+ rl.rl_push_matrix()
+ rl.rl_scalef(scale, scale, 1.0)
+ rl.rl_translatef((1 - scale) * (x + item.rect.width / 2) / scale,
+ (1 - scale) * (y + item.rect.height / 2) / scale, 0)
item.render()
+ rl.rl_pop_matrix()
+
+ # Draw scroll indicator
+ if SCROLL_BAR and not self._horizontal and len(visible_items) > 0:
+ _real_content_size = content_size - self._rect.height + self._txt_scroll_indicator.height
+ scroll_bar_y = -scroll_offset / _real_content_size * self._rect.height
+ scroll_bar_y = min(max(scroll_bar_y, self._rect.y), self._rect.y + self._rect.height - self._txt_scroll_indicator.height)
+ rl.draw_texture_ex(self._txt_scroll_indicator, rl.Vector2(self._rect.x, scroll_bar_y), 0, 1.0, rl.WHITE)
rl.end_scissor_mode()
def show_event(self):
super().show_event()
- # Reset to top
- self.scroll_panel.set_offset(0)
+ if self._reset_scroll_at_show:
+ self.scroll_to(self.scroll_panel.get_offset())
+
for item in self._items:
item.show_event()
diff --git a/system/ui/widgets/scroller_tici.py b/system/ui/widgets/scroller_tici.py
new file mode 100644
index 000000000..a843010d5
--- /dev/null
+++ b/system/ui/widgets/scroller_tici.py
@@ -0,0 +1,90 @@
+import pyray as rl
+from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
+from openpilot.system.ui.widgets import Widget
+
+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, 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 = LineSeparator() if line_separator else None
+ self._pad_end = pad_end
+
+ self.scroll_panel = GuiScrollPanel()
+
+ for item in items:
+ self.add_widget(item)
+
+ def add_widget(self, item: Widget) -> None:
+ 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]
+
+ # Add line separator between items
+ if self._line_separator is not None:
+ l = len(visible_items)
+ for i in range(1, len(visible_items)):
+ visible_items.insert(l - i, self._line_separator)
+
+ 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.update(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
+ y += scroll
+
+ # Update item state
+ item.set_position(x, y)
+ item.set_parent_rect(self._rect)
+ item.render()
+
+ rl.end_scissor_mode()
+
+ def show_event(self):
+ super().show_event()
+ # Reset to top
+ self.scroll_panel.set_offset(0)
+ for item in self._items:
+ item.show_event()
+
+ def hide_event(self):
+ super().hide_event()
+ for item in self._items:
+ item.hide_event()
diff --git a/system/ui/widgets/slider.py b/system/ui/widgets/slider.py
new file mode 100644
index 000000000..b17d8f3b7
--- /dev/null
+++ b/system/ui/widgets/slider.py
@@ -0,0 +1,183 @@
+from collections.abc import Callable
+
+import pyray as rl
+
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.label import UnifiedLabel
+from openpilot.common.filter_simple import FirstOrderFilter
+
+
+class SmallSlider(Widget):
+ HORIZONTAL_PADDING = 8
+ CONFIRM_DELAY = 0.2
+
+ def __init__(self, title: str, confirm_callback: Callable | None = None):
+ # TODO: unify this with BigConfirmationDialogV2
+ super().__init__()
+ self._confirm_callback = confirm_callback
+
+ self._font = gui_app.font(FontWeight.DISPLAY)
+
+ self._load_assets()
+
+ self._drag_threshold = -self._rect.width // 2
+
+ # State
+ self._opacity = 1.0
+ self._confirmed_time = 0.0
+ self._confirm_callback_called = False # we keep dialog open by default, only call once
+ self._start_x_circle = 0.0
+ self._scroll_x_circle = 0.0
+ self._scroll_x_circle_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
+
+ self._is_dragging_circle = False
+
+ self._label = UnifiedLabel(title, font_size=36, font_weight=FontWeight.MEDIUM, text_color=rl.Color(255, 255, 255, int(255 * 0.65)),
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9)
+
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 316 + self.HORIZONTAL_PADDING * 2, 100))
+
+ self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg.png", 316, 100)
+ self._circle_bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_red_circle.png", 100, 100)
+ self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 37, 32)
+
+ @property
+ def confirmed(self) -> bool:
+ return self._confirmed_time > 0.0
+
+ def reset(self):
+ # reset all slider state
+ self._is_dragging_circle = False
+ self._confirmed_time = 0.0
+ self._confirm_callback_called = False
+
+ def set_opacity(self, opacity: float):
+ self._opacity = opacity
+
+ @property
+ def slider_percentage(self):
+ activated_pos = -self._bg_txt.width + self._circle_bg_txt.width
+ return min(max(-self._scroll_x_circle_filter.x / abs(activated_pos), 0.0), 1.0)
+
+ def _on_confirm(self):
+ if self._confirm_callback:
+ self._confirm_callback()
+
+ def _handle_mouse_event(self, mouse_event):
+ super()._handle_mouse_event(mouse_event)
+
+ if mouse_event.left_pressed:
+ # touch rect goes to the padding
+ circle_button_rect = rl.Rectangle(
+ self._rect.x + (self._rect.width - self._circle_bg_txt.width) + self._scroll_x_circle_filter.x - self.HORIZONTAL_PADDING * 2,
+ self._rect.y,
+ self._circle_bg_txt.width + self.HORIZONTAL_PADDING * 2,
+ self._rect.height,
+ )
+ if rl.check_collision_point_rec(mouse_event.pos, circle_button_rect):
+ self._start_x_circle = mouse_event.pos.x
+ self._is_dragging_circle = True
+
+ elif mouse_event.left_released:
+ # swiped to left
+ if self._scroll_x_circle_filter.x < self._drag_threshold:
+ self._confirmed_time = rl.get_time()
+
+ self._is_dragging_circle = False
+
+ if self._is_dragging_circle:
+ self._scroll_x_circle = mouse_event.pos.x - self._start_x_circle
+
+ def _update_state(self):
+ super()._update_state()
+ # TODO: this math can probably be cleaned up to remove duplicate stuff
+ activated_pos = int(-self._bg_txt.width + self._circle_bg_txt.width)
+ self._scroll_x_circle = max(min(self._scroll_x_circle, 0), activated_pos)
+
+ if self._confirmed_time > 0:
+ # swiped left to confirm
+ self._scroll_x_circle_filter.update(activated_pos)
+
+ # activate once animation completes, small threshold for small floats
+ if self._scroll_x_circle_filter.x < (activated_pos + 1):
+ if not self._confirm_callback_called and (rl.get_time() - self._confirmed_time) >= self.CONFIRM_DELAY:
+ self._on_confirm()
+ self._confirm_callback_called = True
+
+ elif not self._is_dragging_circle:
+ # reset back to right
+ self._scroll_x_circle_filter.update(0)
+ else:
+ # not activated yet, keep movement 1:1
+ self._scroll_x_circle_filter.x = self._scroll_x_circle
+
+ def _render(self, _):
+ # TODO: iOS text shimmering animation
+
+ white = rl.Color(255, 255, 255, int(255 * self._opacity))
+
+ bg_txt_x = self._rect.x + (self._rect.width - self._bg_txt.width) / 2
+ bg_txt_y = self._rect.y + (self._rect.height - self._bg_txt.height) / 2
+ rl.draw_texture_ex(self._bg_txt, rl.Vector2(bg_txt_x, bg_txt_y), 0.0, 1.0, white)
+
+ btn_x = bg_txt_x + self._bg_txt.width - self._circle_bg_txt.width + self._scroll_x_circle_filter.x
+ btn_y = self._rect.y + (self._rect.height - self._circle_bg_txt.height) / 2
+
+ if self._confirmed_time == 0.0 or self._scroll_x_circle > 0:
+ self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.65 * (1.0 - self.slider_percentage) * self._opacity)))
+ label_rect = rl.Rectangle(
+ self._rect.x + 20,
+ self._rect.y,
+ self._rect.width - self._circle_bg_txt.width - 20 * 3,
+ self._rect.height,
+ )
+ self._label.render(label_rect)
+
+ # circle and arrow
+ rl.draw_texture_ex(self._circle_bg_txt, rl.Vector2(btn_x, btn_y), 0.0, 1.0, white)
+
+ arrow_x = btn_x + (self._circle_bg_txt.width - self._circle_arrow_txt.width) / 2
+ arrow_y = btn_y + (self._circle_bg_txt.height - self._circle_arrow_txt.height) / 2
+ rl.draw_texture_ex(self._circle_arrow_txt, rl.Vector2(arrow_x, arrow_y), 0.0, 1.0, white)
+
+
+class LargerSlider(SmallSlider):
+ def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True):
+ self._green = green
+ super().__init__(title, confirm_callback=confirm_callback)
+
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 115))
+
+ self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg_larger.png", 520, 115)
+ circle_fn = "slider_green_rounded_rectangle" if self._green else "slider_black_rounded_rectangle"
+ self._circle_bg_txt = gui_app.texture(f"icons_mici/setup/small_slider/{circle_fn}.png", 180, 115)
+ self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 55)
+
+
+class BigSlider(SmallSlider):
+ def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable | None = None):
+ self._icon = icon
+ super().__init__(title, confirm_callback=confirm_callback)
+ self._label = UnifiedLabel(title, font_size=48, font_weight=FontWeight.DISPLAY, text_color=rl.Color(255, 255, 255, int(255 * 0.65)),
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
+ line_height=0.875)
+
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180))
+
+ self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180)
+ self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180)
+ self._circle_arrow_txt = self._icon
+
+
+class RedBigSlider(BigSlider):
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180))
+
+ self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180)
+ self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180)
+ self._circle_arrow_txt = self._icon