mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-06-10 13:34:21 +08:00
Compare commits
100 Commits
nayan-rayl
...
nav-desire
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c3a73b4cf | ||
|
|
b48214acd2 | ||
|
|
8adbd56acd | ||
|
|
65db08f4d1 | ||
|
|
a1e305333f | ||
|
|
c14b81585e | ||
|
|
f3d8b24bf4 | ||
|
|
880ed98ffc | ||
|
|
f1025f6ee9 | ||
|
|
08e85808c5 | ||
|
|
cb03d08397 | ||
|
|
d106c192f2 | ||
|
|
98ffbe1308 | ||
|
|
d09f74612f | ||
|
|
3af0d6e87f | ||
|
|
584269fced | ||
|
|
1dc5741e75 | ||
|
|
b69da9e5ea | ||
|
|
0ccd55a6b5 | ||
|
|
48dc9dbb69 | ||
|
|
56ca486fe9 | ||
|
|
799e819e58 | ||
|
|
aaac1c79d0 | ||
|
|
f46de2d0d5 | ||
|
|
d7fa10a827 | ||
|
|
22b010f674 | ||
|
|
dcaf84d04c | ||
|
|
3a82a0797a | ||
|
|
365e978b42 | ||
|
|
2d1f3833e4 | ||
|
|
ba176a6581 | ||
|
|
63e5d0a476 | ||
|
|
e28dd1e1aa | ||
|
|
43b4e4e271 | ||
|
|
864c811ef6 | ||
|
|
906e9d7a80 | ||
|
|
cfb8f3ae24 | ||
|
|
0cc5e56192 | ||
|
|
1a62ae821e | ||
|
|
b6dd2d14db | ||
|
|
7d4e5bedaf | ||
|
|
1063114408 | ||
|
|
958b4df69f | ||
|
|
72998034e6 | ||
|
|
cefb344183 | ||
|
|
d17e80ad94 | ||
|
|
c2b7087723 | ||
|
|
81b37712f1 | ||
|
|
18cd3633e5 | ||
|
|
9c6a4d4a57 | ||
|
|
1a4c48249b | ||
|
|
5f5e3668eb | ||
|
|
8c07958f6f | ||
|
|
ca1ce9bcc9 | ||
|
|
29f15dc8ed | ||
|
|
31a5a3b3c0 | ||
|
|
2a4b348497 | ||
|
|
3ef3aceb4b | ||
|
|
3d8763b3ce | ||
|
|
b2427a5f20 | ||
|
|
ff4cc96a81 | ||
|
|
3b1ada64be | ||
|
|
6a08186434 | ||
|
|
cf2b033c79 | ||
|
|
9fbef36c6b | ||
|
|
7b28c2f59a | ||
|
|
99d954de10 | ||
|
|
b28f33481c | ||
|
|
589e33f665 | ||
|
|
39342d7b5e | ||
|
|
b763f7aac1 | ||
|
|
450fcd4d55 | ||
|
|
551b4dea31 | ||
|
|
bd269defb3 | ||
|
|
399ed08926 | ||
|
|
90f02040fe | ||
|
|
8423ecedb1 | ||
|
|
dd1479ed82 | ||
|
|
f82845ff42 | ||
|
|
efcc5ccd15 | ||
|
|
6aac50ab56 | ||
|
|
091bce4a3a | ||
|
|
088f6aa407 | ||
|
|
211c8adcce | ||
|
|
fe5366e5b2 | ||
|
|
1ecb0b0f66 | ||
|
|
51e455db79 | ||
|
|
dc6672fa80 | ||
|
|
07b8e7783d | ||
|
|
f17b0f200c | ||
|
|
ad9bde8b1f | ||
|
|
8cf9f9fe23 | ||
|
|
713985d823 | ||
|
|
088f9d0b59 | ||
|
|
53bf5b0d41 | ||
|
|
8c33592628 | ||
|
|
3bbb33f6bd | ||
|
|
5bd9549bd1 | ||
|
|
3481702715 | ||
|
|
c9781ee31d |
3
.github/workflows/tests.yaml
vendored
3
.github/workflows/tests.yaml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
|
||||
Submodule opendbc_repo updated: 4aa8fb154c...b054629f5e
2
panda
2
panda
Submodule panda updated: e4115086b0...dee9061b2a
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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", {}))
|
||||
6
sunnypilot/navd/README.md
Normal file
6
sunnypilot/navd/README.md
Normal 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).
|
||||
0
sunnypilot/navd/__init__.py
Normal file
0
sunnypilot/navd/__init__.py
Normal file
16
sunnypilot/navd/constants.py
Normal file
16
sunnypilot/navd/constants.py
Normal 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
|
||||
@@ -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'
|
||||
|
||||
|
||||
0
sunnypilot/navd/navigation_desires/__init__.py
Normal file
0
sunnypilot/navd/navigation_desires/__init__.py
Normal file
42
sunnypilot/navd/navigation_desires/navigation_desires.py
Normal file
42
sunnypilot/navd/navigation_desires/navigation_desires.py
Normal 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
|
||||
@@ -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
|
||||
0
sunnypilot/navd/navigation_helpers/__init__.py
Normal file
0
sunnypilot/navd/navigation_helpers/__init__.py
Normal file
113
sunnypilot/navd/navigation_helpers/mapbox_integration.py
Normal file
113
sunnypilot/navd/navigation_helpers/mapbox_integration.py
Normal 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,
|
||||
}
|
||||
152
sunnypilot/navd/navigation_helpers/nav_instructions.py
Normal file
152
sunnypilot/navd/navigation_helpers/nav_instructions.py
Normal 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
|
||||
98
sunnypilot/navd/navigation_helpers/tests/test_mapbox.py
Normal file
98
sunnypilot/navd/navigation_helpers/tests/test_mapbox.py
Normal 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
167
sunnypilot/navd/navigationd.py
Executable 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()
|
||||
0
sunnypilot/navd/tests/__init__.py
Normal file
0
sunnypilot/navd/tests/__init__.py
Normal file
67
sunnypilot/navd/tests/test_navigationd.py
Normal file
67
sunnypilot/navd/tests/test_navigationd.py
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
@@ -72,6 +72,8 @@ class ControlsExt:
|
||||
|
||||
CC_SP.intelligentCruiseButtonManagement = sm['selfdriveStateSP'].intelligentCruiseButtonManagement
|
||||
|
||||
CC_SP.speed = sm['carState'].vEgo
|
||||
|
||||
return CC_SP
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
24
system/ui/sunnypilot/lib/application.py
Normal file
24
system/ui/sunnypilot/lib/application.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user