Compare commits

..

82 Commits

Author SHA1 Message Date
nayan
9f17e34e1f Merge remote-tracking branch 'origin/sync-20251114' into nayan-raylib 2025-11-15 08:52:45 -05:00
Jason Wen
73dca101a2 Revert "let's update docs here"
This reverts commit 51fe03cd51.
2025-11-15 02:10:32 -05:00
Jason Wen
51fe03cd51 let's update docs here 2025-11-15 02:06:45 -05:00
Jason Wen
201504ab27 too extra 2025-11-15 01:53:04 -05:00
Jason Wen
88d03af58a need to bring this back 2025-11-15 01:51:50 -05:00
Jason Wen
0f6ea18d14 too extra red diff on the side 2025-11-15 01:39:21 -05:00
nayan
a453aa2c51 tree option dialog + sample 2025-11-14 20:34:18 -05:00
nayan
d08f685982 fixes for latest merge 2025-11-14 20:34:03 -05:00
nayan
cb4d13643a Merge remote-tracking branch 'origin/sync-20251114' into nayan-raylib
# Conflicts:
#	selfdrive/ui/layouts/settings/developer.py
#	selfdrive/ui/sunnypilot/qt/offroad/settings/developer_panel.cc
#	selfdrive/ui/sunnypilot/qt/offroad/settings/developer_panel.h
#	system/manager/process_config.py
#	system/ui/lib/scroll_panel.py
#	system/ui/sunnypilot/lib/application.py
2025-11-14 19:55:13 -05:00
Jason Wen
9e0a88bc5e no more 2025-11-14 03:47:34 -05:00
Jason Wen
77a1c7a7a6 more fixes 2025-11-14 03:25:45 -05:00
Jason Wen
32ca63afd1 quiet mode back 2025-11-14 03:24:48 -05:00
Jason Wen
abceb1f936 raylib says wut 2025-11-14 03:21:18 -05:00
Jason Wen
63a3a95d65 should've been symlink'ed 2025-11-14 03:14:20 -05:00
Jason Wen
a77c5a97dc not yet 2025-11-14 02:57:56 -05:00
Jason Wen
370a7a8fa6 bump opendbc 2025-11-14 02:52:26 -05:00
Jason Wen
81ec281eca sum more 2025-11-14 02:51:54 -05:00
Jason Wen
ec48b66503 commaai/openpilot:7534b2a160faa683412c04c1254440e338931c5e 2025-11-14 02:25:10 -05:00
Jason Wen
a61839db40 commaai/openpilot:954b567b9ba0f3d1ae57d6aa7797fa86dd92ec6e 2025-11-14 02:16:45 -05:00
Jason Wen
e55e0949e7 commaai/openpilot:5198b1b079c37742c1050f02ce0aa6dd42b038b9 2025-11-14 02:12:47 -05:00
Jason Wen
a8ec2a4dc0 cabana: revert to stock Qt 2025-11-14 01:56:19 -05:00
Jason Wen
0c98c33e79 sunnypilot: remove Qt 2025-11-14 01:53:22 -05:00
Jason Wen
a6e43a77d9 bump opendbc 2025-11-14 01:49:18 -05:00
Jason Wen
9d1716c4f7 bump opendbc 2025-11-14 01:46:24 -05:00
Jason Wen
d9930a4f7c bump opendbc 2025-11-14 01:36:23 -05:00
Jason Wen
6f83806ae7 bump opendbc 2025-11-14 01:32:38 -05:00
Jason Wen
716e86c707 bump opendbc 2025-11-14 01:31:57 -05:00
Jason Wen
0d4cfa806b commaai/openpilot:d05cb31e2e916fba41ba8167030945f427fd811b 2025-11-14 01:19:17 -05:00
Jason Wen
c23516a8de Merge branch 'upstream/openpilot/master' into sync-20251114
# Conflicts:
#	.github/workflows/ci_weekly_run.yaml
#	.github/workflows/raylib_ui_preview.yaml
#	.github/workflows/tests.yaml
#	.gitmodules
#	README.md
#	SConstruct
#	common/api.py
#	common/params_keys.h
#	docs/CARS.md
#	msgq_repo
#	opendbc_repo
#	panda
#	selfdrive/car/tests/test_car_interfaces.py
#	selfdrive/controls/controlsd.py
#	selfdrive/controls/lib/latcontrol.py
#	selfdrive/controls/lib/latcontrol_angle.py
#	selfdrive/controls/lib/latcontrol_pid.py
#	selfdrive/controls/lib/latcontrol_torque.py
#	selfdrive/controls/tests/test_latcontrol.py
#	selfdrive/monitoring/helpers.py
#	selfdrive/ui/SConscript
#	selfdrive/ui/main.cc
#	selfdrive/ui/qt/body.h
#	selfdrive/ui/qt/home.cc
#	selfdrive/ui/qt/home.h
#	selfdrive/ui/qt/network/networking.cc
#	selfdrive/ui/qt/network/networking.h
#	selfdrive/ui/qt/network/wifi_manager.cc
#	selfdrive/ui/qt/offroad/developer_panel.cc
#	selfdrive/ui/qt/offroad/developer_panel.h
#	selfdrive/ui/qt/offroad/experimental_mode.cc
#	selfdrive/ui/qt/offroad/firehose.cc
#	selfdrive/ui/qt/offroad/firehose.h
#	selfdrive/ui/qt/offroad/onboarding.cc
#	selfdrive/ui/qt/offroad/onboarding.h
#	selfdrive/ui/qt/offroad/settings.cc
#	selfdrive/ui/qt/offroad/settings.h
#	selfdrive/ui/qt/offroad/software_settings.cc
#	selfdrive/ui/qt/onroad/alerts.cc
#	selfdrive/ui/qt/onroad/annotated_camera.h
#	selfdrive/ui/qt/onroad/buttons.cc
#	selfdrive/ui/qt/onroad/buttons.h
#	selfdrive/ui/qt/onroad/driver_monitoring.cc
#	selfdrive/ui/qt/onroad/hud.cc
#	selfdrive/ui/qt/onroad/hud.h
#	selfdrive/ui/qt/onroad/model.cc
#	selfdrive/ui/qt/onroad/model.h
#	selfdrive/ui/qt/onroad/onroad_home.cc
#	selfdrive/ui/qt/onroad/onroad_home.h
#	selfdrive/ui/qt/request_repeater.h
#	selfdrive/ui/qt/sidebar.cc
#	selfdrive/ui/qt/sidebar.h
#	selfdrive/ui/qt/util.cc
#	selfdrive/ui/qt/widgets/cameraview.h
#	selfdrive/ui/qt/widgets/controls.cc
#	selfdrive/ui/qt/widgets/controls.h
#	selfdrive/ui/qt/widgets/input.cc
#	selfdrive/ui/qt/widgets/input.h
#	selfdrive/ui/qt/widgets/prime.cc
#	selfdrive/ui/qt/widgets/prime.h
#	selfdrive/ui/qt/widgets/ssh_keys.h
#	selfdrive/ui/qt/widgets/toggle.h
#	selfdrive/ui/qt/widgets/wifi.cc
#	selfdrive/ui/qt/widgets/wifi.h
#	selfdrive/ui/qt/window.cc
#	selfdrive/ui/qt/window.h
#	selfdrive/ui/tests/cycle_offroad_alerts.py
#	selfdrive/ui/tests/test_ui/run.py
#	selfdrive/ui/translations/main_ar.ts
#	selfdrive/ui/translations/main_de.ts
#	selfdrive/ui/translations/main_es.ts
#	selfdrive/ui/translations/main_fr.ts
#	selfdrive/ui/translations/main_ja.ts
#	selfdrive/ui/translations/main_ko.ts
#	selfdrive/ui/translations/main_nl.ts
#	selfdrive/ui/translations/main_pl.ts
#	selfdrive/ui/translations/main_pt-BR.ts
#	selfdrive/ui/translations/main_th.ts
#	selfdrive/ui/translations/main_tr.ts
#	selfdrive/ui/translations/main_zh-CHS.ts
#	selfdrive/ui/translations/main_zh-CHT.ts
#	selfdrive/ui/ui.cc
#	selfdrive/ui/ui.h
#	system/manager/build.py
#	system/version.py
2025-11-14 01:11:37 -05:00
nayan
1a4992d390 Merge remote-tracking branch 'origin/master' into nayan-raylib
# Conflicts:
#	common/params_keys.h
#	selfdrive/ui/sunnypilot/qt/offroad/settings/developer_panel.cc
#	selfdrive/ui/sunnypilot/qt/offroad/settings/developer_panel.h
2025-11-13 18:15:13 -05:00
nayan
9d7193c0e7 driving stats 2025-10-05 19:18:10 -04:00
nayan
87718a3c21 add Display panel 2025-10-05 14:42:52 -04:00
nayan
5d96be11de Network panel fix 2025-10-05 14:30:44 -04:00
nayan
daf2060324 Merge remote-tracking branch 'origin/master' into nayan-raylib
# Conflicts:
#	common/params_keys.h
2025-10-05 14:14:50 -04:00
nayan
0a4a19e1f0 not needed yet 2025-08-24 18:48:10 -04:00
nayan
261ad8ee8b add raylib toggle 2025-08-24 18:41:50 -04:00
nayan
f206083e4b Revert "make raylib the default"
This reverts commit 956eb494e9.
2025-08-24 18:15:56 -04:00
Nayan
1f9534a592 Merge branch 'master' into nayan-raylib 2025-08-24 16:18:15 -04:00
nayan
ca7c4813f4 change padding 2025-08-23 23:42:49 -04:00
nayan
a595e6cc89 panel bg color 2025-08-23 23:37:31 -04:00
nayan
01087045cc add param to togglesp 2025-08-23 23:15:39 -04:00
nayan
e98738954c ruff, ruff, lint 2025-08-23 22:47:48 -04:00
nayan
9dd0fc8499 check for param 2025-08-23 21:39:34 -04:00
nayan
097b540795 use sp toggles on developer panel 2025-08-23 21:39:19 -04:00
nayan
5e7c20e705 this is better 2025-08-23 21:29:32 -04:00
nayan
956eb494e9 make raylib the default 2025-08-23 21:04:11 -04:00
nayan
6621e58303 fix display value 2025-08-23 20:59:50 -04:00
nayan
ab0ddbedeb Merge remote-tracking branch 'origin/master' into nayan-raylib 2025-08-23 20:42:29 -04:00
nayan
f5c106b741 refactor 1 2025-08-14 21:01:02 -04:00
nayan
9b237b5d44 Merge remote-tracking branch 'origin/master' into nayan-raylib
# Conflicts:
#	common/params_keys.h
#	system/manager/manager.py
#	system/manager/process_config.py
2025-08-14 19:36:17 -04:00
nayan
e9768e555e more device panel 2025-07-22 23:09:35 -04:00
nayan
e4ceaf1013 fix icons sizing 2025-07-22 23:00:50 -04:00
nayan
608e42eba9 optionControl - dynamic label & fix total width, device panel updates, list_view cleanup 2025-07-22 22:49:04 -04:00
nayan
f332ea051a multibutton param & style changes 2025-07-22 15:53:47 -04:00
nayan
17c84dde2a fix init for option control 2025-07-22 12:53:25 -04:00
nayan
4a1fb82a35 lint.. LINT.!! LIIINNNTTTT...!!!! 2025-07-22 11:58:15 -04:00
nayan
686d4594d8 better, bigger toggles 2025-07-22 11:18:35 -04:00
nayan
0ba208e189 add bg for selected panel button 2025-07-22 11:17:49 -04:00
nayan
5cf0de0485 option control & split device panel 2025-07-21 22:58:32 -04:00
nayan
c995726c62 update name to sp 2025-07-21 19:45:35 -04:00
nayan
8e788cd609 fix spinner 2025-07-21 18:31:42 -04:00
nayan
164800184b ignore lint 2025-07-21 18:25:29 -04:00
nayan
9424252ee5 stock ui param & split settings 2025-07-21 18:17:25 -04:00
nayan
c512cd7737 add missing icons 2025-07-21 17:06:17 -04:00
nayan
a31af2a4c8 misc lint 2025-07-21 16:59:11 -04:00
nayan
09e392afad don't register click when scrolling 2025-07-21 16:54:39 -04:00
nayan
7b0376fdf6 fix scrolling 2025-07-21 16:50:54 -04:00
nayan
5dee9c5b67 Merge remote-tracking branch 'origin/master-new' into nayan-raylib 2025-07-21 12:47:29 -04:00
nayan
c997bf2a23 Merge remote-tracking branch 'origin/master-new' into nayan-raylib 2025-07-19 17:44:29 -04:00
nayan
9a437424f4 Merge remote-tracking branch 'origin/master-new' into nayan-raylib
# Conflicts:
#	common/params_keys.h
#	selfdrive/ui/layouts/settings/settings.py
2025-07-19 15:31:01 -04:00
nayan
bcaca1a534 Merge remote-tracking branch 'origin/master-new' into nayan-raylib 2025-07-17 18:00:58 -04:00
nayan
5871eafc05 use pyui with param 2025-07-05 15:11:10 -04:00
nayan
8d36a24218 use pyui with param 2025-07-05 13:51:03 -04:00
nayan
46f634ed63 black panels 2025-07-05 10:46:30 -04:00
nayan
e2f3b76666 option control 2025-07-04 19:05:13 -04:00
nayan
be72e3e1c8 not needded 2025-07-04 18:07:12 -04:00
nayan
1fea34f96b let there be icons.. most of them 2025-07-01 17:08:45 -04:00
nayan
c1329719a7 sp controls 2025-07-01 16:08:38 -04:00
nayan
f870d00876 better approach 2025-07-01 15:54:02 -04:00
nayan
cae4ce5668 move to styles 2025-07-01 14:00:31 -04:00
nayan
c4f8055da2 update toggle & list view 2025-07-01 13:45:32 -04:00
nayan
8f65d84d2f init sp panels & toggle 2025-06-30 19:22:15 -04:00
48 changed files with 1738 additions and 694 deletions

