Compare commits

..

114 Commits

Author SHA1 Message Date
discountchubbs
87a67ac195 all protected 2025-11-20 14:07:07 -08:00
discountchubbs
dc1edf294e protected 2025-11-20 14:04:11 -08:00
discountchubbs
aef9a95c42 super() wasn't needed 2025-11-20 14:01:22 -08:00
discountchubbs
0ccded8294 wow nayan, update_state is cool 2025-11-20 11:53:56 -08:00
discountchubbs
c1614d197a # Conflicts:
#	system/ui/sunnypilot/widgets/list_view.py
2025-11-20 11:45:15 -08:00
discountchubbs
15dac3d906 attach refresh to render 2025-11-18 20:32:10 -08:00
discountchubbs
98ecfafcdd raylib 2025-11-18 12:04:28 -08:00
discountchubbs
0306e59ac1 input_dia 2025-11-18 06:50:12 -08:00
discountchubbs
2d80f2db96 that 2025-11-18 06:50:07 -08:00
discountchubbs
795ed7afb5 git merge rl-sp-multibutton 2025-11-17 16:52:07 -08:00
discountchubbs
206368ec68 git merge optimize gui app 2025-11-17 16:52:02 -08:00
discountchubbs
aa141521fc raylib: SP Panels by @nayan 2025-11-17 06:11:07 -08:00
James Vecellio-Grant
6e421989ab Merge branch 'nav-desires' into nav-raylib 2025-11-16 15:42:29 -08:00
James Vecellio-Grant
9c3a73b4cf Merge branch 'navigationd-service' into nav-desires 2025-11-16 15:42:20 -08:00
discountchubbs
d10349721c gotcha! 2025-11-16 15:38:46 -08:00
discountchubbs
b48214acd2 formatting i guess 2025-11-16 15:31:34 -08:00
James Vecellio-Grant
8adbd56acd Merge branch 'navigationd-service' into nav-desires 2025-11-16 15:25:42 -08:00
discountchubbs
65db08f4d1 walrus it up 2025-11-16 15:24:43 -08:00
discountchubbs
a1e305333f Bring up to date with nav-commacon 2025-11-16 15:20:44 -08:00
James Vecellio-Grant
c14b81585e Merge branch 'navigationd-init' into navigationd-service 2025-11-16 15:12:32 -08:00
discountchubbs
f3d8b24bf4 bearing 2025-11-16 15:10:42 -08:00
discountchubbs
880ed98ffc Merge remote-tracking branch 'origin/master' into navigationd-init 2025-11-16 15:00:46 -08:00
Jason Wen
f1025f6ee9 Sync: commaai/openpilot:master into sunnypilot/openpilot:master (#1470) 2025-11-16 03:16:58 -05:00
Jason Wen
08e85808c5 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-16 02:50:28 -05:00
Jason Wen
cb03d08397 tools: add retry mechanism for API requests (#1480) 2025-11-16 01:55:19 -05:00
James Vecellio-Grant
d106c192f2 Merge branch 'navigationd-service' into nav-desires 2025-11-10 18:49:22 -08:00
discountchubbs
98ffbe1308 more 2025-11-10 18:45:41 -08:00
discountchubbs
d09f74612f Revert "more"
This reverts commit b69da9e5ea.
2025-11-10 18:45:16 -08:00
discountchubbs
3af0d6e87f more 2025-11-10 18:44:26 -08:00
discountchubbs
584269fced Revert "more"
This reverts commit b69da9e5ea.
2025-11-10 18:43:50 -08:00
James Vecellio-Grant
1dc5741e75 Merge branch 'navigationd-service' into nav-desires 2025-11-10 18:41:36 -08:00
discountchubbs
b69da9e5ea more 2025-11-10 18:41:14 -08:00
discountchubbs
0ccd55a6b5 sm 2025-11-10 18:37:47 -08:00
James Vecellio-Grant
48dc9dbb69 Merge branch 'navigationd-service' into nav-desires 2025-11-10 12:41:03 -08:00
discountchubbs
56ca486fe9 more frames 2025-11-10 12:39:42 -08:00
discountchubbs
799e819e58 more desire changes 2025-11-10 11:57:55 -08:00
James Vecellio-Grant
aaac1c79d0 Merge branch 'navigationd-service' into nav-desires 2025-11-10 11:37:15 -08:00
discountchubbs
f46de2d0d5 adjust for gps bounce 2025-11-10 11:36:07 -08:00
James Vecellio-Grant
d7fa10a827 Merge branch 'navigationd-service' into nav-desires 2025-11-10 08:58:01 -08:00
James Vecellio-Grant
22b010f674 Merge branch 'navigationd-init' into navigationd-service 2025-11-10 08:57:44 -08:00
James Vecellio-Grant
dcaf84d04c Merge branch 'master' into navigationd-init 2025-11-10 08:57:31 -08:00
discountchubbs
3a82a0797a v_ego 2025-11-10 08:56:49 -08:00
discountchubbs
365e978b42 dynamic distance interpolation 2025-11-10 08:50:21 -08:00
James Vecellio-Grant
2d1f3833e4 Merge branch 'master' into navigationd-init 2025-11-08 10:10:13 -08:00
James Vecellio-Grant
ba176a6581 Merge branch 'navigationd-service' into nav-desires 2025-11-07 07:45:21 -08:00
James Vecellio-Grant
63e5d0a476 Merge branch 'navigationd-init' into navigationd-service 2025-11-07 07:45:11 -08:00
discountchubbs
e28dd1e1aa 30 meters before turn is more natural 2025-11-07 07:44:38 -08:00
discountchubbs
43b4e4e271 check packets in selfdrived 2025-11-06 12:17:52 -08:00
discountchubbs
864c811ef6 small clean 2025-11-05 19:05:29 -08:00
James Vecellio-Grant
906e9d7a80 Merge branch 'navigationd-service' into nav-desires 2025-11-05 18:51:41 -08:00
discountchubbs
cfb8f3ae24 main entry point for navigation updates 2025-11-05 18:49:32 -08:00
James Vecellio-Grant
0cc5e56192 Merge branch 'navigationd-init' into navigationd-service 2025-11-05 18:03:19 -08:00
discountchubbs
1a62ae821e green means good 2025-11-05 18:02:38 -08:00
James Vecellio-Grant
b6dd2d14db Merge branch 'navigationd-service' into nav-desires 2025-11-01 08:02:18 -07:00
James Vecellio-Grant
7d4e5bedaf Merge branch 'navigationd-init' into navigationd-service 2025-11-01 08:02:08 -07:00
James Vecellio-Grant
1063114408 Merge branch 'master' into navigationd-init 2025-11-01 08:01:57 -07:00
discountchubbs
958b4df69f give slightly more leniancy for offline routing 2025-11-01 08:01:30 -07:00
discountchubbs
72998034e6 copyright 2025-10-30 06:26:01 -07:00
discountchubbs
cefb344183 copyright 2025-10-30 06:22:34 -07:00
discountchubbs
d17e80ad94 Merge remote-tracking branch 'origin/navigationd-service' into nav-desires 2025-10-29 19:41:29 -07:00
discountchubbs
c2b7087723 Merge remote-tracking branch 'origin/navigationd-init' into navigationd-service 2025-10-29 19:40:19 -07:00
James Vecellio-Grant
81b37712f1 Merge branch 'master' into navigationd-init 2025-10-29 19:39:41 -07:00
discountchubbs
18cd3633e5 Merge remote-tracking branch 'origin/navigationd-service' into nav-desires 2025-10-29 19:37:41 -07:00
discountchubbs
9c6a4d4a57 Merge remote-tracking branch 'origin/navigationd-init' into navigationd-service 2025-10-29 19:37:10 -07:00
discountchubbs
1a4c48249b fix: handle empty maxspeed list in nav_instructions 2025-10-29 19:36:39 -07:00
discountchubbs
5f5e3668eb Add steering pressed and torque to desire for non blindspot cars, and a note! 2025-10-28 06:43:00 -07:00
discountchubbs
8c07958f6f Merge remote-tracking branch 'origin/navigationd-service' into nav-desires 2025-10-28 06:28:33 -07:00
discountchubbs
ca1ce9bcc9 Clear route cache more gracefully to allow we based route connection to happen quick ish 2025-10-28 06:27:51 -07:00
discountchubbs
29f15dc8ed Merge remote-tracking branch 'origin/navigationd-service' into nav-desires 2025-10-26 11:07:16 -07:00
discountchubbs
31a5a3b3c0 assertion comparison operators 2025-10-26 11:06:00 -07:00
discountchubbs
2a4b348497 Merge remote-tracking branch 'origin/navigationd-service' into nav-desires 2025-10-26 08:44:38 -07:00
discountchubbs
3ef3aceb4b Apply auto cancel route after 10 seconds off route. 2025-10-26 08:44:03 -07:00
James Vecellio-Grant
3d8763b3ce Merge branch 'master' into navigationd-init 2025-10-25 21:14:39 -07:00
James Vecellio-Grant
b2427a5f20 Merge branch 'master' into navigationd-init 2025-10-25 16:27:44 -07:00
discountchubbs
ff4cc96a81 Merge remote-tracking branch 'origin/navigationd-service' into nav-desires 2025-10-23 19:21:04 -07:00
discountchubbs
3b1ada64be sync 2025-10-23 19:20:15 -07:00
discountchubbs
6a08186434 Merge remote-tracking branch 'origin/navigationd-init' into navigationd-service 2025-10-23 19:17:49 -07:00
discountchubbs
cf2b033c79 clean 2025-10-23 19:15:54 -07:00
discountchubbs
9fbef36c6b desire handling 2025-10-23 19:12:34 -07:00
discountchubbs
7b28c2f59a Merge remote-tracking branch 'origin/navigationd-service' into nav-desires 2025-10-23 17:24:16 -07:00
discountchubbs
99d954de10 sync 2025-10-23 17:23:47 -07:00
discountchubbs
b28f33481c Merge remote-tracking branch 'origin/navigationd-init' into navigationd-service 2025-10-23 17:21:56 -07:00
discountchubbs
589e33f665 sum red diff 2025-10-23 17:20:23 -07:00
discountchubbs
39342d7b5e diff is red so == good? 2025-10-23 17:16:29 -07:00
discountchubbs
b763f7aac1 Merge remote-tracking branch 'origin/navigationd-service' into nav-desires 2025-10-21 16:27:02 -07:00
discountchubbs
450fcd4d55 oopsie part two lol 2025-10-21 16:25:30 -07:00
James Vecellio
551b4dea31 oopsie 2025-10-21 16:20:20 -07:00
James Vecellio
bd269defb3 oopsie 2025-10-21 16:19:49 -07:00
James Vecellio-Grant
399ed08926 Merge branch 'master' into navigationd-init 2025-10-21 15:29:45 -07:00
James Vecellio-Grant
90f02040fe Merge branch 'master' into navigationd-service 2025-10-21 15:29:42 -07:00
James Vecellio-Grant
8423ecedb1 Merge branch 'master' into nav-desires 2025-10-21 15:29:39 -07:00
discountchubbs
dd1479ed82 More macOS crap 🤠 2025-10-21 15:25:15 -07:00
discountchubbs
f82845ff42 Merge remote-tracking branch 'origin/navigationd-service' into nav-desires 2025-10-21 12:05:13 -07:00
discountchubbs
efcc5ccd15 Merge remote-tracking branch 'origin/navigationd-init' into navigationd-service 2025-10-21 12:03:44 -07:00
James Vecellio-Grant
6aac50ab56 Merge branch 'master' into navigationd-init 2025-10-21 12:03:05 -07:00
discountchubbs
091bce4a3a Merge remote-tracking branch 'origin/navigationd-service' into nav-desires 2025-10-21 05:54:42 -07:00
discountchubbs
088f6aa407 sync 2025-10-21 05:53:12 -07:00
James Vecellio-Grant
211c8adcce Merge branch 'master' into navigationd-init 2025-10-21 05:52:06 -07:00
discountchubbs
fe5366e5b2 rm unused import 2025-10-19 20:34:11 -07:00
discountchubbs
1ecb0b0f66 dumb 2025-10-19 20:16:36 -07:00
discountchubbs
51e455db79 stupid msgq always breaking macOS 2025-10-19 20:15:14 -07:00
discountchubbs
dc6672fa80 Merge remote-tracking branch 'origin/navigationd-init' into navigationd-service 2025-10-19 20:02:36 -07:00
discountchubbs
07b8e7783d Do i really need a readme 2025-10-19 19:47:21 -07:00
discountchubbs
f17b0f200c non blocking polling
SOME attribute protection. kids crying, so need to stop for now!
2025-10-19 17:20:13 -07:00
discountchubbs
ad9bde8b1f non blocking polling
SOME attribute protection. kids crying, so need to stop for now!
2025-10-19 17:17:37 -07:00
discountchubbs
8cf9f9fe23 params!
maybe these should be protected attributes, but thats a tomorrow problem
2025-10-19 17:03:04 -07:00
discountchubbs
713985d823 feat: navigationd desire loop
Notes: Maybe I should add a param for this, so its not automatic when a route is set.
2025-10-19 09:38:29 -07:00
James Vecellio-Grant
088f9d0b59 Merge branch 'master' into navigationd-service 2025-10-18 18:22:34 -07:00
James Vecellio-Grant
53bf5b0d41 Merge branch 'master' into navigationd-init 2025-10-18 18:22:27 -07:00
discountchubbs
8c33592628 Revert "feat: navigationd" bc it was supposed to be a branch lol
This reverts commit 3bbb33f6bd.
2025-10-18 18:18:28 -07:00
James Vecellio
3bbb33f6bd feat: navigationd
I changed the reroute counter to 9 updates, which is every 3 seconds.  compared to 3, which is one second.
2025-10-18 18:15:22 -07:00
discountchubbs
5bd9549bd1 some clean up for production 2025-10-16 15:43:34 -07:00
discountchubbs
3481702715 some suggestions applied 2025-10-16 06:55:15 -07:00
discountchubbs
c9781ee31d feat: mapbox navigation helpers 2025-10-16 06:43:48 -07:00
58 changed files with 1323 additions and 1267 deletions

View File

@@ -21,11 +21,12 @@ 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 -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 -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
PYTEST: pytest --continue-on-collection-errors --durations=0 -n logical

View File

@@ -369,6 +369,7 @@ struct CarControlSP @0xa5cd762cd951a455 {
leadOne @2 :LeadData;
leadTwo @3 :LeadData;
intelligentCruiseButtonManagement @4 :IntelligentCruiseButtonManagement;
speed @5 :Float32;
struct Param {
key @0 :Text;
@@ -454,7 +455,20 @@ struct ModelDataV2SP @0xa1680744031fdb2d {
}
}
struct CustomReserved10 @0xcb9fd56c7057593a {
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 CustomReserved11 @0xc2243c65e0340384 {

View File

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

View File

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

View File

@@ -173,9 +173,8 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"ShowAdvancedControls", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ShowTurnSignals", {PERSISTENT | BACKUP, BOOL, "0"}},
{"StandstillTimer", {PERSISTENT | BACKUP, BOOL, "0"}},
{"sunnypilot_ui", {PERSISTENT, BOOL, "1"}},
{"TrueVEgoUI", {PERSISTENT | BACKUP, BOOL, "0"}},
{"sunnypilot_ui", {PERSISTENT | BACKUP, BOOL, "1"}},
{"UseRaylib", {PERSISTENT | BACKUP, BOOL, "0"}},
// MADS params
{"Mads", {PERSISTENT | BACKUP, BOOL, "1"}},
@@ -191,6 +190,15 @@ 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"}},
{"MapboxFavorites", {PERSISTENT | BACKUP, STRING}},
{"MapboxToken", {PERSISTENT | BACKUP, STRING}},
{"MapboxSettings", {CLEAR_ON_MANAGER_START, JSON}},
{"MapboxRoute", {PERSISTENT, STRING}},
{"MapboxRecompute", {PERSISTENT | BACKUP, BOOL, "0"}},
{"NavDesiresAllowed", {PERSISTENT | BACKUP, BOOL, "0"}},
// Neural Network Lateral Control
{"NeuralNetworkLateralControl", {PERSISTENT | BACKUP, BOOL, "0"}},

2
panda

Submodule panda updated: e4115086b0...dee9061b2a

View File

@@ -3,6 +3,7 @@ from openpilot.common.constants import CV
from openpilot.common.realtime import DT_MDL
from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeController, AutoLaneChangeMode
from openpilot.sunnypilot.selfdrive.controls.lib.lane_turn_desire import LaneTurnController
from openpilot.sunnypilot.navd.navigation_desires.navigation_desires import NavigationDesires
LaneChangeState = log.LaneChangeState
LaneChangeDirection = log.LaneChangeDirection
@@ -51,6 +52,7 @@ class DesireHelper:
self.alc = AutoLaneChangeController(self)
self.lane_turn_controller = LaneTurnController(self)
self.lane_turn_direction = TurnDirection.none
self.navigation_desires = NavigationDesires()
@staticmethod
def get_lane_change_direction(CS):
@@ -143,3 +145,7 @@ class DesireHelper:
self.desire = log.Desire.none
self.alc.update_state()
nav_desire = self.navigation_desires.update(carstate, lateral_active)
if nav_desire != log.Desire.none and (self.desire == log.Desire.none or self.desire in (log.Desire.turnLeft, log.Desire.turnRight)):
self.desire = nav_desire

View File

@@ -13,8 +13,6 @@ 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

@@ -10,7 +10,7 @@ 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)
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp as toggle_item
# Description constants
DESCRIPTIONS = {

View File

@@ -9,11 +9,11 @@ from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets import DialogResult
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)
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp as toggle_item
from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp as multiple_button_item
PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants
# Description constants
DESCRIPTIONS = {

View File

@@ -1,17 +0,0 @@
"""
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

@@ -8,10 +8,10 @@ class CruiseLayout(Widget):
super().__init__()
self._params = Params()
items = self._init_items()
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
def _initialize_items(self):
items = [
]
@@ -19,3 +19,6 @@ class CruiseLayout(Widget):
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()

View File

@@ -1,126 +1,5 @@
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

@@ -8,10 +8,10 @@ class DisplayLayout(Widget):
super().__init__()
self._params = Params()
items = self._init_items()
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
def _initialize_items(self):
items = [
]
@@ -19,3 +19,6 @@ class DisplayLayout(Widget):
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()

View File

@@ -1,8 +1,5 @@
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
@@ -11,80 +8,17 @@ class ModelsLayout(Widget):
super().__init__()
self._params = Params()
items = self._init_items()
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
def _initialize_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.
def show_event(self):
self._scroller.show_event()

View File

@@ -0,0 +1,124 @@
import json
from functools import partial
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import DialogResult, Widget
from openpilot.system.ui.widgets.confirm_dialog import alert_dialog
from openpilot.system.ui.widgets.list_view import button_item
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.sunnypilot.navd.navigationd import Navigationd
from openpilot.system.ui.sunnypilot.widgets.input_dialog import InputDialogSP
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp, multiple_button_item_sp
class NavigationLayout(Widget):
def __init__(self):
super().__init__()
self._navd = Navigationd()
self._params = Params()
self._mapbox_token_item = button_item("Mapbox token", "Edit", "Enter your mapbox public token",
partial(self._show_param_input, "MapboxToken", "Enter Mapbox Token"))
self._mapbox_route_item = button_item("Mapbox route", "Edit", "",
partial(self._show_param_input, "MapboxRoute", "Enter Mapbox Route"))
self._vis_items = [
button_item("Set Home", "Set", "", partial(self._open_fav_dialog, "home", "Set Home Route")),
button_item("Set Work", "Set", "", partial(self._open_fav_dialog, "work", "Set Work Route")),
button_item("Add Favorite", "Add", "Add a new favorite", self._add_fav),
button_item("Remove Favorite", "Remove", "Remove a favorite", self._remove_fav),
toggle_item_sp("Mapbox recompute", "Enable automatic route recomputation", param="MapboxRecompute"),
toggle_item_sp("Navigation allowed", "Allow navigation to automatically take turns", param="NavDesiresAllowed"),
]
self.items = [
self._mapbox_token_item, self._mapbox_route_item,
button_item("Clear current route", "Clear", "", self._clear_route),
multiple_button_item_sp("Favorites", "Select favorite route", ["Home", "Work", "Favorites"], 0, callback=self._favorites_callback),
*self._vis_items[:4],
toggle_item_sp("Allow navigation", "Enable navigation service", callback=self._update_navigation_visibility, param="AllowNavigation"),
*self._vis_items[4:],
]
self._scroller = Scroller(self.items, line_separator=True, spacing=0)
@property
def _favs(self):
try:
return json.loads(self._params.get("MapboxFavorites") or "{}")
except Exception:
return {}
def _show_param_input(self, param, title):
InputDialogSP(title, current_text=self._params.get(param, return_default=True) or "", param=param).show()
def _clear_route(self):
self._navd.route = None
self._params.remove("MapboxRoute")
def _handle_save_fav(self, key, is_fav, res, text):
if res == DialogResult.CONFIRM and text:
favs = self._favs
(favs.setdefault("favorites", {}) if is_fav else favs)[key] = text
self._params.put("MapboxFavorites", json.dumps(favs))
def _open_fav_dialog(self, key, title):
InputDialogSP(title, current_text=self._favs.get(key, ""), callback=partial(self._handle_save_fav, key, False)).show()
def _add_fav_name_cb(self, res, name):
if res == DialogResult.CONFIRM and name:
InputDialogSP(f"Set Route for {name}", "", callback=partial(self._handle_save_fav, name, True), min_text_size=1).show()
def _add_fav(self):
InputDialogSP("Favorite Name", "", callback=self._add_fav_name_cb, min_text_size=1).show()
def _set_mapbox_route_cb(self, favorites, selection):
self._params.put("MapboxRoute", favorites[selection])
def _favorites_callback(self, index):
favs = self._favs
if index < 2:
if route := favs.get(["home", "work"][index]):
self._params.put("MapboxRoute", route)
elif favorites := favs.get("favorites"):
self._show_list_dialog(tr("Select Favorite"), list(favorites.keys()), partial(self._set_mapbox_route_cb, favorites))
else:
gui_app.set_modal_overlay(alert_dialog(tr("No custom favorites set")))
def _remove_fav_cb(self, selection):
favs = self._favs
if favs.get("favorites", {}).pop(selection, None):
self._params.put("MapboxFavorites", json.dumps(favs))
def _remove_fav(self):
if favorites := self._favs.get("favorites"):
self._show_list_dialog(tr("Remove Favorite"), list(favorites.keys()), self._remove_fav_cb)
else:
gui_app.set_modal_overlay(alert_dialog(tr("No custom favorites to remove")))
def _list_dialog_cb(self, callback, res):
if res == DialogResult.CONFIRM and self._dialog.selection:
callback(self._dialog.selection)
gui_app.set_modal_overlay(None)
def _show_list_dialog(self, title, items, callback):
self._dialog = MultiOptionDialog(title, items)
gui_app.set_modal_overlay(self._dialog, callback=partial(self._list_dialog_cb, callback))
def _update_navigation_visibility(self, state):
for item in self._vis_items:
item.set_visible(state)
def _update_state(self):
self._mapbox_token_item.action_item.set_value(self._params.get("MapboxToken") or "Mapbox token not set")
self._mapbox_route_item.action_item.set_value(self._params.get("MapboxRoute") or "Destination not set")
self._update_navigation_visibility(self._params.get_bool("AllowNavigation"))
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()

View File

@@ -8,10 +8,10 @@ class OSMLayout(Widget):
super().__init__()
self._params = Params()
items = self._init_items()
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
def _initialize_items(self):
items = [
]
@@ -19,3 +19,6 @@ class OSMLayout(Widget):
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()

View File

@@ -22,7 +22,8 @@ from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle import VehicleLa
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
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.display import DisplayLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.navigation import NavigationLayout
OP.PANEL_COLOR = rl.Color(10, 10, 10, 255)
ICON_SIZE = 70
@@ -37,6 +38,7 @@ OP.PanelType = IntEnum( # type: ignore
"VISUALS",
"DISPLAY",
"OSM",
"NAVIGATION",
"TRIPS",
"VEHICLE",
],
@@ -72,6 +74,7 @@ class SettingsLayoutSP(OP.SettingsLayout):
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.NAVIGATION: PanelInfo(tr_noop("Navigation"), NavigationLayout(), 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"),

View File

@@ -8,10 +8,10 @@ class SteeringLayout(Widget):
super().__init__()
self._params = Params()
items = self._init_items()
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
def _initialize_items(self):
items = [
]
@@ -19,3 +19,6 @@ class SteeringLayout(Widget):
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()

View File

@@ -8,10 +8,10 @@ class SunnylinkLayout(Widget):
super().__init__()
self._params = Params()
items = self._init_items()
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
def _initialize_items(self):
items = [
]
@@ -19,3 +19,6 @@ class SunnylinkLayout(Widget):
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()

View File

@@ -8,10 +8,10 @@ class TripsLayout(Widget):
super().__init__()
self._params = Params()
items = self._init_items()
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
def _initialize_items(self):
items = [
]
@@ -19,3 +19,6 @@ class TripsLayout(Widget):
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()

View File

@@ -8,10 +8,10 @@ class VehicleLayout(Widget):
super().__init__()
self._params = Params()
items = self._init_items()
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
def _initialize_items(self):
items = [
]
@@ -19,3 +19,6 @@ class VehicleLayout(Widget):
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()

View File

@@ -8,10 +8,10 @@ class VisualsLayout(Widget):
super().__init__()
self._params = Params()
items = self._init_items()
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self):
def _initialize_items(self):
items = [
]
@@ -19,3 +19,6 @@ class VisualsLayout(Widget):
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()

View File

@@ -1,6 +0,0 @@
"""
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

@@ -1,145 +0,0 @@
"""
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

@@ -148,7 +148,7 @@ def setup_experimental_mode_description(click, pm: PubMaster):
def setup_openpilot_long_confirmation_dialog(click, pm: PubMaster):
setup_settings_developer(click, pm)
click(2000, 960) # toggle openpilot longitudinal control
click(650, 960) # toggle openpilot longitudinal control
def setup_onroad(click, pm: PubMaster):
@@ -292,6 +292,7 @@ def create_screenshots():
with OpenpilotPrefix():
params = Params()
params.put("DongleId", "123456789012345")
params.put_bool("sunnypilot_ui", True)
# Set branch name
params.put("UpdaterCurrentDescription", VERSION)

View File

@@ -0,0 +1,7 @@
# 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).
- `nav raylib`: Raylib UI for navigation. This may take a hot minute to make, maybe 1-2 weeks.

View File

View File

@@ -0,0 +1,16 @@
"""
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,6 +72,15 @@ 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)
@@ -126,6 +135,8 @@ 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

@@ -0,0 +1,42 @@
"""
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 cereal.messaging as messaging
from cereal import car, log
from openpilot.common.constants import CV
from openpilot.common.params import Params
class NavigationDesires:
def __init__(self):
self.sm = messaging.SubMaster(['navigationd'])
self.desire = log.Desire.none
self._turn_speed_limit = 20 * CV.MPH_TO_MS
self._params = Params()
self.param_counter = -1
self.nav_allowed: bool = False
def update_params(self):
self.param_counter += 1
if self.param_counter % 60 == 0: # every 3 seconds at 20hz
self.nav_allowed = self._params.get("NavDesiresAllowed", return_default=True)
def update(self, CS: car.CarState, lateral_active: bool) -> log.Desire:
self.update_params()
self.sm.update(0)
nav_msg = self.sm['navigationd']
self.desire = log.Desire.none
if self.nav_allowed and nav_msg.valid and lateral_active:
upcoming = nav_msg.upcomingTurn
if upcoming == 'slightLeft' and not CS.rightBlinker and not CS.leftBlindspot and CS.steeringPressed and CS.steeringTorque > 0:
self.desire = log.Desire.keepLeft
elif upcoming == 'slightRight' and not CS.leftBlinker and not CS.rightBlindspot and CS.steeringPressed and CS.steeringTorque < 0:
self.desire = log.Desire.keepRight
elif upcoming == 'left' and not CS.rightBlinker and not CS.leftBlindspot and CS.vEgo < self._turn_speed_limit:
self.desire = log.Desire.turnLeft
elif upcoming == 'right' and not CS.leftBlinker and not CS.rightBlindspot and CS.vEgo < self._turn_speed_limit:
self.desire = log.Desire.turnRight
return self.desire

View File

@@ -0,0 +1,96 @@
"""
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 pytest
import types
from cereal import log
from openpilot.common.params import Params
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
from openpilot.sunnypilot.navd.navigation_desires.navigation_desires import NavigationDesires
def make_car(vEgo=0, leftBlinker=False, rightBlinker=False, leftBlindspot=False, rightBlindspot=False, steeringPressed=False, steeringTorque=0):
return types.SimpleNamespace(
vEgo=vEgo, leftBlinker=leftBlinker, rightBlinker=rightBlinker,
leftBlindspot=leftBlindspot, rightBlindspot=rightBlindspot,
steeringPressed=steeringPressed, steeringTorque=steeringTorque
)
NAVIGATION_PARAMS: list[tuple] = [
('slightLeft', make_car(steeringPressed=True, steeringTorque=1), log.Desire.keepLeft),
('slightRight', make_car(steeringPressed=True, steeringTorque=-1), log.Desire.keepRight),
('slightLeft', make_car(vEgo=9, leftBlindspot=True), log.Desire.none),
('slightRight', make_car(vEgo=9, rightBlindspot=True), log.Desire.none),
('left', make_car(vEgo=5, leftBlinker=True, rightBlinker=False, leftBlindspot=False), log.Desire.turnLeft),
('left', make_car(vEgo=5, leftBlinker=False, rightBlinker=True), log.Desire.none),
('right', make_car(vEgo=6, rightBlinker=True, leftBlindspot=False), log.Desire.turnRight),
('right', make_car(vEgo=6, rightBlinker=True, rightBlindspot=True), log.Desire.none),
('left', make_car(vEgo=9, leftBlinker=True), log.Desire.none),
]
INTEGRATION_PARAMS: list[tuple] = [(carstate, upcoming, log.Desire.none, expected) for upcoming, carstate, expected in NAVIGATION_PARAMS] + [
(make_car(vEgo=6, leftBlinker=True, steeringPressed=True, steeringTorque=1), 'slightLeft', log.Desire.turnLeft, log.Desire.keepLeft),
(make_car(vEgo=5, rightBlinker=True, steeringPressed=True, steeringTorque=-1), 'slightRight', log.Desire.turnRight, log.Desire.keepRight),
(make_car(vEgo=9, leftBlinker=True), 'slightLeft', log.Desire.laneChangeLeft, log.Desire.laneChangeLeft),
(make_car(vEgo=9, rightBlinker=True), 'slightRight', log.Desire.laneChangeRight, log.Desire.laneChangeRight),
(make_car(vEgo=9), 'none', log.Desire.none, log.Desire.none),
]
def make_nav_msg(valid=False, upcoming='none'):
return types.SimpleNamespace(valid=valid, upcomingTurn=upcoming)
def params_setter(allowed: bool):
params = Params()
params.put("NavDesiresAllowed", allowed)
@pytest.fixture
def mock_submaster(mocker):
mock_sm = mocker.patch('cereal.messaging.SubMaster')
mock_sm_instance = mocker.Mock()
mock_sm.return_value = mock_sm_instance
mock_sm_instance.__getitem__ = mocker.Mock(return_value=make_nav_msg(valid=False))
params_setter(True)
return mock_sm_instance
@pytest.mark.parametrize("upcoming, carstate, expected", NAVIGATION_PARAMS)
def test_navigation_desires_update(mock_submaster, mocker, upcoming, carstate, expected):
nav_desires = NavigationDesires()
nav_desires.sm.__getitem__ = mocker.Mock(return_value=make_nav_msg(valid=True, upcoming=upcoming))
nav_desires.update(carstate, True)
assert nav_desires.desire == expected
@pytest.mark.parametrize("msg_valid,lateral_active", [(False, True), (True, False)])
def test_invalid_or_inactive(mock_submaster, mocker, msg_valid, lateral_active):
nav_desires = NavigationDesires()
nav_desires.sm.__getitem__ = mocker.Mock(return_value=make_nav_msg(valid=msg_valid, upcoming='slightLeft'))
nav_desires.update(make_car(), lateral_active)
assert nav_desires.desire == log.Desire.none
def test_update(mock_submaster, mocker):
mock_submaster.__getitem__ = mocker.Mock(return_value=make_nav_msg(valid=True, upcoming='left'))
nav_desires = NavigationDesires()
assert nav_desires.update(make_car(leftBlinker=True, steeringPressed=True, steeringTorque=1), True) == log.Desire.turnLeft
params_setter(False)
nav_desires.param_counter = 59
nav_desires.update(make_car(leftBlinker=True), True)
assert nav_desires.desire == log.Desire.none
@pytest.mark.parametrize("carstate, upcoming, current_desire, expected", INTEGRATION_PARAMS)
def test_desire_helper(mock_submaster, mocker, carstate, upcoming, current_desire, expected):
mock_submaster.__getitem__ = mocker.Mock(return_value=make_nav_msg(valid=True, upcoming=upcoming))
dh = DesireHelper()
dh.desire = current_desire
if current_desire in (log.Desire.laneChangeLeft, log.Desire.laneChangeRight):
dh.lane_change_state = log.LaneChangeState.laneChangeStarting
dh.lane_change_direction = log.LaneChangeDirection.left if current_desire == log.Desire.laneChangeLeft else log.LaneChangeDirection.right
dh.update(carstate, True, 1.0)
assert dh.desire == expected

View File

@@ -0,0 +1,113 @@
"""
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

@@ -0,0 +1,152 @@
"""
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

@@ -0,0 +1,98 @@
"""
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

167
sunnypilot/navd/navigationd.py Executable file
View File

@@ -0,0 +1,167 @@
"""
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

View File

@@ -0,0 +1,67 @@
"""
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

View File

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

View File

@@ -7,11 +7,17 @@ See the LICENSE.md file in the root directory for more details.
from parameterized import parameterized
import cereal.messaging
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper, LaneChangeState, LaneChangeDirection
from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeController, AutoLaneChangeMode, \
AUTO_LANE_CHANGE_TIMER, ONE_SECOND_DELAY
class MockSubMaster:
def __init__(self, services):
pass
AUTO_LANE_CHANGE_TIMER_COMBOS = [
(AutoLaneChangeMode.NUDGELESS, AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.NUDGELESS]),
(AutoLaneChangeMode.HALF_SECOND, AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.HALF_SECOND]),
@@ -23,6 +29,7 @@ AUTO_LANE_CHANGE_TIMER_COMBOS = [
class TestAutoLaneChangeController:
def setup_method(self):
cereal.messaging.SubMaster = MockSubMaster
self.DH = DesireHelper()
self.alc = AutoLaneChangeController(self.DH)

View File

@@ -1,14 +1,29 @@
import pytest
import cereal.messaging
from cereal import log, custom
from openpilot.common.params import Params
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
from openpilot.sunnypilot.selfdrive.controls.lib.lane_turn_desire import LaneTurnController, LANE_CHANGE_SPEED_MIN
from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeMode
TurnDirection = custom.ModelDataV2SP.TurnDirection
class MockSubMaster:
def __init__(self, services): pass
def update(self, timeout): pass
def __getitem__(self, key):
return type('nav_msg', (), {'valid': False})()
@pytest.fixture(autouse=True)
def mock_submaster():
cereal.messaging.SubMaster = MockSubMaster
@pytest.mark.parametrize("left_blinker,right_blinker,v_ego,blindspot_left,blindspot_right,expected", [
(True, False, 5, False, False, TurnDirection.turnLeft),
(False, True, 6, False, False, TurnDirection.turnRight),
@@ -107,7 +122,6 @@ def set_lane_turn_params():
])
def test_desire_helper_integration(carstate, lateral_active, lane_change_prob, expected_desire, set_lane_turn_params):
dh = DesireHelper()
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE
for _ in range(10):
dh.update(carstate, lateral_active, lane_change_prob)
assert dh.desire == expected_desire # The first four tests were unit tests to test the controller, where this tests the integration in desire helpers
assert dh.desire == expected_desire

View File

@@ -180,6 +180,9 @@ 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

@@ -1,223 +0,0 @@
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

@@ -1,233 +0,0 @@
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

@@ -2,7 +2,25 @@ import pyray as rl
from dataclasses import dataclass
@dataclass
class Default:
class Base:
# 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
# Toggle Control
TOGGLE_HEIGHT = 100
TOGGLE_WIDTH = int(TOGGLE_HEIGHT * 1.75)
TOGGLE_BG_HEIGHT = TOGGLE_HEIGHT - 20
# Button Control
BUTTON_WIDTH = 250
BUTTON_HEIGHT = 100
@dataclass
class SP_Default(Base):
# Base Colors
BASE_BG_COLOR = rl.Color(57, 57, 57, 255) # Grey
ON_BG_COLOR = rl.Color(28, 101, 186, 255) # Blue
@@ -15,22 +33,7 @@ class Default:
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
@@ -38,12 +41,4 @@ class Default:
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
style = SP_Default

View File

View File

@@ -0,0 +1,35 @@
from collections.abc import Callable
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.keyboard import Keyboard
class InputDialogSP:
def __init__(self, title: str, sub_title: str | None = None, current_text: str = "", param: str | None = None,
callback: Callable[[DialogResult, str], None] | None = None,
min_text_size: int = 0, password_mode: bool = False):
self.callback = callback
self.current_text = current_text
self.keyboard = Keyboard(max_text_size=255, min_text_size=min_text_size, password_mode=password_mode)
self.param = param
self._params = Params()
self.sub_title = sub_title
self.title = title
def show(self):
self.keyboard.reset(min_text_size=self.keyboard._min_text_size)
self.keyboard.set_title(tr(self.title), *(tr(self.sub_title),) if self.sub_title else ())
self.keyboard.set_text(self.current_text)
def internal_callback(result: DialogResult):
text = self.keyboard.text if result == DialogResult.CONFIRM else ""
if result == DialogResult.CONFIRM:
if self.param:
self._params.put(self.param, text)
if self.callback:
self.callback(result, text)
gui_app.set_modal_overlay(self.keyboard, internal_callback)

View File

@@ -0,0 +1,173 @@
import pyray as rl
from collections.abc import Callable
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import MousePos
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.sunnypilot.widgets.toggle import ToggleSP
from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, _resolve_value
from openpilot.system.ui.sunnypilot.lib.styles import style
class ToggleActionSP(ToggleAction):
def __init__(self, initial_state: bool = False, width: int = style.TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True,
callback: Callable[[bool], None] | None = None, param: str | None = None):
ToggleAction.__init__(self, initial_state, width, enabled, callback)
self.toggle = ToggleSP(initial_state=initial_state, callback=callback, param=param)
class MultipleButtonActionSP(MultipleButtonAction):
def __init__(self, param: str | None, buttons: list[str | Callable[[], 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):
spacing = style.ITEM_PADDING
button_y = rect.y + (rect.height - style.BUTTON_HEIGHT) / 2
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
if not self.enabled:
bg_color = style.DISABLED_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
text_color = style.ITEM_TEXT_COLOR if self.enabled else style.ITEM_DISABLED_TEXT_COLOR
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, text_color)
def _handle_mouse_release(self, mouse_pos: MousePos):
MultipleButtonAction._handle_mouse_release(self, mouse_pos)
if self.param_key:
self.params.put(self.param_key, self.selected_button)
class ListItemSP(ListItem):
def __init__(self, title: str | Callable[[], str] = "", icon: str | None = None, description: str | Callable[[], str] | None = None,
description_visible: bool = False, callback: Callable | None = None,
action_item: ItemAction | None = None, inline: bool = True):
ListItem.__init__(self, title, icon, description, description_visible, callback, action_item)
self.inline = inline
if not self.inline:
self._rect.height += style.ITEM_BASE_HEIGHT/1.75
def get_item_height(self, font: rl.Font, max_width: int) -> float:
height = super().get_item_height(font, max_width)
if not self.inline:
height = height + style.ITEM_BASE_HEIGHT/1.75
return height
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
if not self.action_item:
return rl.Rectangle(0, 0, 0, 0)
if not self.inline:
action_y = item_rect.y + self._text_size.y + style.ITEM_PADDING * 3
return rl.Rectangle(item_rect.x + style.ITEM_PADDING, action_y, item_rect.width - (style.ITEM_PADDING * 2), style.BUTTON_HEIGHT)
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, _):
if not self.is_visible:
return
# Don't draw items that are not in parent's viewport
if (self._rect.y + self.rect.height) <= self._parent_rect.y or self._rect.y >= (self._parent_rect.y + self._parent_rect.height):
return
content_x = self._rect.x + 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 * 1.5
# Draw title
if self.title:
self._text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._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
self._text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2 if self.inline else self._rect.y + style.ITEM_PADDING * 1.5
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)
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)
desc_y = self._rect.y + style.ITEM_DESC_V_OFFSET
if not self.inline and self.action_item:
desc_y = self.action_item.rect.y + style.ITEM_DESC_V_OFFSET - style.ITEM_PADDING * 1.75
description_rect = rl.Rectangle(self._rect.x + style.ITEM_PADDING, desc_y, content_width, description_height)
self._html_renderer.render(description_rect)
def toggle_item_sp(title: str | Callable[[], 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, callback=callback, param=param)
return ListItemSP(title=title, description=description, action_item=action, icon=icon, callback=callback)
def multiple_button_item_sp(title: str | Callable[[], str], description: str| Callable[[], str], buttons: list[str | Callable[[], str]],
selected_index: int = 0, button_width: int = style.BUTTON_WIDTH, callback: Callable = None,
icon: str = "", param: str | None = None, inline: bool = True) -> ListItem:
action = MultipleButtonActionSP(param, buttons, button_width, selected_index, callback=callback)
return ListItemSP(title=title, description=description, icon=icon, action_item=action, inline=inline)

View File

@@ -0,0 +1,58 @@
import os
import pytest
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.sunnypilot.widgets.input_dialog import InputDialogSP
os.environ['SDL_VIDEODRIVER'] = 'dummy'
if not os.environ.get('CI'):
pytest.skip("Test in CI environment, or comment out this flag to test locally", allow_module_level=True)
class TestInputDialog:
def setup_method(self):
self.params = Params()
def test_input_dialog_int(self):
gui_app.init_window("test window")
dialog = InputDialogSP("title", current_text="current_text", param="MapTargetVelocities")
dialog.show()
dialog.keyboard._render_return_status = 1
gui_app._handle_modal_overlay()
assert self.params.get("MapTargetVelocities") == "current_text"
def test_before_input_dialog(self):
gui_app.init_window("test window")
current_apn = "gsmapn"
# This tests the pre InputDialogSP, where keyboard setup had to be done for every single dialog box you want to use.
self.keyboard = Keyboard()
self.keyboard.reset(min_text_size=0)
self.keyboard.set_title(("Enter APN"), ("networking"))
self.keyboard.set_text(current_apn)
def pre_input_dialog_callback(result):
if result == 1:
apn = self.keyboard.text.strip()
self.params.put("GsmApn", apn)
gui_app.set_modal_overlay(self.keyboard, pre_input_dialog_callback)
self.keyboard.set_text("new_apn")
self.keyboard._render_return_status = 1
gui_app._handle_modal_overlay()
pre_input_dialog_result = self.params.get("GsmApn")
assert pre_input_dialog_result == "new_apn"
dialog = InputDialogSP(title="Enter APN", sub_title="networking", current_text=current_apn, param="GsmApn")
dialog.show()
dialog.keyboard.set_text("new_apn")
dialog.keyboard._render_return_status = 1
gui_app._handle_modal_overlay()
input_dialog_result = self.params.get("GsmApn")
assert input_dialog_result == "new_apn"
assert pre_input_dialog_result == input_dialog_result

View File

@@ -1,18 +1,21 @@
import pyray as rl
from collections.abc import Callable
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
from openpilot.system.ui.sunnypilot.lib.styles import style
style = styles.Default
KNOB_PADDING = 5
KNOB_RADIUS = style.TOGGLE_BG_HEIGHT / 2 - KNOB_PADDING
SYMBOL_SIZE = KNOB_RADIUS / 2
class ToggleSP(Toggle):
def __init__(self, initial_state=False, param: str | None = None):
def __init__(self, initial_state=False, callback: Callable[[bool], None] | None = None, 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)
Toggle.__init__(self, initial_state, callback)
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
@@ -44,30 +47,24 @@ class ToggleSP(Toggle):
# 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
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_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
rl.draw_circle(int(knob_x), int(knob_y), KNOB_RADIUS, knob_color)
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_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
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)),
@@ -83,8 +80,7 @@ class ToggleSP(Toggle):
)
else:
# Draw X when toggle is OFF
x_size_factor = 0.65
x_offset = symbol_size * x_size_factor
x_offset = SYMBOL_SIZE * 0.65
rl.draw_line_ex(
rl.Vector2(int(knob_x - x_offset), int(knob_y - x_offset)),

View File

@@ -1,369 +0,0 @@
# /**
# * 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,5 +1,6 @@
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
@@ -11,6 +12,9 @@ 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()