Compare commits

..

100 Commits

Author SHA1 Message Date
James Vecellio-Grant
9c3a73b4cf Merge branch 'navigationd-service' into nav-desires 2025-11-16 15:42:20 -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
55 changed files with 863 additions and 1741 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

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

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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