View File

@@ -21,12 +21,11 @@ env:
PYTHONWARNINGS: error
BASE_IMAGE: sunnypilot-base
AZURE_TOKEN: ${{ secrets.AZURE_COMMADATACI_OPENPILOTCI_TOKEN }}
MAPBOX_TOKEN_CI: ${{ secrets.MAPBOX_TOKEN_CI }}
DOCKER_LOGIN: docker login ghcr.io -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }}
BUILD: release/ci/docker_build_sp.sh base
RUN: docker run --shm-size 2G -v $PWD:/tmp/openpilot -w /tmp/openpilot -e CI=1 -e PYTHONWARNINGS=error -e FILEREADER_CACHE=1 -e PYTHONPATH=/tmp/openpilot -e NUM_JOBS -e JOB_ID -e GITHUB_ACTION -e GITHUB_REF -e GITHUB_HEAD_REF -e GITHUB_SHA -e GITHUB_REPOSITORY -e GITHUB_RUN_ID -e MAPBOX_TOKEN_CI=$MAPBOX_TOKEN_CI -v $GITHUB_WORKSPACE/.ci_cache/scons_cache:/tmp/scons_cache -v $GITHUB_WORKSPACE/.ci_cache/comma_download_cache:/tmp/comma_download_cache -v $GITHUB_WORKSPACE/.ci_cache/openpilot_cache:/tmp/openpilot_cache $BASE_IMAGE /bin/bash -c
RUN: docker run --shm-size 2G -v $PWD:/tmp/openpilot -w /tmp/openpilot -e CI=1 -e PYTHONWARNINGS=error -e FILEREADER_CACHE=1 -e PYTHONPATH=/tmp/openpilot -e NUM_JOBS -e JOB_ID -e GITHUB_ACTION -e GITHUB_REF -e GITHUB_HEAD_REF -e GITHUB_SHA -e GITHUB_REPOSITORY -e GITHUB_RUN_ID -v $GITHUB_WORKSPACE/.ci_cache/scons_cache:/tmp/scons_cache -v $GITHUB_WORKSPACE/.ci_cache/comma_download_cache:/tmp/comma_download_cache -v $GITHUB_WORKSPACE/.ci_cache/openpilot_cache:/tmp/openpilot_cache $BASE_IMAGE /bin/bash -c
PYTEST: pytest --continue-on-collection-errors --durations=0 -n logical

View File

@@ -369,7 +369,6 @@ struct CarControlSP @0xa5cd762cd951a455 {
leadOne @2 :LeadData;
leadTwo @3 :LeadData;
intelligentCruiseButtonManagement @4 :IntelligentCruiseButtonManagement;
speed @5 :Float32;
struct Param {
key @0 :Text;
@@ -455,20 +454,7 @@ struct ModelDataV2SP @0xa1680744031fdb2d {
}
}
struct Navigationd @0xcb9fd56c7057593a {
upcomingTurn @0 :Text;
currentSpeedLimit @1 :UInt16;
bannerInstructions @2 :Text;
distanceFromRoute @3 :Float32;
allManeuvers @4 :List(Maneuver);
valid @5 :Bool;
struct Maneuver {
distance @0 :Float32;
type @1 :Text;
modifier @2 :Text;
instruction @3 :Text;
}
struct CustomReserved10 @0xcb9fd56c7057593a {
}
struct CustomReserved11 @0xc2243c65e0340384 {

View File

@@ -2635,7 +2635,7 @@ struct Event {
carStateSP @114 :Custom.CarStateSP;
liveMapDataSP @115 :Custom.LiveMapDataSP;
modelDataV2SP @116 :Custom.ModelDataV2SP;
navigationd @136 :Custom.Navigationd;
customReserved10 @136 :Custom.CustomReserved10;
customReserved11 @137 :Custom.CustomReserved11;
customReserved12 @138 :Custom.CustomReserved12;
customReserved13 @139 :Custom.CustomReserved13;

View File

@@ -89,7 +89,6 @@ _services: dict[str, tuple] = {
"carStateSP": (True, 100., 10),
"liveMapDataSP": (True, 1., 1),
"modelDataV2SP": (True, 20.),
"navigationd": (True, 3.),
"liveLocationKalman": (True, 20.),
# debug

View File

@@ -174,6 +174,8 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"ShowTurnSignals", {PERSISTENT | BACKUP, BOOL, "0"}},
{"StandstillTimer", {PERSISTENT | BACKUP, BOOL, "0"}},
{"TrueVEgoUI", {PERSISTENT | BACKUP, BOOL, "0"}},
{"sunnypilot_ui", {PERSISTENT | BACKUP, BOOL, "1"}},
{"UseRaylib", {PERSISTENT | BACKUP, BOOL, "0"}},
// MADS params
{"Mads", {PERSISTENT | BACKUP, BOOL, "1"}},
@@ -189,13 +191,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"ModelManager_LastSyncTime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}},
{"ModelManager_ModelsCache", {PERSISTENT | BACKUP, JSON}},
// Navigation params
{"AllowNavigation", {PERSISTENT | BACKUP, BOOL, "0"}},
{"MapboxToken", {PERSISTENT | BACKUP, STRING}},
{"MapboxSettings", {CLEAR_ON_MANAGER_START, JSON}},
{"MapboxRoute", {PERSISTENT, STRING}},
{"MapboxRecompute", {PERSISTENT | BACKUP, BOOL, "0"}},
// Neural Network Lateral Control
{"NeuralNetworkLateralControl", {PERSISTENT | BACKUP, BOOL, "0"}},

2
panda

Submodule panda updated: dee9061b2a...e4115086b0

View File

@@ -10,6 +10,11 @@ from openpilot.selfdrive.ui.ui_state import device, ui_state
from openpilot.system.ui.widgets import Widget
from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow
from openpilot.common.params import Params
if Params().get_bool("sunnypilot_ui"):
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.settings import SettingsLayoutSP as SettingsLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.home import HomeLayoutSP as HomeLayout
class MainState(IntEnum):
HOME = 0

View File

@@ -9,6 +9,9 @@ from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets import DialogResult
if Params().get_bool("sunnypilot_ui"):
from openpilot.system.ui.sunnypilot.lib.list_view import (toggle_item_sp as toggle_item)
# Description constants
DESCRIPTIONS = {
'enable_adb': tr_noop(

View File

@@ -11,6 +11,10 @@ from openpilot.selfdrive.ui.ui_state import ui_state
PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants
if Params().get_bool("sunnypilot_ui"):
from openpilot.system.ui.sunnypilot.lib.list_view import (multiple_button_item_sp as multiple_button_item,
toggle_item_sp as toggle_item)
# Description constants
DESCRIPTIONS = {
"OpenpilotEnabledToggle": tr_noop(

View File

@@ -0,0 +1,17 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from openpilot.selfdrive.ui.layouts.home import HomeLayout
from openpilot.selfdrive.ui.sunnypilot.widgets.drive_stats import DriveStatsWidget
class HomeLayoutSP(HomeLayout):
def __init__(self):
super().__init__()
self._drive_stats_widget = DriveStatsWidget()
def _render_left_column(self):
self._drive_stats_widget.render(self.left_column_rect)

View File

@@ -0,0 +1,21 @@
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class CruiseLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
items = self._init_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
items = [
]
return items
def _render(self, rect):
self._scroller.render(rect)

View File

@@ -0,0 +1,126 @@
from itertools import filterfalse
from openpilot.selfdrive.ui.layouts.settings.device import DeviceLayout
from openpilot.system.ui.widgets.list_view import dual_button_item, DualButtonAction
from openpilot.system.ui.sunnypilot.lib.list_view import option_item_sp, multiple_button_item_sp
offroad_time_options = {
0: [0, "Always On"],
1: [5, "5m"],
2: [10, "10m"],
3: [15, "15m"],
4: [30, "30m"],
5: [60, "1h"],
6: [120, "2h"],
7: [180, "3h"],
8: [300, "5h"],
9: [600, "10h"],
10: [1440, "24h"],
11: [1800, "30h (Default)"]
}
brightness_options = {
0: [1, "Auto (Dark)"],
1: [0, "Auto"],
2: [10, "10"],
3: [20, "20"],
4: [30, "30"],
5: [40, "40"],
6: [50, "50"],
7: [60, "60"],
8: [70, "70"],
9: [80, "80"],
10: [90, "90"],
11: [100, "100"]
}
class DeviceLayoutSP(DeviceLayout):
def __init__(self):
DeviceLayout.__init__(self)
def _hide_item(self, item):
return (item.title in {"Driver Camera", "Regulatory", "Review Training Guide", "Change Language"}
or (isinstance(item.action_item, DualButtonAction)))
def _initialize_items(self):
items = DeviceLayout._initialize_items(self)
items[:] = filterfalse(self._hide_item, items)
self.max_time_offroad = option_item_sp(
title="Max Time Offroad",
description="Device will automatically shutdown after set time once the engine is turned off.\n(30h is the default)",
param="MaxTimeOffroad",
min_value=0,
max_value=11,
value_change_step=1,
on_value_changed=None,
enabled=True,
icon="",
value_map=offroad_time_options,
label_width=300,
use_float_scaling=False)
self.device_wake_mode = multiple_button_item_sp(
"Wake Up Behavior",
description=self.wake_mode_description,
param="DeviceBootMode",
buttons=["Default", "Offroad"],
button_width=255,
callback=None,
)
self.interactivity_timeout = option_item_sp(
title="Interactivity Timeout",
description="""Apply a custom timeout for settings UI.\n
This is the time after which settings UI closes automatically
if user is not interacting with the screen.""",
param="InteractivityTimeout",
min_value=0,
max_value=120,
value_change_step=10,
on_value_changed=None,
enabled=True,
icon="",
value_map=None,
label_width=300,
use_float_scaling=False,
label_callback=self.update_interactivity_timeout_label
)
self.brightness = option_item_sp(
title="Brightness",
description="Overrides the brightness of the device.",
param="Brightness",
min_value=0,
max_value=11,
value_change_step=1,
on_value_changed=None,
enabled=True,
icon="",
value_map=brightness_options,
label_width=300,
use_float_scaling=False,
label_callback=None
)
items += [
self.device_wake_mode,
self.max_time_offroad,
self.interactivity_timeout,
self.brightness,
]
items += [
dual_button_item("Reboot", "Power Off", left_callback=self._reboot_prompt, right_callback=self._power_off_prompt),
]
return items
def wake_mode_description(self) -> str:
def_str = "Default: Device will boot/wake-up normally & will be ready to engage."
offrd_str = "Offroad: Device will be in Always Offroad mode after boot/wake-up."
header = "Controls state of the device after boot/sleep."
return f"{header}\n\n{def_str}\n{offrd_str}"
def update_interactivity_timeout_label(self, value: int) -> str:
return "Default" if value == 0 else f"{value}s"

View File

@@ -0,0 +1,21 @@
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class DisplayLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
items = self._init_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
items = [
]
return items
def _render(self, rect):
self._scroller.render(rect)

View File

@@ -0,0 +1,90 @@
from openpilot.system.ui.sunnypilot.lib.list_view import button_item_sp, toggle_item_sp
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.tree_option_dialog import TreeOptionDialog, TreeNode, TreeFolder
from openpilot.system.ui.lib.application import gui_app
from openpilot.common.params import Params
class ModelsLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
items = self._init_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
items = [
button_item_sp("Current Model", "SELECT", callback=self._on_model_select),
toggle_item_sp("Live Learning Steer Delay"),
]
return items
def _render(self, rect):
self._scroller.render(rect)
def _on_model_select(self):
# Demo data for TreeOptionDialog
tree_items = [
# Top-level items (no folder)
TreeFolder(
folder="",
items=[
TreeNode("Default Model", "default_model", 0),
TreeNode("Experimental Model", "experimental_model", 1),
]
),
# Models folder
TreeFolder(
folder="Available Models",
items=[
TreeNode("Model A - Comfort", "model_a_comfort", 2),
TreeNode("Model B - Aggressive", "model_b_aggressive", 3),
TreeNode("Model C - Balanced", "model_c_balanced", 4),
TreeNode("Model D - Highway", "model_d_highway", 5),
TreeNode("Model E - City", "model_e_city", 6),
]
),
# Development Models folder
TreeFolder(
folder="Development Models",
items=[
TreeNode("Dev Model Alpha", "dev_alpha", 7),
TreeNode("Dev Model Beta", "dev_beta", 8),
TreeNode("Dev Model Gamma", "dev_gamma", 9),
]
),
# Custom Models folder
TreeFolder(
folder="Custom Models",
items=[
TreeNode("Custom Model 1", "custom_1", 10),
TreeNode("Custom Model 2", "custom_2", 11),
TreeNode("Custom Model 3", "custom_3", 12),
]
),
]
# Get current model selection
current_model = "default_model"
# Create and show the tree dialog
dialog = TreeOptionDialog(
title="Select Model",
items=tree_items,
current=current_model,
fav_param="ModelManager_Favs"
)
# Set as modal overlay
gui_app.set_modal_overlay(dialog)
# Save selection when dialog closes
def save_selection():
if dialog.selection != current_model:
self._params.put("ModelSelection", dialog.selection)
# Update button text or other UI elements here if needed
# Note: In a real implementation, you'd need to hook into the dialog's close event
# to call save_selection(). For now, this demonstrates the usage.

View File

@@ -0,0 +1,21 @@
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class OSMLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
items = self._init_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
items = [
]
return items
def _render(self, rect):
self._scroller.render(rect)

View File

@@ -0,0 +1,186 @@
import pyray as rl
from dataclasses import dataclass
from enum import IntEnum
from openpilot.selfdrive.ui.layouts.settings import settings as OP
from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.device import DeviceLayoutSP
from openpilot.selfdrive.ui.layouts.settings.firehose import FirehoseLayout
from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout
from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout
from openpilot.system.ui.lib.application import gui_app,MousePos
from openpilot.system.ui.lib.multilang import tr_noop
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets.network import NetworkUI
from openpilot.system.ui.lib.wifi_manager import WifiManager
from openpilot.system.ui.widgets import Widget
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.models import ModelsLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.sunnylink import SunnylinkLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.osm import OSMLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.trips import TripsLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle import VehicleLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.steering import SteeringLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.cruise import CruiseLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.visuals import VisualsLayout
from selfdrive.ui.sunnypilot.layouts.settings.display import DisplayLayout
OP.PANEL_COLOR = rl.Color(10, 10, 10, 255)
ICON_SIZE = 70
OP.PanelType = IntEnum( # type: ignore
"PanelType",
[es.name for es in OP.PanelType] + [
"SUNNYLINK",
"MODELS",
"STEERING",
"CRUISE",
"VISUALS",
"DISPLAY",
"OSM",
"TRIPS",
"VEHICLE",
],
start=0,
)
@dataclass
class PanelInfo(OP.PanelInfo):
icon: str = ""
class SettingsLayoutSP(OP.SettingsLayout):
def __init__(self):
OP.SettingsLayout.__init__(self)
self._nav_items: list[Widget] = []
# Create sidebar scroller
self._sidebar_scroller = Scroller([], spacing=0, line_separator = False, pad_end=False)
# Panel configuration
wifi_manager = WifiManager()
wifi_manager.set_active(False)
self._panels = {
OP.PanelType.DEVICE: PanelInfo(tr_noop("Device"), DeviceLayoutSP(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_home.png"),
OP.PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUI(wifi_manager), icon="icons/network.png"),
OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/shell.png"),
OP.PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_toggle.png"),
OP.PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_software.png"),
OP.PanelType.MODELS: PanelInfo(tr_noop("Models"), ModelsLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_models.png"),
OP.PanelType.STEERING: PanelInfo(tr_noop("Steering"), SteeringLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_lateral.png"),
OP.PanelType.CRUISE: PanelInfo(tr_noop("Cruise"), CruiseLayout(), icon="icons/speed_limit.png"),
OP.PanelType.VISUALS: PanelInfo(tr_noop("Visuals"), VisualsLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_visuals.png"),
OP.PanelType.DISPLAY: PanelInfo(tr_noop("Display"), DisplayLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_display.png"),
OP.PanelType.OSM: PanelInfo(tr_noop("OSM"), OSMLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_map.png"),
OP.PanelType.TRIPS: PanelInfo(tr_noop("Trips"), TripsLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_trips.png"),
OP.PanelType.VEHICLE: PanelInfo(tr_noop("Vehicle"), VehicleLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_vehicle.png"),
OP.PanelType.FIREHOSE: PanelInfo(tr_noop("Firehose"), FirehoseLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_firehose.png"),
OP.PanelType.DEVELOPER: PanelInfo(tr_noop("Developer"), DeveloperLayout(), icon="icons/shell.png"),
}
def _create_nav_button(self, panel_type: OP.PanelType, panel_info: PanelInfo) -> Widget:
class NavButton(Widget):
def __init__(self, parent, p_type, p_info):
super().__init__()
self.parent = parent
self.panel_type = p_type
self.panel_info = p_info
def _render(self, rect):
is_selected = self.panel_type == self.parent._current_panel
text_color = OP.TEXT_SELECTED if is_selected else OP.TEXT_NORMAL
content_x = rect.x + 90
text_size = measure_text_cached(self.parent._font_medium, self.panel_info.name, 65)
# Draw background if selected
if is_selected:
self.container_rect = rl.Rectangle(
content_x - 20, rect.y, OP.SIDEBAR_WIDTH - 70, OP.NAV_BTN_HEIGHT
)
rl.draw_rectangle_rounded(self.container_rect, 0.2, 5, OP.CLOSE_BTN_COLOR)
if self.panel_info.icon:
icon_texture = gui_app.texture(self.panel_info.icon, ICON_SIZE, ICON_SIZE, keep_aspect_ratio=True)
rl.draw_texture(icon_texture, int(content_x), int(rect.y + (OP.NAV_BTN_HEIGHT - icon_texture.height) / 2), rl.WHITE)
content_x += ICON_SIZE + 20
# Draw button text (right-aligned)
text_pos = rl.Vector2(
content_x,
rect.y + (OP.NAV_BTN_HEIGHT - text_size.y) / 2
)
rl.draw_text_ex(self.parent._font_medium, self.panel_info.name, text_pos, 55, 0, text_color)
# Store button rect for click detection
self.panel_info.button_rect = rect
return NavButton(self, panel_type, panel_info)
def _draw_sidebar(self, rect: rl.Rectangle):
rl.draw_rectangle_rec(rect, OP.SIDEBAR_COLOR)
# Close button
close_btn_rect = rl.Rectangle(
rect.x + (rect.width - OP.CLOSE_BTN_SIZE) / 2, rect.y + 60, OP.CLOSE_BTN_SIZE, OP.CLOSE_BTN_SIZE
)
pressed = (rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and
rl.check_collision_point_rec(rl.get_mouse_position(), close_btn_rect))
close_color = OP.CLOSE_BTN_PRESSED if pressed else OP.CLOSE_BTN_COLOR
rl.draw_rectangle_rounded(close_btn_rect, 1.0, 20, close_color)
icon_color = rl.Color(255, 255, 255, 255) if not pressed else rl.Color(220, 220, 220, 255)
icon_dest = rl.Rectangle(
close_btn_rect.x + (close_btn_rect.width - self._close_icon.width) / 2,
close_btn_rect.y + (close_btn_rect.height - self._close_icon.height) / 2,
self._close_icon.width,
self._close_icon.height,
)
rl.draw_texture_pro(
self._close_icon,
rl.Rectangle(0, 0, self._close_icon.width, self._close_icon.height),
icon_dest,
rl.Vector2(0, 0),
0,
icon_color,
)
# Store close button rect for click detection
self._close_btn_rect = close_btn_rect
# Navigation buttons with scroller
if not self._nav_items:
for panel_type, panel_info in self._panels.items():
nav_button = self._create_nav_button(panel_type, panel_info)
nav_button.rect.width = rect.width - 100 # Full width minus padding
nav_button.rect.height = OP.NAV_BTN_HEIGHT
self._nav_items.append(nav_button)
self._sidebar_scroller.add_widget(nav_button)
# Draw navigation section with scroller
nav_rect = rl.Rectangle(
rect.x,
rect.y + 300, # Starting Y position for nav items
rect.width,
rect.height - 300 # Remaining height after close button
)
if self._nav_items:
self._sidebar_scroller.render(nav_rect)
return
def _handle_mouse_release(self, mouse_pos: MousePos) -> bool:
# Check close button
if rl.check_collision_point_rec(mouse_pos, self._close_btn_rect):
if self._close_callback:
self._close_callback()
return True
# Check navigation buttons
for panel_type, panel_info in self._panels.items():
if rl.check_collision_point_rec(mouse_pos, panel_info.button_rect) and self._sidebar_scroller.scroll_panel.is_touch_valid():
self.set_current_panel(panel_type)
return True
return False

View File

@@ -0,0 +1,21 @@
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class SteeringLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
items = self._init_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
items = [
]
return items
def _render(self, rect):
self._scroller.render(rect)

View File

@@ -0,0 +1,21 @@
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class SunnylinkLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
items = self._init_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
items = [
]
return items
def _render(self, rect):
self._scroller.render(rect)

View File

@@ -0,0 +1,21 @@
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class TripsLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
items = self._init_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
items = [
]
return items
def _render(self, rect):
self._scroller.render(rect)

View File

@@ -0,0 +1,21 @@
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class VehicleLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
items = self._init_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
items = [
]
return items
def _render(self, rect):
self._scroller.render(rect)

View File

@@ -0,0 +1,21 @@
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class VisualsLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
items = self._init_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
items = [
]
return items
def _render(self, rect):
self._scroller.render(rect)

View File

@@ -0,0 +1,6 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""

View File

@@ -0,0 +1,145 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import json
import pyray as rl
from typing import Dict, Optional
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import gui_label
from openpilot.tools.lib.api import CommaApi
from openpilot.common.params import Params
import openpilot.system.ui.sunnypilot.lib.styles as styles
style = styles.Default
MILE_TO_KM = 1.609344
class DriveStatsWidget(Widget):
"""Widget for displaying driving statistics"""
BG_COLOR = style.BASE_BG_COLOR
TEXT_COLOR = rl.WHITE
SECONDARY_TEXT_COLOR = style.ITEM_DESC_TEXT_COLOR
def __init__(self):
super().__init__()
self.stats = {"all": {"routes": 0, "distance": 0, "minutes": 0},
"week": {"routes": 0, "distance": 0, "minutes": 0}}
self.metric = Params().get_bool("IsMetric")
self.api_client = CommaApi()
self.last_fetch_time = 0
self.fetch_interval = 30 # 30 seconds between fetches
# Initial fetch
self._fetch_stats()
def _fetch_stats(self):
"""Fetch driving statistics from the API"""
dongle_id = Params().get("DongleId")
if dongle_id:
url = f"v1.1/devices/{dongle_id}/stats"
try:
response = self.api_client.get(url)
self._handle_response(response.text, response.status_code)
except Exception as e:
print(f"Exception fetching drive stats for dongle id {dongle_id}: {e}")
def _handle_response(self, response_text, status_code):
"""Handle API response for drive statistics"""
if status_code != 200:
print(f"Error fetching drive stats: {status_code}")
return
try:
response = json.loads(response_text)
self.stats = response
except json.JSONDecodeError:
print("Failed to parse drive stats response")
def _get_distance_unit(self):
"""Get the distance unit based on metric setting"""
return "km" if self.metric else "mi"
def _render_stats_section(self, rect: rl.Rectangle, title: str, stats_data: Dict):
"""Render a stats section (All Time or Past Week)"""
x, y = rect.x, rect.y
width = rect.width
# Title
gui_label(rl.Rectangle(x, y, width, 51), title, 51, font_weight=FontWeight.MEDIUM)
y += 80 # Move down after title
# Calculate values
routes = int(stats_data.get("routes", 0))
distance = int(stats_data.get("distance", 0) * (MILE_TO_KM if self.metric else 1))
hours = int(stats_data.get("minutes", 0) / 60)
# Layout for values and labels
column_width = width / 3
# Draw values
font_bold = gui_app.font(FontWeight.MEDIUM)
font_light = gui_app.font(FontWeight.LIGHT)
# Routes column
rl.draw_text_ex(font_bold, str(routes), rl.Vector2(x, y), 78, 0, self.TEXT_COLOR)
rl.draw_text_ex(font_light, "Drives", rl.Vector2(x, y + 85), 51, 0, self.SECONDARY_TEXT_COLOR)
# Distance column
rl.draw_text_ex(font_bold, str(distance), rl.Vector2(x + column_width, y), 78, 0, self.TEXT_COLOR)
rl.draw_text_ex(font_light, self._get_distance_unit(), rl.Vector2(x + column_width, y + 85), 51, 0, self.SECONDARY_TEXT_COLOR)
# Hours column
rl.draw_text_ex(font_bold, str(hours), rl.Vector2(x + column_width * 2, y), 78, 0, self.TEXT_COLOR)
rl.draw_text_ex(font_light, "Hours", rl.Vector2(x + column_width * 2, y + 85), 51, 0, self.SECONDARY_TEXT_COLOR)
# Return the height of this section
return 160 # Approximate height of the section
def _render(self, rect):
"""Main render method"""
# Check if we need to update stats
current_time = rl.get_time()
if current_time - self.last_fetch_time >= self.fetch_interval:
self._fetch_stats()
self.last_fetch_time = current_time
# Check if metric setting has changed
current_metric = Params().get_bool("IsMetric")
if self.metric != current_metric:
self.metric = current_metric
# Draw background
rl.draw_rectangle_rounded(rect, 0.02, 10, self.BG_COLOR)
# Content margins
content_rect = rl.Rectangle(
rect.x + 50, # left margin
rect.y + 50, # top margin
rect.width - 100, # width with margins
rect.height - 110 # height with margins
)
# Render all time stats
all_time_rect = rl.Rectangle(content_rect.x, content_rect.y, content_rect.width, 0)
all_time_height = self._render_stats_section(all_time_rect, "ALL TIME", self.stats.get("all", {}))
# Space between sections
spacing = 200
# Render past week stats
week_rect = rl.Rectangle(
content_rect.x,
content_rect.y + all_time_height + spacing,
content_rect.width,
0
)
self._render_stats_section(week_rect, "PAST WEEK", self.stats.get("week", {}))

View File

@@ -1,6 +0,0 @@
# Navigation
Navigation daemon with Mapbox integration for semi-offline navigation. This module handles route planning, geocoding, and turn-by-turn instructions to support autonomous driving features.
- `navigation_helpers/`: Mapbox API integration and navigation instructions processing.
- `navigationd`: Navigation service which uses mapbox integration to generate a route and keep it up to date. This service runs at three hz, using keep time to ensure the while loop only updates three times a second rather than every time sm updates, which in this case would be twenty hz (LLK).

View File

@@ -1,16 +0,0 @@
"""
Copyright (c) 2021-, James Vecellio, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
class NAV_CV:
""" These distances are expected in meters format and convert to desired format """
SHORT_DISTANCE_METERS = 200.0
QUARTER_MILE = 402.336
POINT_ONE_MILE = 160.9344
METERS_TO_KILO = 1000 # divide n by this
METERS_TO_MILE = 1609.344 # divide n by this
METERS_TO_FEET = 3.280839895 # multiply n by this

View File

@@ -72,15 +72,6 @@ class Coordinate:
return x * EARTH_MEAN_RADIUS
def bearing_between_two_points(point_one: Coordinate, point_two: Coordinate) -> float:
dlon = math.radians(point_two.longitude - point_one.longitude)
bearing_radians = math.atan2(math.sin(dlon)* math.cos(point_two.latitude), math.cos(point_one.latitude) * math.sin(point_two.latitude) -
math.sin(point_one.latitude) * math.cos(point_two.latitude) * math.cos(dlon))
bearing_degrees = math.degrees(bearing_radians)
bearing_normalized = (bearing_degrees + 360) % 360
return bearing_normalized
def minimum_distance(a: Coordinate, b: Coordinate, p: Coordinate):
if a.distance_to(b) < 0.01:
return a.distance_to(p)
@@ -135,8 +126,6 @@ def string_to_direction(direction: str) -> str:
if d in direction:
if 'slight' in direction and d in MODIFIABLE_DIRECTIONS:
return 'slight' + d.capitalize()
elif 'sharp' in direction and d in MODIFIABLE_DIRECTIONS:
return 'sharp' + d.capitalize()
return d
return 'none'

View File

@@ -1,113 +0,0 @@
"""
Copyright (c) 2021-, James Vecellio, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import requests
from urllib.parse import quote
from openpilot.common.params import Params
class MapboxIntegration:
def __init__(self):
self.params = Params()
def get_public_token(self) -> str:
token: str = self.params.get('MapboxToken', return_default=True)
return token
def set_destination(self, postvars, current_lon, current_lat, bearing=None) -> tuple[dict, bool]:
if 'latitude' in postvars and 'longitude' in postvars:
self.nav_confirmed(postvars, current_lon, current_lat, bearing)
return postvars, True
addr = postvars['place_name']
if not addr:
return postvars, False
token = self.get_public_token()
url = f'https://api.mapbox.com/geocoding/v5/mapbox.places/{quote(addr)}.json?access_token={token}&limit=1&proximity={current_lon},{current_lat}'
try:
response = requests.get(url, timeout=5)
if response.status_code == 200:
features = response.json()['features']
if features:
longitude, latitude = features[0]['geometry']['coordinates']
postvars.update({'latitude': latitude, 'longitude': longitude, 'name': addr})
self.nav_confirmed(postvars, current_lon, current_lat, bearing)
return postvars, True
except requests.RequestException:
pass # Broad exception to handle network errors like no internet without crashing navd process.
return postvars, False
def nav_confirmed(self, postvars, start_lon, start_lat, bearing=None) -> None:
if not postvars:
return
latitude = float(postvars['latitude'])
longitude = float(postvars['longitude'])
data: dict = {'navData': {'current': {'latitude': latitude, 'longitude': longitude}, 'route': {}}}
token = self.get_public_token()
route_data = self.generate_route(start_lon, start_lat, longitude, latitude, token, bearing)
if route_data:
data['navData']['route'] = route_data
self.params.put('MapboxSettings', data)
@staticmethod
def generate_route(start_lon, start_lat, end_lon, end_lat, token, bearing=None) -> dict | None:
if not token:
return None
params = {
'access_token': token,
'geometries': 'geojson',
'steps': 'true',
'overview': 'full',
'annotations': 'maxspeed',
'alternatives': 'false',
'banner_instructions': 'true',
}
if bearing is not None:
params['bearings'] = f'{int((bearing + 360) % 360):.0f},90;'
try:
response = requests.get(f'https://api.mapbox.com/directions/v5/mapbox/driving/{start_lon},{start_lat};{end_lon},{end_lat}', params=params, timeout=5)
data = response.json() if response.status_code == 200 else {}
except requests.RequestException:
return None
routes = data['routes'] if data else None
legs = routes[0]['legs'] if routes else None
if data.get('code') != 'Ok' or not routes or not legs:
return None
route = routes[0]
leg = legs[0]
steps = [
{
'maneuver': step['maneuver']['type'],
'instruction': step['maneuver']['instruction'],
'distance': step['distance'],
'duration': step['duration'],
'location': {'longitude': step['maneuver']['location'][0], 'latitude': step['maneuver']['location'][1]},
'modifier': step['maneuver'].get('modifier', 'none'),
'bannerInstructions': step['bannerInstructions'],
}
for step in leg['steps']
]
maxspeed = [{'speed': item['speed'], 'unit': item['unit']} for item in leg['annotation']['maxspeed'] if 'speed' in item]
return {
'steps': steps,
'totalDistance': route['distance'],
'totalDuration': route['duration'],
'geometry': [{'longitude': coord[0], 'latitude': coord[1]} for coord in route['geometry']['coordinates']],
'maxspeed': maxspeed,
}

View File

@@ -1,152 +0,0 @@
"""
Copyright (c) 2021-, James Vecellio, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from numpy import interp
from openpilot.common.params import Params
from openpilot.sunnypilot.navd.helpers import Coordinate, bearing_between_two_points, distance_along_geometry, string_to_direction
class NavigationInstructions:
def __init__(self):
self.coord = Coordinate(0, 0)
self.params = Params()
self._cached_route = None
self._route_loaded = False
self._no_route = False
self.closest_idx: float = 0
def get_route_progress(self, current_lat, current_lon) -> dict | None:
route = self.get_current_route()
if not route or not route['geometry'] or not route['steps']:
return None
self.coord.latitude = current_lat
self.coord.longitude = current_lon
# Find the closest point on the route relative to self
self.closest_idx, min_distance = min(((idx, self.coord.distance_to(coord)) for idx, coord in enumerate(route['geometry'])), key=lambda x: x[1])
closest_cumulative = distance_along_geometry(route['geometry'], self.coord)
# Find the current step index, which is the HIGHEST idx where the step location cumulative less/equal closest cumulative
current_step_idx = max((idx for idx, step in enumerate(route['steps']) if step['cumulative_distance'] <= closest_cumulative), default=-1)
current_step = route['steps'][current_step_idx if current_step_idx >= 0 else 0]
# The next turn is the next step relative to our cumulative index
next_turn_idx = current_step_idx + 1
next_turn = route['steps'][next_turn_idx] if 0 <= next_turn_idx < len(route['steps']) else None
current_maxspeed = current_step['maxspeed']
distance_to_end_of_step = max(0, current_step['distance'] - (closest_cumulative - current_step['cumulative_distance']))
all_maneuvers: list = []
max_maneuvers = 3
for idx in range(current_step_idx, min(current_step_idx + max_maneuvers, len(route['steps']))):
step = route['steps'][idx]
if idx == current_step_idx:
distance = distance_to_end_of_step
else:
distance = step['cumulative_distance'] - closest_cumulative
all_maneuvers.append({'distance': distance, 'type': step['maneuver'], 'modifier': step['modifier'], 'instruction': step['instruction']})
return {
'distance_from_route': min_distance,
'current_step': current_step,
'next_turn': next_turn,
'current_maxspeed': current_maxspeed,
'all_maneuvers': all_maneuvers,
'current_step_idx': current_step_idx,
'distance_to_end_of_step': distance_to_end_of_step,
}
def get_current_route(self):
if self._route_loaded and self._cached_route is not None:
return self._cached_route
if self._no_route:
return None
param_value = self.params.get('MapboxSettings')
route = param_value['navData']['route'] if param_value else None
if not route:
self._no_route = True
return None
geometry = [Coordinate(coord['latitude'], coord['longitude']) for coord in route['geometry']]
cumulative_distances = [0.0]
cumulative_distances.extend(cumulative_distances[-1] + geometry[step - 1].distance_to(geometry[step]) for step in range(1, len(geometry)))
maxspeed = [(speed['speed'], speed['unit']) for speed in route['maxspeed']]
steps = []
for step in route['steps']:
location = Coordinate(step['location']['latitude'], step['location']['longitude'])
closest_idx = min(range(len(geometry)), key=lambda i: location.distance_to(geometry[i]))
steps.append({
'bannerInstructions': step['bannerInstructions'],
'distance': step['distance'],
'duration': step['duration'],
'maneuver': step['maneuver'],
'location': location,
'cumulative_distance': cumulative_distances[closest_idx],
'maxspeed': maxspeed[min(closest_idx, len(maxspeed) - 1)] if len(maxspeed) > 0 else (0, 'kmh'),
'modifier': string_to_direction(step['modifier']),
'instruction': step['instruction'],
})
self._cached_route = {
'bearings': [bearing_between_two_points(geometry[i], geometry[i+2]) for i in range(len(geometry)-2)],
'steps': steps,
'total_distance': route['totalDistance'],
'total_duration': route['totalDuration'],
'geometry': geometry,
'cumulative_distances': cumulative_distances,
'maxspeed': maxspeed,
}
self._route_loaded = True
return self._cached_route
def clear_route_cache(self):
self._cached_route = None
self._route_loaded = False
self._no_route = False
def route_bearing_misalign(self, route, bearing, v_ego) -> bool:
route_bearing_misalign:bool = False
if v_ego < 5.0:
route_bearing_misalign = False
elif 0 < self.closest_idx < len(route['geometry']) -1:
route_bearing = route['bearings'][self.closest_idx -1]
current_bearing_normalized = (bearing + 360) % 360
bearing_difference = abs(current_bearing_normalized - route_bearing)
if min(bearing_difference, 360 - bearing_difference) > 95:
route_bearing_misalign = True # flag for recompute/cancellation
return route_bearing_misalign
def get_upcoming_turn_from_progress(self, progress, current_lat, current_lon, v_ego: float) -> str:
if progress and progress['next_turn']:
speed_breakpoints: list = [0, 5, 10, 15, 20, 25, 30, 35, 40]
distance_breakpoints: list = [20, 25, 30, 45, 60, 75, 90, 105, 120]
distance_interp = interp(v_ego, speed_breakpoints, distance_breakpoints)
self.coord.latitude = current_lat
self.coord.longitude = current_lon
distance = self.coord.distance_to(progress['next_turn']['location'])
if distance <= distance_interp:
modifier = progress['next_turn']['modifier']
return str(modifier)
return 'none'
@staticmethod
def arrived_at_destination(progress, v_ego) -> bool:
if v_ego < 1.0:
maneuvers = progress['all_maneuvers'][0]
if maneuvers['type'] == 'arrive' or maneuvers['instruction'].startswith('Your destination'):
return True
return False

View File

@@ -1,98 +0,0 @@
"""
Copyright (c) 2021-, James Vecellio, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import os
from openpilot.common.constants import CV
from openpilot.sunnypilot.navd.navigation_helpers.mapbox_integration import MapboxIntegration
from openpilot.sunnypilot.navd.navigation_helpers.nav_instructions import NavigationInstructions
class TestMapbox:
@classmethod
def setup_class(cls):
cls.mapbox = MapboxIntegration()
cls.nav = NavigationInstructions()
token = os.environ.get('MAPBOX_TOKEN_CI')
if token:
cls.mapbox.params.put('MapboxToken', token)
# route setup
cls.current_lon, cls.current_lat = -119.17557, 34.23305
cls.mapbox.params.put('MapboxRoute', '740 E Ventura Blvd. Camarillo, CA')
cls.postvars = {"place_name": cls.mapbox.params.get('MapboxRoute')}
cls.postvars, cls.valid_addr = cls.mapbox.set_destination(cls.postvars, cls.current_lon, cls.current_lat)
cls.route = cls.nav.get_current_route()
cls.progress = cls.nav.get_route_progress(cls.current_lat, cls.current_lon)
def test_set_destination(self):
assert self.valid_addr
settings = self.mapbox.params.get('MapboxSettings')
assert settings is not None
dest_lat = settings['navData']['current']['latitude']
dest_lon = settings['navData']['current']['longitude']
assert dest_lat == self.postvars["latitude"] and dest_lon == self.postvars["longitude"]
def test_get_route(self):
assert self.route is not None
assert 'steps' in self.route
assert 'geometry' in self.route
assert 'maxspeed' in self.route
assert 'total_distance' in self.route
assert 'total_duration' in self.route
assert len(self.route['steps']) > 0
assert len(self.route['geometry']) > 0
assert len(self.route['maxspeed']) > 0
if self.route and 'steps' in self.route:
for step in self.route['steps']:
assert 'modifier' in step
def test_upcoming_turn_detection(self):
upcoming = self.nav.get_upcoming_turn_from_progress(self.progress, self.current_lat, self.current_lon, v_ego=40.0)
assert isinstance(upcoming, str)
assert upcoming == 'none'
if self.route['steps']:
turn_lat = self.route['steps'][1]['location'].latitude
turn_lon = self.route['steps'][1]['location'].longitude
close_lat = turn_lat - 0.000175 # slightly before the turn
if self.progress and self.progress.get('next_turn'):
expected_turn = self.progress['next_turn']['modifier']
upcoming_close = self.nav.get_upcoming_turn_from_progress(self.progress, close_lat, turn_lon, v_ego=0.0)
if expected_turn:
assert upcoming_close == expected_turn == 'right', "Should be a right turn upcoming"
def test_route_progress_tracking(self):
assert self.progress is not None
assert 'distance_from_route' in self.progress
assert 'next_turn' in self.progress
assert 'current_maxspeed' in self.progress
assert 'all_maneuvers' in self.progress
assert 'distance_to_end_of_step' in self.progress
assert self.progress['distance_from_route'] >= 0
assert isinstance(self.progress['all_maneuvers'], list)
def test_speed_limit_handling(self):
speed_limit_metric = self.progress['current_maxspeed'][0]
speed_limit_imperial = (round(speed_limit_metric * CV.KPH_TO_MPH))
assert isinstance(speed_limit_metric, int)
assert isinstance(speed_limit_imperial, int)
def test_arrival_detection(self):
is_arrived = self.nav.arrived_at_destination(self.progress, 2.0)
assert isinstance(is_arrived, bool)
assert not is_arrived
def test_bearing_misalign(self):
lat = self.route['steps'][1]['location'].latitude
lon = self.route['steps'][1]['location'].longitude
self.nav.get_route_progress(lat, lon)
route_bearing_misaligned = self.nav.route_bearing_misalign(self.route, 45, 5.0)
# based on math: closest index: 7, normalized bearing: 45 route bearing: 180.5486953778888, expected differential: 135.54869538
assert route_bearing_misaligned

View File

@@ -1,167 +0,0 @@
"""
Copyright (c) 2021-, James Vecellio, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from math import degrees
from numpy import interp
import cereal.messaging as messaging
from cereal import custom
from openpilot.common.params import Params
from openpilot.common.realtime import Ratekeeper
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.navd.constants import NAV_CV
from openpilot.sunnypilot.navd.helpers import Coordinate, parse_banner_instructions
from openpilot.sunnypilot.navd.navigation_helpers.mapbox_integration import MapboxIntegration
from openpilot.sunnypilot.navd.navigation_helpers.nav_instructions import NavigationInstructions
class Navigationd:
def __init__(self):
self.params = Params()
self.mapbox = MapboxIntegration()
self.nav_instructions = NavigationInstructions()
self.sm = messaging.SubMaster(['carControlSP', 'liveLocationKalman'])
self.pm = messaging.PubMaster(['navigationd'])
self.rk = Ratekeeper(3) # 3 Hz
self.route = None
self.destination: str | None = None
self.new_destination: str = ''
self.allow_navigation: bool = False
self.recompute_allowed: bool = False
self.allow_recompute: bool = False
self.reroute_counter: int = 0
self.cancel_route_counter: int = 0
self.frame: int = -1
self.last_position: Coordinate | None = None
self.last_bearing: float | None = None
self.valid: bool = False
def _update_params(self):
if self.last_position is not None:
self.frame += 1
if self.frame % 15 == 0:
self.allow_navigation = self.params.get('AllowNavigation', return_default=True)
self.new_destination = self.params.get('MapboxRoute')
self.recompute_allowed = self.params.get('MapboxRecompute', return_default=True)
self.allow_recompute: bool = (self.new_destination != self.destination and self.new_destination != '') or (
self.recompute_allowed and self.reroute_counter > 9 and self.route)
if self.allow_recompute:
postvars = {'place_name': self.new_destination}
postvars, valid_addr = self.mapbox.set_destination(postvars, self.last_position.longitude, self.last_position.latitude, self.last_bearing)
if valid_addr:
self.destination = self.new_destination
self.nav_instructions.clear_route_cache()
self.route = self.nav_instructions.get_current_route()
self.cancel_route_counter = 0
self.reroute_counter = 0
if self.cancel_route_counter == 30:
self.cancel_route_counter = 0
self.params.put_nonblocking("MapboxRoute", "")
self.nav_instructions.clear_route_cache()
self.route = None
self.valid = self.route is not None
def _update_navigation(self) -> tuple[str, dict | None, dict]:
banner_instructions: str = ''
nav_data: dict = {}
if self.allow_navigation and self.route and self.last_position is not None:
if progress := self.nav_instructions.get_route_progress(self.last_position.latitude, self.last_position.longitude):
v_ego = float(max(self.sm['carControlSP'].speed, 0.0))
nav_data['upcoming_turn'] = self.nav_instructions.get_upcoming_turn_from_progress(progress, self.last_position.latitude,
self.last_position.longitude, v_ego)
speed_limit, _ = progress['current_maxspeed']
nav_data['current_speed_limit'] = speed_limit
arrived = self.nav_instructions.arrived_at_destination(progress, v_ego)
if progress['current_step']:
if parsed := parse_banner_instructions(progress['current_step']['bannerInstructions'], progress['distance_to_end_of_step']):
banner_instructions = parsed['maneuverPrimaryText']
nav_data['distance_from_route'] = progress['distance_from_route']
speed_breakpoints: list = [0.0, 5.0, 10.0, 20.0, 40.0]
distance_list: list = [100.0, 125.0, 150.0, 200.0, 250.0]
large_distance: bool = progress['distance_from_route'] > float(interp(v_ego, speed_breakpoints, distance_list))
route_bearing_misalign: bool = self.nav_instructions.route_bearing_misalign(self.route, self.last_bearing, v_ego)
if large_distance and not arrived:
self.cancel_route_counter = self.cancel_route_counter + 1 if progress['distance_from_route'] > NAV_CV.QUARTER_MILE else 0
if self.recompute_allowed:
self.reroute_counter += 1
elif arrived:
self.cancel_route_counter += 1
self.recompute_allowed = False
elif route_bearing_misalign:
self.cancel_route_counter += 1
if self.recompute_allowed:
self.reroute_counter += 1
else:
self.cancel_route_counter = 0
self.reroute_counter = 0
# Don't recompute in last segment to prevent reroute loops
if progress['current_step_idx'] == len(self.route['steps']) - 1:
self.recompute_allowed = False
self.allow_navigation = False
else:
banner_instructions = ''
progress = None
nav_data = {}
return banner_instructions, progress, nav_data
def _build_navigation_message(self, banner_instructions: str, progress: dict | None, nav_data: dict, valid: bool):
msg = messaging.new_message('navigationd')
msg.valid = valid
msg.navigationd.upcomingTurn = nav_data.get('upcoming_turn', 'none')
msg.navigationd.currentSpeedLimit = nav_data.get('current_speed_limit', 0)
msg.navigationd.bannerInstructions = banner_instructions
msg.navigationd.distanceFromRoute = nav_data.get('distance_from_route', 0.0)
msg.navigationd.valid = self.valid
all_maneuvers = (
[custom.Navigationd.Maneuver.new_message(distance=m['distance'], type=m['type'], modifier=m['modifier'],
instruction=m['instruction']) for m in progress['all_maneuvers']]
if progress
else []
)
msg.navigationd.allManeuvers = all_maneuvers
return msg
def run(self):
cloudlog.warning('navigationd init')
while True:
self.sm.update(0)
location = self.sm['liveLocationKalman']
localizer_valid = location.positionGeodetic.valid if location else False
if localizer_valid:
self.last_bearing = degrees(location.calibratedOrientationNED.value[2])
self.last_position = Coordinate(location.positionGeodetic.value[0], location.positionGeodetic.value[1])
self._update_params()
banner_instructions, progress, nav_data = self._update_navigation()
msg = self._build_navigation_message(banner_instructions, progress, nav_data, valid=localizer_valid)
self.pm.send('navigationd', msg)
self.rk.keep_time()
def main():
nav = Navigationd()
nav.run()

View File

@@ -1,67 +0,0 @@
"""
Copyright (c) 2021-, James Vecellio, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import platform
import pytest
import cereal.messaging as messaging
from openpilot.sunnypilot.navd.navigationd import Navigationd
from openpilot.sunnypilot.navd.helpers import Coordinate
class TestNavigationd:
is_darwin = platform.system() == "Darwin"
@pytest.fixture(autouse=True)
def setup_method(self, mocker):
if self.is_darwin:
mocker.patch('cereal.messaging.SubMaster')
mocker.patch('cereal.messaging.PubMaster')
def test_update_params(self):
nav = Navigationd()
nav.last_position = None
nav._update_params()
assert nav.frame == -1
nav.last_position = Coordinate(latitude=37.0, longitude=128.0)
nav._update_params()
assert nav.frame == 0 # frame only updates when last position is set
def test_update_navigation_no_position(self):
nav = Navigationd()
nav.last_position = None
banner, progress, nav_data = nav._update_navigation()
assert banner == ''
assert progress is None
assert nav_data == {}
def test_update_navigation(self):
nav = Navigationd()
nav.last_position = Coordinate(latitude=37.0, longitude=128.0)
nav.route = {'580 Winchester dr, oxnard, CA': True}
banner, progress, nav_data = nav._update_navigation()
assert isinstance(banner, str)
assert not progress # no route was actually set
assert isinstance(nav_data, dict)
def test_build_navigation_message(self):
if self.is_darwin:
nav = Navigationd()
msg = nav._build_navigation_message('', None, {}, True)
assert msg.navigationd.bannerInstructions == ''
assert msg.navigationd.valid is False
else:
sm = messaging.SubMaster(['navigationd'])
nav = Navigationd()
msg = nav._build_navigation_message('', None, {}, True)
nav.pm.send('navigationd', msg)
sm.update()
received_msg = sm['navigationd']
assert received_msg.bannerInstructions == msg.navigationd.bannerInstructions
assert received_msg.valid == msg.navigationd.valid

Binary file not shown.

Binary file not shown.

View File

@@ -72,8 +72,6 @@ class ControlsExt:
CC_SP.intelligentCruiseButtonManagement = sm['selfdriveStateSP'].intelligentCruiseButtonManagement
CC_SP.speed = sm['carState'].vEgo
return CC_SP
@staticmethod

View File

@@ -180,9 +180,6 @@ procs += [
NativeProcess("mapd", Paths.mapd_root(), ["bash", "-c", f"{MAPD_PATH} > /dev/null 2>&1"], mapd_ready),
PythonProcess("mapd_manager", "sunnypilot.mapd.mapd_manager", always_run),
# navigationd
PythonProcess("navigationd", "sunnypilot.navd.navigationd", only_onroad),
# locationd
NativeProcess("locationd_llk", "sunnypilot/selfdrive/locationd", ["./locationd"], only_onroad),
]

View File

@@ -8,8 +8,6 @@ from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.text import wrap_text
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.sunnypilot.lib.application import gui_app_sp
# Constants
PROGRESS_BAR_WIDTH = 1000
PROGRESS_BAR_HEIGHT = 20
@@ -28,7 +26,7 @@ def clamp(value, min_value, max_value):
class Spinner(Widget):
def __init__(self):
super().__init__()
self._comma_texture = gui_app_sp.sp_texture("images/spinner_sunnypilot.png", TEXTURE_SIZE, TEXTURE_SIZE)
self._comma_texture = gui_app.texture("../../sunnypilot/selfdrive/assets/images/spinner_sunnypilot.png", TEXTURE_SIZE, TEXTURE_SIZE)
self._spinner_texture = gui_app.texture("images/spinner_track.png", TEXTURE_SIZE, TEXTURE_SIZE, alpha_premultiply=True)
self._rotation = 0.0
self._progress: int | None = None

View File

@@ -1,24 +0,0 @@
from openpilot.system.ui.lib.application import GuiApplication
from importlib.resources import as_file, files
ASSETS_DIR_SP = files("openpilot.sunnypilot.selfdrive").joinpath("assets")
class GuiApplicationSP(GuiApplication):
def __init__(self, width: int, height: int):
super().__init__(width, height)
def sp_texture(self, asset_path: str, width: int, height: int, alpha_premultiply=False, keep_aspect_ratio=True):
cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}"
if cache_key in self._textures:
return self._textures[cache_key]
with as_file(ASSETS_DIR_SP.joinpath(asset_path)) as fspath:
image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio)
texture_obj = self._load_texture_from_image(image_obj)
self._textures[cache_key] = texture_obj
return texture_obj
gui_app_sp = GuiApplicationSP(2160, 1080)

View File

@@ -0,0 +1,223 @@
import pyray as rl
from openpilot.system.ui.widgets.list_view import ToggleAction, ButtonAction, DualButtonAction, TextAction, MultipleButtonAction, ListItem, ItemAction
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.lib.text_measure import measure_text_cached
from collections.abc import Callable
from openpilot.common.params import Params
from openpilot.system.ui.sunnypilot.lib.toggle import ToggleSP
from openpilot.system.ui.sunnypilot.lib.option_control import OptionControlSP
from openpilot.system.ui.widgets.list_view import _resolve_value
import openpilot.system.ui.sunnypilot.lib.styles as styles
style = styles.Default
class ToggleActionSP(ToggleAction):
def __init__(self, initial_state: bool = False, width: int = style.TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True, param: str | None = None):
ToggleAction.__init__(self, initial_state, width, enabled)
self.toggle = ToggleSP(initial_state=initial_state, param=param)
class ListItemSP(ListItem):
def __init__(self, title: str = "", icon: str | None = None, description: str | Callable[[], str] | None = None,
description_visible: bool = False, callback: Callable | None = None,
action_item: ItemAction | None = None):
ListItem.__init__(self, title, icon, description, description_visible, callback, action_item)
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
if not self.action_item:
return rl.Rectangle(0, 0, 0, 0)
right_width = self.action_item.rect.width
if right_width == 0: # Full width action (like DualButtonAction)
return rl.Rectangle(item_rect.x + style.ITEM_PADDING, item_rect.y,
item_rect.width - (style.ITEM_PADDING * 2), style.ITEM_BASE_HEIGHT)
action_width = self.action_item.rect.width
if isinstance(self.action_item, ToggleAction):
action_x = item_rect.x
else:
action_x = item_rect.x + item_rect.width - action_width
action_y = item_rect.y
return rl.Rectangle(action_x, action_y, action_width, style.ITEM_BASE_HEIGHT)
def _render(self, _):
content_x = self._rect.x + style.ITEM_PADDING
text_x = content_x
left_action_item = isinstance(self.action_item, ToggleAction)
if left_action_item:
left_rect = rl.Rectangle(
content_x,
self._rect.y + (style.ITEM_BASE_HEIGHT - style.TOGGLE_HEIGHT) // 2,
style.TOGGLE_WIDTH,
style.TOGGLE_HEIGHT
)
text_x = left_rect.x + left_rect.width + style.ITEM_PADDING
# Draw title
if self.title:
text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - text_size.y) // 2
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, style.ITEM_TEXT_COLOR)
# Render toggle and handle callback
if self.action_item.render(left_rect) and self.action_item.enabled:
if self.callback:
self.callback()
else:
if self.title:
# Draw main text
text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - text_size.y) // 2
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, style.ITEM_TEXT_COLOR)
# Draw right item if present
if self.action_item:
right_rect = self.get_right_item_rect(self._rect)
right_rect.y = self._rect.y
if self.action_item.render(right_rect) and self.action_item.enabled:
# Right item was clicked/activated
if self.callback:
self.callback()
# Draw description if visible
if self.description_visible:
content_width = int(self._rect.width - style.ITEM_PADDING * 2)
description_height = self._html_renderer.get_total_height(content_width)
description_rect = rl.Rectangle(
self._rect.x + style.ITEM_PADDING,
self._rect.y + style.ITEM_DESC_V_OFFSET,
content_width,
description_height
)
self._html_renderer.render(description_rect)
class MultipleButtonActionSP(MultipleButtonAction):
def __init__(self, param: str | None, buttons: list[str], button_width: int, selected_index: int = 0, callback: Callable = None):
MultipleButtonAction.__init__(self, buttons, button_width, selected_index, callback)
self.param_key = param
self.params = Params()
if self.param_key:
self.selected_button = int(self.params.get(self.param_key, return_default = True))
def _render(self, rect: rl.Rectangle) -> bool:
spacing = 20
button_y = rect.y + (rect.height - style.BUTTON_HEIGHT) / 2
clicked = -1
for i, _text in enumerate(self.buttons):
button_x = rect.x + i * (self.button_width + spacing)
button_rect = rl.Rectangle(button_x, button_y, self.button_width, style.BUTTON_HEIGHT)
# Check button state
mouse_pos = rl.get_mouse_position()
is_hovered = rl.check_collision_point_rec(mouse_pos, button_rect)
is_pressed = is_hovered and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and self.is_pressed
is_selected = i == self.selected_button
# Button colors
if is_selected:
bg_color = style.ON_BG_COLOR
if is_pressed:
bg_color = style.ON_HOVER_BG_COLOR
elif is_pressed:
bg_color = style.OFF_HOVER_BG_COLOR
else:
bg_color = style.OFF_BG_COLOR
# Draw button
rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color)
# Draw text
text = _resolve_value(_text, "")
text_size = measure_text_cached(self._font, text, 40)
text_x = button_x + (self.button_width - text_size.x) / 2
text_y = button_y + (style.BUTTON_HEIGHT - text_size.y) / 2
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, style.ITEM_TEXT_COLOR)
# Handle click
if is_hovered and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and self.is_pressed:
clicked = i
if clicked >= 0:
self.selected_button = clicked
if self.param_key:
self.params.put(self.param_key, self.selected_button)
if self.callback:
self.callback(clicked)
return True
return False
class OptionControlActionSP(ItemAction):
def __init__(self, param: str, min_value: int, max_value: int,
value_change_step: int = 1, enabled: bool | Callable[[], bool] = True,
on_value_changed: Callable[[int], None] | None = None,
value_map: dict[int, tuple[int, str]] | None = None,
label_width: int = style.BUTTON_WIDTH,
use_float_scaling: bool = False,
label_callback: Callable[[int], str] | None = None):
# Initialize with zero width - the component will size itself
super().__init__()
# Create the option control
self.option_control = OptionControlSP(
param, min_value, max_value, value_change_step,
enabled, on_value_changed, value_map, label_width, use_float_scaling,
label_callback
)
def _render(self, rect: rl.Rectangle) -> bool | int | None:
# Ensure the touch validity callback is passed to the option control
if hasattr(self, '_touch_valid_callback') and self._touch_valid_callback:
self.option_control.set_touch_valid_callback(self._touch_valid_callback)
# Pass the enabled state to the option control
self.option_control.set_enabled(self.enabled)
# Render the control and return whether a value change occurred
return self.option_control.render(rect)
def toggle_item_sp(title: str, description: str | Callable[[], str] | None = None, initial_state: bool = False,
callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, param: str | None = None) -> ListItem:
action = ToggleActionSP(initial_state=initial_state, enabled=enabled, param=param)
return ListItemSP(title=title, description=description, action_item=action, icon=icon, callback=callback)
def button_item_sp(title: str, button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
action = ButtonAction(text=button_text, enabled=enabled)
return ListItemSP(title=title, description=description, action_item=action, callback=callback)
def text_item_sp(title: str, value: str | Callable[[], str], description: str | Callable[[], str] | None = None,
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
action = TextAction(text=value, color=rl.Color(170, 170, 170, 255), enabled=enabled)
return ListItemSP(title=title, description=description, action_item=action, callback=callback)
def dual_button_item_sp(left_text: str, right_text: str, left_callback: Callable = None, right_callback: Callable = None,
description: str | Callable[[], str] | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
action = DualButtonAction(left_text, right_text, left_callback, right_callback, enabled)
return ListItemSP(title="", description=description, action_item=action)
def multiple_button_item_sp(title: str, description: str| Callable[[], str], buttons: list[str], selected_index: int = 0,
button_width: int = style.BUTTON_WIDTH, callback: Callable = None, icon: str = "",
param: str | None = None) -> ListItem:
action = MultipleButtonActionSP(param, buttons, button_width, selected_index, callback=callback)
return ListItemSP(title=title, description=description, icon=icon, action_item=action)
def option_item_sp(title: str, param: str,
min_value: int, max_value: int, description: str | Callable[[], str] | None = None,
value_change_step: int = 1, on_value_changed: Callable[[int], None] | None = None,
enabled: bool | Callable[[], bool] = True,
icon: str = "", label_width: int = style.BUTTON_WIDTH, value_map: dict[int, tuple[int, str]] | None = None,
use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None) -> ListItem:
action = OptionControlActionSP(
param, min_value, max_value, value_change_step,
enabled, on_value_changed, value_map, label_width, use_float_scaling, label_callback
)
return ListItemSP(title=title, description=description, action_item=action, icon=icon)

View File

@@ -0,0 +1,233 @@
import pyray as rl
from collections.abc import Callable
from openpilot.common.params import Params
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
import openpilot.system.ui.sunnypilot.lib.styles as styles
style = styles.Default
# Dimensions and styling constants
BUTTON_WIDTH = 80
BUTTON_HEIGHT = 80
LABEL_WIDTH = 200
BUTTON_SPACING = 25
VALUE_FONT_SIZE = 50
BUTTON_FONT_SIZE = 60
BUTTON_CORNER_RADIUS = 20
CONTAINER_PADDING = 20
INNER_PADDING = 10
TOP_PADDING = 25
class OptionControlSP(Widget):
def __init__(self, param: str, min_value: int, max_value: int,
value_change_step: int = 1, enabled: bool | Callable[[], bool] = True,
on_value_changed: Callable[[int], None] | None = None,
value_map: dict[int, tuple[int, str]] | None = None,
label_width: int = LABEL_WIDTH,
use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None):
super().__init__()
self.params = Params()
self.param_key = param
self.min_value = min_value
self.max_value = max_value
self.value_change_step = value_change_step
self._enabled = enabled
self.on_value_changed = on_value_changed
self.value_map = value_map
self.label_width = label_width
self.use_float_scaling = use_float_scaling
self.current_value = min_value
self.label_callback = label_callback
if self.value_map:
for key in self.value_map:
if self.value_map[key][0] == self.params.get(self.param_key, return_default = True):
self.current_value = int(key)
break
else:
self.current_value = int(self.params.get(self.param_key, return_default = True))
# Initialize font and button styles
self._font = gui_app.font(FontWeight.MEDIUM)
# Layout rectangles for components
self.minus_btn_rect = rl.Rectangle(0, 0, BUTTON_WIDTH, BUTTON_HEIGHT)
self.label_rect = rl.Rectangle(0, 0, self.label_width, BUTTON_HEIGHT)
self.plus_btn_rect = rl.Rectangle(0, 0, BUTTON_WIDTH, BUTTON_HEIGHT)
self.container_rect = rl.Rectangle(0, 0, 0, 0)
def set_enabled(self, enabled: bool | Callable[[], bool]):
"""Set whether the control is enabled"""
self._enabled = enabled
def get_value(self) -> int:
"""Get the current value of the control"""
return self.current_value
def set_value(self, value: int):
"""Set the control to a specific value"""
if self.min_value <= value <= self.max_value:
self.current_value = value
if self.value_map:
self.params.put(self.param_key, self.value_map[value][0])
else:
self.params.put(self.param_key, value)
if self.on_value_changed:
self.on_value_changed(value)
def _update_layout_rects(self):
"""Update the layout rectangles when the widget rect changes"""
# Calculate total control width
control_width = (BUTTON_WIDTH * 2) + self.label_width + (BUTTON_SPACING * 2)
total_width = control_width + (CONTAINER_PADDING * 2)
# Update the widget's width to match the control
self._rect.width = total_width
# Position the control in the parent rectangle
start_x = self._rect.x + self._rect.width - control_width - (CONTAINER_PADDING * 2)
# Set container rectangle
self.container_rect = rl.Rectangle(
start_x,
self._rect.y + TOP_PADDING,
total_width,
BUTTON_HEIGHT + (CONTAINER_PADDING * 2)
)
# Set component rectangles
component_y = self._rect.y + TOP_PADDING + CONTAINER_PADDING
start_x = self.container_rect.x + CONTAINER_PADDING
# Minus button
self.minus_btn_rect = rl.Rectangle(
start_x,
component_y,
BUTTON_WIDTH,
BUTTON_HEIGHT
)
# Value label
label_x = start_x + BUTTON_WIDTH + BUTTON_SPACING
self.label_rect = rl.Rectangle(
label_x,
component_y,
self.label_width,
BUTTON_HEIGHT
)
# Plus button
plus_x = label_x + self.label_width + BUTTON_SPACING
self.plus_btn_rect = rl.Rectangle(
plus_x,
component_y,
BUTTON_WIDTH,
BUTTON_HEIGHT
)
def is_enabled(self) -> bool:
"""Check if the control is enabled"""
return self._enabled() if callable(self._enabled) else self._enabled
def get_displayed_value(self) -> str:
"""Get the displayed value, handling value mapping if present"""
value = self.current_value
if callable(self.label_callback):
return self.label_callback(value)
if self.value_map:
# Use the value map to get the display string
if value in self.value_map:
return self.value_map[value][1] # Return the display string
# If using float scaling, format as float
if self.use_float_scaling:
return f"{value / 100.0:.2f}"
return str(value)
def _render(self, rect: rl.Rectangle) -> bool:
"""Render the widget and handle input"""
if self._rect.width == 0 or self._rect.height == 0:
return False
# Ensure layout rectangles are updated
if self.container_rect.width == 0:
self._update_layout_rects()
# Get enabled state
enabled = self.is_enabled()
# Draw container background
rl.draw_rectangle_rounded(self.container_rect, 1, BUTTON_CORNER_RADIUS, style.OPTIONCONTROL_CONTAINER_BG)
# Determine button states
minus_enabled = enabled and self.current_value > self.min_value
plus_enabled = enabled and self.current_value < self.max_value
# Render buttons and label
minus_pressed = self._render_button(self.minus_btn_rect, "-", minus_enabled)
self._render_value_label()
plus_pressed = self._render_button(self.plus_btn_rect, "+", plus_enabled)
# Handle button presses
value_changed = False
if minus_pressed and minus_enabled:
self.current_value -= self.value_change_step
self.current_value = max(self.min_value, self.current_value)
value_changed = True
elif plus_pressed and plus_enabled:
self.current_value += self.value_change_step
self.current_value = min(self.max_value, self.current_value)
value_changed = True
# Call the value changed callback
if value_changed:
self.set_value(self.current_value)
return value_changed
def _render_button(self, rect: rl.Rectangle, text: str, enabled: bool) -> bool:
"""Render a button and return True if it was clicked"""
mouse_pos = rl.get_mouse_position()
is_hovered = rl.check_collision_point_rec(mouse_pos, rect) and self._touch_valid()
is_pressed = is_hovered and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
was_clicked = is_hovered and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT)
# Determine button colors based on state
if not enabled:
bg_color = style.OPTIONCONTROL_BTN_DISABLED
text_color = style.OPTIONCONTROL_TEXT_DISABLED
elif is_pressed:
bg_color = style.OPTIONCONTROL_BTN_PRESSED
text_color = style.OPTIONCONTROL_TEXT_PRESSED
else:
bg_color = style.OPTIONCONTROL_BTN_ENABLED
text_color = style.OPTIONCONTROL_TEXT_ENABLED
# Draw button background
rl.draw_rectangle_rounded(rect, 1, BUTTON_CORNER_RADIUS, bg_color)
# Draw button text
text_size = measure_text_cached(self._font, text, BUTTON_FONT_SIZE)
text_x = rect.x + (rect.width - text_size.x) / 2
text_y = rect.y + (rect.height - text_size.y) / 2
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), BUTTON_FONT_SIZE, 0, text_color)
return was_clicked and enabled
def _render_value_label(self):
"""Render the current value label"""
text = self.get_displayed_value()
text_color = style.OPTIONCONTROL_TEXT_ENABLED if self.is_enabled() else style.OPTIONCONTROL_TEXT_DISABLED
# Calculate text position centered in the label area
text_size = measure_text_cached(self._font, text, VALUE_FONT_SIZE)
text_x = self.label_rect.x + (self.label_rect.width - text_size.x) / 2
text_y = self.label_rect.y + (self.label_rect.height - text_size.y) / 2
# Draw the text
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), VALUE_FONT_SIZE, 0, text_color)

View File

@@ -0,0 +1,49 @@
import pyray as rl
from dataclasses import dataclass
@dataclass
class Default:
# Base Colors
BASE_BG_COLOR = rl.Color(57, 57, 57, 255) # Grey
ON_BG_COLOR = rl.Color(28, 101, 186, 255) # Blue
OFF_BG_COLOR = BASE_BG_COLOR
ON_HOVER_BG_COLOR = rl.Color(17, 78, 150, 255) # Dark Blue
OFF_HOVER_BG_COLOR = rl.Color(21, 21, 21, 255) # Dark gray
DISABLED_ON_BG_COLOR = rl.Color(37, 70, 107, 255) # Dull Blue
DISABLED_OFF_BG_COLOR = rl.Color(39, 39, 39, 255) # Grey
ITEM_TEXT_COLOR = rl.WHITE
ITEM_DISABLED_TEXT_COLOR = rl.Color(88, 88, 88, 255)
ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255)
# Widget/Control Base Dimensions
ITEM_BASE_HEIGHT = 170
ITEM_PADDING = 20
ITEM_TEXT_FONT_SIZE = 50
ITEM_DESC_FONT_SIZE = 40
ITEM_DESC_V_OFFSET = 150
# Button Control
BUTTON_WIDTH = 250
BUTTON_HEIGHT = 100
# Toggle Control
TOGGLE_HEIGHT = 100
TOGGLE_WIDTH = int(TOGGLE_HEIGHT * 1.75)
TOGGLE_BG_HEIGHT = TOGGLE_HEIGHT - 20
TOGGLE_ON_COLOR = ON_BG_COLOR
TOGGLE_OFF_COLOR = OFF_BG_COLOR
TOGGLE_KNOB_COLOR = rl.WHITE
TOGGLE_DISABLED_ON_COLOR = DISABLED_ON_BG_COLOR
TOGGLE_DISABLED_OFF_COLOR = DISABLED_OFF_BG_COLOR
TOGGLE_DISABLED_KNOB_COLOR = rl.Color(88, 88, 88, 255) # Lighter Grey
# Option Control
OPTIONCONTROL_CONTAINER_BG = OFF_BG_COLOR
OPTIONCONTROL_BTN_ENABLED = rl.Color(88, 88, 88, 255)
OPTIONCONTROL_BTN_PRESSED = ON_BG_COLOR
OPTIONCONTROL_BTN_DISABLED = DISABLED_OFF_BG_COLOR
OPTIONCONTROL_TEXT_ENABLED = rl.WHITE
OPTIONCONTROL_TEXT_PRESSED = rl.WHITE
OPTIONCONTROL_TEXT_DISABLED = ITEM_DISABLED_TEXT_COLOR

View File

@@ -0,0 +1,100 @@
import pyray as rl
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import MousePos
from openpilot.system.ui.widgets.toggle import Toggle
import openpilot.system.ui.sunnypilot.lib.styles as styles
style = styles.Default
class ToggleSP(Toggle):
def __init__(self, initial_state=False, param: str | None = None):
self.param_key = param
self.params = Params()
if self.param_key:
initial_state = self.params.get_bool(self.param_key)
Toggle.__init__(self, initial_state)
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
if self._enabled and self.param_key:
self.params.put_bool(self.param_key, self._state)
def _render(self, rect: rl.Rectangle):
self.update()
if self._enabled:
bg_color = self._blend_color(style.TOGGLE_OFF_COLOR, style.TOGGLE_ON_COLOR, self._progress)
knob_color = style.TOGGLE_KNOB_COLOR
else:
bg_color = self._blend_color(style.TOGGLE_DISABLED_OFF_COLOR, style.TOGGLE_DISABLED_ON_COLOR, self._progress)
knob_color = style.TOGGLE_DISABLED_KNOB_COLOR
# Draw background
bg_rect = rl.Rectangle(self._rect.x, self._rect.y, style.TOGGLE_WIDTH, style.TOGGLE_BG_HEIGHT)
# Draw outline first
outline_color = style.TOGGLE_ON_COLOR
if not self._enabled:
# Use a more subtle color for disabled state
outline_color = rl.Color(outline_color.r // 2, outline_color.g // 2, outline_color.b // 2, 255)
# Draw outline by drawing a slightly larger rounded rectangle behind the background
outline_rect = rl.Rectangle(bg_rect.x - 2, bg_rect.y - 2, bg_rect.width + 4, bg_rect.height + 4)
rl.draw_rectangle_rounded(outline_rect, 1.0, 10, outline_color)
# Draw actual background
rl.draw_rectangle_rounded(bg_rect, 1.0, 10, bg_color)
# Draw knob to sit inside the background
knob_padding = 5
knob_radius = style.TOGGLE_BG_HEIGHT / 2 - knob_padding
left_edge = bg_rect.x + knob_padding
right_edge = bg_rect.x + bg_rect.width - knob_padding
knob_travel_distance = right_edge - left_edge - 2 * knob_radius
min_knob_x = left_edge + knob_radius
knob_x = min_knob_x + knob_travel_distance * self._progress
knob_y = self._rect.y + style.TOGGLE_BG_HEIGHT / 2
rl.draw_circle(int(knob_x), int(knob_y), knob_radius, knob_color)
symbol_size = knob_radius / 2
if self._state and (self._enabled or self._progress > 0.5):
# Draw checkmark when toggle is ON
start_x = knob_x - symbol_size * 0.8
start_y = knob_y
mid_x = knob_x - symbol_size * 0.1
mid_y = knob_y + symbol_size * 0.6
end_x = knob_x + symbol_size * 0.8
end_y = knob_y - symbol_size * 0.5
rl.draw_line_ex(
rl.Vector2(int(start_x), int(start_y)),
rl.Vector2(int(mid_x), int(mid_y)),
3,
style.TOGGLE_ON_COLOR
)
rl.draw_line_ex(
rl.Vector2(int(mid_x), int(mid_y)),
rl.Vector2(int(end_x), int(end_y)),
3,
style.TOGGLE_ON_COLOR
)
else:
# Draw X when toggle is OFF
x_size_factor = 0.65
x_offset = symbol_size * x_size_factor
rl.draw_line_ex(
rl.Vector2(int(knob_x - x_offset), int(knob_y - x_offset)),
rl.Vector2(int(knob_x + x_offset), int(knob_y + x_offset)),
3,
style.TOGGLE_OFF_COLOR
)
rl.draw_line_ex(
rl.Vector2(int(knob_x + x_offset), int(knob_y - x_offset)),
rl.Vector2(int(knob_x - x_offset), int(knob_y + x_offset)),
3,
style.TOGGLE_OFF_COLOR
)

View File

@@ -0,0 +1,369 @@
# /**
# * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
# *
# * This file is part of sunnypilot and is licensed under the MIT License.
# * See the LICENSE.md file in the root directory for more details.
# */
import pyray as rl
from dataclasses import dataclass
from openpilot.system.ui.lib.application import FontWeight, gui_app
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
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.common.params import Params
# Constants
MARGIN = 50
TITLE_FONT_SIZE = 70
ITEM_HEIGHT = 135
BUTTON_SPACING = 50
BUTTON_HEIGHT = 160
ITEM_SPACING = 5
STAR_ICON_SIZE = 100
FOLDER_ICON_SIZE = 40
INDENT_SIZE = 50
@dataclass
class TreeNode:
"""Represents a single item in the tree"""
display_name: str
ref: str # Unique identifier for the item
index: int = 0
@dataclass
class TreeFolder:
"""Represents a folder containing tree nodes"""
folder: str # Folder name, empty string for top-level items
items: list[TreeNode]
class TreeItemWidget(Widget):
"""Widget representing a single tree item (folder or node)"""
def __init__(self, text: str, ref: str = "", is_folder: bool = False,
indent_level: int = 0, click_callback=None, favorite_callback=None,
is_favorite: bool = False, star_icon=None, folder_icon=None):
super().__init__()
self.text = text
self.ref = ref
self.is_folder = is_folder
self.indent_level = indent_level
self.is_expanded = False
self.is_selected = False
self.is_favorite = is_favorite
self._click_callback = click_callback
self._favorite_callback = favorite_callback
self.star_icon = star_icon
self.folder_icon = folder_icon
self.children: list[TreeItemWidget] = []
# Fixed height for tree items
self._rect = rl.Rectangle(0, 0, 0, ITEM_HEIGHT)
def add_child(self, child):
self.children.append(child)
def toggle_expand(self):
if self.is_folder:
self.is_expanded = not self.is_expanded
def _render(self, _):
# Background color based on selection state
bg_color = rl.Color(70, 91, 234, 255) if self.is_selected else rl.Color(79, 79, 79, 255)
# Draw background with rounded corners
rl.draw_rectangle_rounded(self._rect, 0.07, 10, bg_color)
# Calculate text position with indent
text_x = self._rect.x + self.indent_level * INDENT_SIZE + 20
text_y = self._rect.y + (self._rect.height - TITLE_FONT_SIZE) / 2
text_width = self._rect.width - self.indent_level * INDENT_SIZE - 40
# Draw folder icon if folder
if self.is_folder and self.folder_icon:
icon_x = text_x
icon_y = self._rect.y + (self._rect.height - FOLDER_ICON_SIZE) / 2
rl.draw_texture_ex(self.folder_icon, rl.Vector2(icon_x, icon_y), 0,
FOLDER_ICON_SIZE / self.folder_icon.width, rl.WHITE)
text_x += FOLDER_ICON_SIZE + 10
text_width -= FOLDER_ICON_SIZE + 10
# Draw text
text_rect = rl.Rectangle(text_x, text_y, text_width - STAR_ICON_SIZE - 20, TITLE_FONT_SIZE)
gui_label(text_rect, self.text, 55, font_weight=FontWeight.LIGHT,
color=rl.WHITE)
# Draw star icon for non-folder items (on the right)
if not self.is_folder and self.star_icon:
star_x = self._rect.x + self._rect.width - STAR_ICON_SIZE - 20
star_y = self._rect.y + (self._rect.height - STAR_ICON_SIZE) / 2
star_rect = rl.Rectangle(star_x, star_y, STAR_ICON_SIZE, STAR_ICON_SIZE)
# Check if star icon is clicked
for mouse_event in gui_app.mouse_events:
if mouse_event.left_released and rl.check_collision_point_rec(mouse_event.pos, star_rect):
if self._favorite_callback:
self._favorite_callback(self)
return
# Draw star icon (filled if favorite, empty otherwise)
icon_scale = STAR_ICON_SIZE / self.star_icon.width
rl.draw_texture_ex(self.star_icon, rl.Vector2(star_x, star_y), 0, icon_scale,
rl.WHITE if self.is_favorite else rl.Color(255, 255, 255, 100))
def _handle_mouse_release(self, mouse_pos):
"""Handle click on the item"""
if self._click_callback:
self._click_callback(self)
return True
class TreeOptionDialog(Widget):
"""Dialog for selecting an item from a hierarchical tree structure with favorites support"""
def __init__(self, title: str, items: list[TreeFolder], current: str = "", fav_param: str = ""):
super().__init__()
self.title = title
self.items = items
self.current = current
self.selection = current
self.fav_param = fav_param
self.params = Params()
# Load favorites from params
self.favorites: set[str] = set()
if fav_param:
fav_str = self.params.get(fav_param)
if fav_str:
self.favorites = set(fav for fav in fav_str.split(';') if fav)
# Load icons
self.star_filled_icon = None
self.star_empty_icon = None
self.folder_icon = None
try:
self.star_filled_icon = rl.load_texture("../../sunnypilot/selfdrive/assets/icons/star-filled.png")
self.star_empty_icon = rl.load_texture("../../sunnypilot/selfdrive/assets/icons/star-empty.png")
self.folder_icon = rl.load_texture("../assets/icons/menu.png")
except:
# Icons are optional
pass
# Build tree structure
self.tree_items: list[TreeItemWidget] = []
self.ref_to_item: dict[str, TreeItemWidget] = {} # Map ref to item for quick lookup
self._build_tree()
# Create scroller with all visible items
self.scroller = Scroller(self._get_visible_items(), spacing=ITEM_SPACING, pad_end=True)
# Buttons
self.cancel_button = Button("Cancel", click_callback=lambda: gui_app.set_modal_overlay(None))
self.select_button = Button("Select", click_callback=self._on_select, button_style=ButtonStyle.PRIMARY)
def _build_tree(self):
"""Build the tree structure from the input items"""
folders: dict[str, TreeItemWidget] = {}
# Create favorites folder
favorites_folder = TreeItemWidget(
"Favorites", "", is_folder=True, indent_level=0,
click_callback=self._on_item_clicked,
folder_icon=self.folder_icon
)
folders["__favorites__"] = favorites_folder
self.tree_items.append(favorites_folder)
# First pass: create all folders and items
for tree_folder in self.items:
folder_name = tree_folder.folder
if not folder_name:
# Top-level items (no folder)
for node in tree_folder.items:
item = self._create_item_widget(node, indent_level=0)
self.tree_items.append(item)
self.ref_to_item[node.ref] = item
else:
# Items in a folder
if folder_name not in folders:
folder_widget = TreeItemWidget(
folder_name, "", is_folder=True, indent_level=0,
click_callback=self._on_item_clicked,
folder_icon=self.folder_icon
)
folders[folder_name] = folder_widget
# Insert after favorites folder
self.tree_items.insert(1, folder_widget)
folder_widget = folders[folder_name]
for node in tree_folder.items:
item = self._create_item_widget(node, indent_level=1)
folder_widget.add_child(item)
self.ref_to_item[node.ref] = item
# Auto-expand folder if it contains the current selection
if node.ref == self.current:
folder_widget.is_expanded = True
# Second pass: populate favorites folder
for fav_ref in self.favorites:
if fav_ref in self.ref_to_item:
original_item = self.ref_to_item[fav_ref]
# Create a duplicate item for the favorites folder
fav_item = self._create_item_widget(
TreeNode(original_item.text, fav_ref, 0),
indent_level=1
)
favorites_folder.add_child(fav_item)
# Auto-expand favorites if it contains the current selection
if fav_ref == self.current:
favorites_folder.is_expanded = True
def _create_item_widget(self, node: TreeNode, indent_level: int) -> TreeItemWidget:
"""Create a tree item widget from a TreeNode"""
is_fav = node.ref in self.favorites
star_icon = self.star_filled_icon if is_fav else self.star_empty_icon
item = TreeItemWidget(
node.display_name, node.ref, is_folder=False, indent_level=indent_level,
click_callback=self._on_item_clicked,
favorite_callback=self._on_favorite_toggled,
is_favorite=is_fav,
star_icon=star_icon
)
if node.ref == self.current:
item.is_selected = True
return item
def _get_visible_items(self) -> list[TreeItemWidget]:
"""Get all currently visible items in the tree (respecting expand/collapse state)"""
visible = []
for item in self.tree_items:
visible.append(item)
if item.is_folder and item.is_expanded:
visible.extend(item.children)
return visible
def _on_item_clicked(self, item: TreeItemWidget):
"""Handle click on a tree item"""
if item.is_folder:
# Toggle folder expansion
item.toggle_expand()
# Rebuild scroller with new visible items
visible_items = self._get_visible_items()
self.scroller._items = visible_items
for widget in visible_items:
widget.set_touch_valid_callback(self.scroller.scroll_panel.is_touch_valid)
else:
# Select the item
# Deselect all items first
for ref_item in self.ref_to_item.values():
ref_item.is_selected = False
# Also deselect favorite duplicates
for tree_item in self.tree_items:
if tree_item.is_folder:
for child in tree_item.children:
child.is_selected = False
# Select the clicked item
item.is_selected = True
self.selection = item.ref
def _on_favorite_toggled(self, item: TreeItemWidget):
"""Handle toggling favorite status of an item"""
if item.ref in self.favorites:
# Remove from favorites
self.favorites.discard(item.ref)
# Update all instances of this item (original + duplicate in favorites)
if item.ref in self.ref_to_item:
self.ref_to_item[item.ref].is_favorite = False
self.ref_to_item[item.ref].star_icon = self.star_empty_icon
# Remove from favorites folder
favorites_folder = self.tree_items[0]
favorites_folder.children = [c for c in favorites_folder.children if c.ref != item.ref]
else:
# Add to favorites
self.favorites.add(item.ref)
# Update all instances of this item
if item.ref in self.ref_to_item:
self.ref_to_item[item.ref].is_favorite = True
self.ref_to_item[item.ref].star_icon = self.star_filled_icon
# Add to favorites folder
favorites_folder = self.tree_items[0]
fav_item = self._create_item_widget(
TreeNode(item.text, item.ref, 0),
indent_level=1
)
# Insert at the beginning of favorites
favorites_folder.children.insert(0, fav_item)
# Save favorites to params
if self.fav_param:
self.params.put(self.fav_param, ';'.join(self.favorites))
# Rebuild scroller with new visible items
visible_items = self._get_visible_items()
self.scroller._items = visible_items
for widget in visible_items:
widget.set_touch_valid_callback(self.scroller.scroll_panel.is_touch_valid)
def _on_select(self):
"""Handle select button click"""
gui_app.set_modal_overlay(None)
def _render(self, rect):
dialog_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN,
rect.width - 2 * MARGIN, rect.height - 2 * MARGIN)
rl.draw_rectangle_rounded(dialog_rect, 0.02, 20, rl.Color(27, 27, 27, 255))
content_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN,
dialog_rect.width - 2 * MARGIN, dialog_rect.height - 2 * MARGIN)
# Title
gui_label(rl.Rectangle(content_rect.x, content_rect.y, content_rect.width, TITLE_FONT_SIZE),
self.title, 70, font_weight=FontWeight.MEDIUM)
# Tree area
tree_y = content_rect.y + TITLE_FONT_SIZE + 25
tree_h = content_rect.height - TITLE_FONT_SIZE - BUTTON_HEIGHT - 60
tree_rect = rl.Rectangle(content_rect.x, tree_y, content_rect.width, tree_h)
# Update all visible items with correct width
for item in self._get_visible_items():
item.set_rect(rl.Rectangle(0, 0, tree_rect.width, ITEM_HEIGHT))
self.scroller.render(tree_rect)
# Buttons
button_y = content_rect.y + content_rect.height - BUTTON_HEIGHT
button_w = (content_rect.width - BUTTON_SPACING) / 2
cancel_rect = rl.Rectangle(content_rect.x, button_y, button_w, BUTTON_HEIGHT)
self.cancel_button.render(cancel_rect)
select_rect = rl.Rectangle(content_rect.x + button_w + BUTTON_SPACING, button_y, button_w, BUTTON_HEIGHT)
self.select_button.set_enabled(self.selection != self.current)
self.select_button.render(select_rect)
return -1
def show_event(self):
"""Reset scroll position when dialog is shown"""
self.scroller.show_event()
def hide_event(self):
"""Clean up when dialog is hidden"""
self.scroller.hide_event()

View File

@@ -1,6 +1,5 @@
import os
import requests
from requests.adapters import HTTPAdapter, Retry
API_HOST = os.getenv('API_HOST', 'https://api.commadotai.com')
# TODO: this should be merged into common.api
@@ -12,9 +11,6 @@ class CommaApi:
if token:
self.session.headers['Authorization'] = 'JWT ' + token
retries = Retry(total=5, backoff_factor=1, status_forcelist=[500, 502, 503, 504])
self.session.mount('https://', HTTPAdapter(max_retries=retries))
def request(self, method, endpoint, **kwargs):
with self.session.request(method, API_HOST + '/' + endpoint, **kwargs) as resp:
resp_json = resp.json()