keys = {
{"SubaruStopAndGo", {PERSISTENT | BACKUP, BOOL, "0"}},
{"SubaruStopAndGoManualParkingBrake", {PERSISTENT | BACKUP, BOOL, "0"}},
{"TeslaCoopSteering", {PERSISTENT | BACKUP, BOOL, "0"}},
+ {"ToyotaEnforceStockLongitudinal", {PERSISTENT | BACKUP, BOOL, "0"}},
{"DynamicExperimentalControl", {PERSISTENT | BACKUP, BOOL, "0"}},
{"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}},
diff --git a/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py
index 3377ce6c68..4f068c4f5a 100644
--- a/selfdrive/monitoring/helpers.py
+++ b/selfdrive/monitoring/helpers.py
@@ -449,7 +449,7 @@ class DriverMonitoring:
rpyCalib = [0., 0., 0.]
else:
highway_speed = sm['carState'].vEgo
- enabled = sm['selfdriveState'].enabled
+ enabled = sm['selfdriveState'].enabled or sm['carControl'].latActive
wrong_gear = sm['carState'].gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low)
standstill = sm['carState'].standstill
driver_engaged = sm['carState'].steeringPressed or sm['carState'].gasPressed
diff --git a/selfdrive/monitoring/test_monitoring.py b/selfdrive/monitoring/test_monitoring.py
index 6ea9b80283..75adb6a2c8 100644
--- a/selfdrive/monitoring/test_monitoring.py
+++ b/selfdrive/monitoring/test_monitoring.py
@@ -1,6 +1,7 @@
import numpy as np
+import pytest
-from cereal import log
+from cereal import log, car
from openpilot.common.realtime import DT_DMON
from openpilot.selfdrive.monitoring.helpers import DriverMonitoring, DRIVER_MONITOR_SETTINGS
from openpilot.system.hardware import HARDWARE
@@ -204,3 +205,66 @@ class TestMonitoring:
assert EventName.driverUnresponsive in \
events[int((INVISIBLE_SECONDS_TO_RED-1+DT_DMON*d_status.settings._HI_STD_FALLBACK_TIME+0.1)/DT_DMON)].names
+
+@pytest.mark.parametrize("enabled_state, lat_active_state, expected", [
+ (False, False, False), # Both Disabled
+ (True, False, True), # OP Enabled, Lat Inactive
+ (False, True, True), # OP Disabled, Lat Active (e.g. MADS)
+ (True, True, True) # Both Active
+])
+def test_enabled_states(enabled_state, lat_active_state, expected):
+ """
+ Test DriverMonitoring.run_step with all 4 combinations of:
+ - selfdriveState.enabled (True/False)
+ - carControl.latActive (True/False)
+ """
+ cs = car.CarState.new_message()
+ cs.vEgo = 30.0
+ cs.gearShifter = car.CarState.GearShifter.drive
+ cs.standstill = False
+ cs.steeringPressed = False
+ cs.gasPressed = False
+
+ ss = log.SelfdriveState.new_message()
+ ss.enabled = enabled_state
+
+ cc = car.CarControl.new_message()
+ cc.latActive = lat_active_state
+
+ mv2 = log.ModelDataV2.new_message()
+ mv2.meta.disengagePredictions.brakeDisengageProbs = [0.0]
+
+ lc = log.LiveCalibrationData.new_message()
+ lc.rpyCalib = [0.0, 0.0, 0.0]
+
+ ds = make_msg(False)
+
+ sm = {
+ 'carState': cs,
+ 'selfdriveState': ss,
+ 'carControl': cc,
+ 'modelV2': mv2,
+ 'liveCalibration': lc,
+ 'driverStateV2': ds
+ }
+
+ driver_monitoring = DriverMonitoring()
+
+ # run_test doesn't assign enabled to a variable, so we need to spy on _update_events to see its value
+ captured_args = []
+ original_update_events = driver_monitoring._update_events
+
+ def spy_update_events(driver_engaged, op_engaged, standstill, wrong_gear, car_speed):
+ captured_args.append(op_engaged)
+ return original_update_events(driver_engaged, op_engaged, standstill, wrong_gear, car_speed)
+
+ driver_monitoring._update_events = spy_update_events
+
+ driver_monitoring.run_step(sm, demo=False)
+
+ # Assertion
+ assert len(captured_args) == 1, "Expected _update_events to be called exactly once"
+ actual_enabled = captured_args[0]
+
+ assert actual_enabled == expected, f"Expected op_engaged={expected}, but got {actual_enabled}"
+
diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py
index 750a30b73a..bdffea4cfe 100644
--- a/selfdrive/ui/mici/layouts/home.py
+++ b/selfdrive/ui/mici/layouts/home.py
@@ -109,7 +109,7 @@ class MiciHomeLayout(Widget):
self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 55, 35)
self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 55, 35)
- self._openpilot_label = MiciLabel("sunnypilot", font_size=96, color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.DISPLAY)
+ self._openpilot_label = MiciLabel("sunnypilot", font_size=90, color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.AUDIOWIDE)
self._version_label = MiciLabel("", font_size=36, font_weight=FontWeight.ROMAN)
self._large_version_label = MiciLabel("", font_size=64, color=rl.GRAY, font_weight=FontWeight.ROMAN)
self._date_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN)
diff --git a/selfdrive/ui/onroad/model_renderer.py b/selfdrive/ui/onroad/model_renderer.py
index 77dd4ad4d4..f33791c013 100644
--- a/selfdrive/ui/onroad/model_renderer.py
+++ b/selfdrive/ui/onroad/model_renderer.py
@@ -11,6 +11,8 @@ from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
from openpilot.system.ui.widgets import Widget
+from openpilot.selfdrive.ui.sunnypilot.onroad.model_renderer import ChevronMetrics, ModelRendererSP
+
CLIP_MARGIN = 500
MIN_DRAW_DISTANCE = 10.0
MAX_DRAW_DISTANCE = 100.0
@@ -41,9 +43,11 @@ class LeadVehicle:
fill_alpha: int = 0
-class ModelRenderer(Widget):
+class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP):
def __init__(self):
- super().__init__()
+ Widget.__init__(self)
+ ChevronMetrics.__init__(self)
+ ModelRendererSP.__init__(self)
self._longitudinal_control = False
self._experimental_mode = False
self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps)
@@ -137,6 +141,7 @@ class ModelRenderer(Widget):
if render_lead_indicator and radar_state:
self._draw_lead_indicator()
+ self.chevron_metrics.draw_lead_status(sm, radar_state, self._rect, self._lead_vehicles)
def _update_raw_points(self, model):
"""Update raw 3D points from model data"""
@@ -290,6 +295,10 @@ class ModelRenderer(Widget):
allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control
self._blend_filter.update(int(allow_throttle))
+ if ui_state.rainbow_path:
+ self.rainbow_path.draw_rainbow_path(self._rect, self._path)
+ return
+
if self._experimental_mode:
# Draw with acceleration coloring
if len(self._exp_gradient.colors) > 1:
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/device.py b/selfdrive/ui/sunnypilot/layouts/settings/device.py
index 081969cf10..36c5fdb342 100644
--- a/selfdrive/ui/sunnypilot/layouts/settings/device.py
+++ b/selfdrive/ui/sunnypilot/layouts/settings/device.py
@@ -5,8 +5,216 @@ 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.settings.device import DeviceLayout
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.hardware import HARDWARE
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.lib.multilang import tr
+from openpilot.system.ui.sunnypilot.widgets.list_view import option_item_sp, multiple_button_item_sp, button_item_sp, \
+ dual_button_item_sp, Spacer
+from openpilot.system.ui.widgets import DialogResult
+from openpilot.system.ui.widgets.button import ButtonStyle
+from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog
+from openpilot.system.ui.widgets.list_view import text_item
+from openpilot.system.ui.widgets.scroller_tici import LineSeparator
+
+offroad_time_options = {
+ 0: 0,
+ 1: 5,
+ 2: 10,
+ 3: 15,
+ 4: 30,
+ 5: 60,
+ 6: 120,
+ 7: 180,
+ 8: 300,
+ 9: 600,
+ 10: 1440,
+ 11: 1800,
+}
class DeviceLayoutSP(DeviceLayout):
def __init__(self):
DeviceLayout.__init__(self)
+ self._scroller._line_separator = None
+
+ def _initialize_items(self):
+ DeviceLayout._initialize_items(self)
+
+ # Using dual button with no right button for better alignment
+ self._always_offroad_btn = dual_button_item_sp(
+ left_text=lambda: tr("Enable Always Offroad"),
+ left_callback=self._handle_always_offroad,
+ right_text="",
+ right_callback=None,
+ )
+ self._always_offroad_btn.action_item.right_button.set_visible(False)
+
+ self._max_time_offroad = option_item_sp(
+ title=lambda: tr("Max Time Offroad"),
+ description=lambda: tr("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=360,
+ use_float_scaling=False,
+ inline=True,
+ label_callback=self._update_max_time_offroad_label
+ )
+
+ self._device_wake_mode = multiple_button_item_sp(
+ title=lambda: tr("Wake Up Behavior"),
+ description=self.wake_mode_description,
+ param="DeviceBootMode",
+ buttons=[lambda: tr("Default"), lambda: tr("Offroad")],
+ button_width=364,
+ callback=None,
+ inline=True,
+ )
+
+ self._quiet_mode_and_dcam = dual_button_item_sp(
+ left_text=lambda: tr("Quiet Mode"),
+ right_text=lambda: tr("Driver Camera Preview"),
+ left_callback=lambda: ui_state.params.put_bool("QuietMode", not ui_state.params.get_bool("QuietMode")),
+ right_callback=self._show_driver_camera
+ )
+ self._quiet_mode_and_dcam.action_item.right_button.set_button_style(ButtonStyle.NORMAL)
+
+ self._reg_and_training = dual_button_item_sp(
+ left_text=lambda: tr("Regulatory"),
+ left_callback=self._on_regulatory,
+ right_text=lambda: tr("Training Guide"),
+ right_callback=self._on_review_training_guide
+ )
+ self._reg_and_training.action_item.right_button.set_button_style(ButtonStyle.NORMAL)
+
+ self._onroad_uploads_and_reset_settings = dual_button_item_sp(
+ left_text=lambda: tr("Onroad Uploads"),
+ left_callback=lambda: ui_state.params.put_bool("OnroadUploads", not ui_state.params.get_bool("OnroadUploads")),
+ right_text=lambda: tr("Reset Settings"),
+ right_callback=self._reset_settings
+ )
+
+ self._power_buttons = dual_button_item_sp(
+ left_text=lambda: tr("Reboot"),
+ right_text=lambda: tr("Power Off"),
+ left_callback=self._reboot_prompt,
+ right_callback=self._power_off_prompt
+ )
+
+ items = [
+ text_item(lambda: tr("Dongle ID"), self._params.get("DongleId") or (lambda: tr("N/A"))),
+ LineSeparator(),
+ text_item(lambda: tr("Serial"), self._params.get("HardwareSerial") or (lambda: tr("N/A"))),
+ LineSeparator(),
+ self._pair_device_btn,
+ LineSeparator(),
+ self._reset_calib_btn,
+ LineSeparator(),
+ button_item_sp(lambda: tr("Change Language"), lambda: tr("CHANGE"), callback=self._show_language_dialog),
+ LineSeparator(),
+ self._device_wake_mode,
+ LineSeparator(),
+ self._max_time_offroad,
+ LineSeparator(height=10),
+ self._quiet_mode_and_dcam,
+ self._reg_and_training,
+ self._onroad_uploads_and_reset_settings,
+ Spacer(10),
+ LineSeparator(height=10),
+ self._power_buttons,
+ ]
+
+ return items
+
+ def _offroad_transition(self):
+ self._power_buttons.action_item.right_button.set_visible(ui_state.is_offroad())
+
+ @staticmethod
+ def wake_mode_description() -> str:
+ def_str = tr("Default: Device will boot/wake-up normally & will be ready to engage.")
+ offrd_str = tr("Offroad: Device will be in Always Offroad mode after boot/wake-up.")
+ header = tr("Controls state of the device after boot/sleep.")
+
+ return f"{header}\n\n{def_str}\n{offrd_str}"
+
+ @staticmethod
+ def _reset_settings():
+ def _do_reset(result: int):
+ if result == DialogResult.CONFIRM:
+ for _key in ui_state.params.all_keys():
+ ui_state.params.remove(_key)
+ HARDWARE.reboot()
+
+ def _second_confirm(result: int):
+ if result == DialogResult.CONFIRM:
+ gui_app.set_modal_overlay(ConfirmDialog(
+ text=tr("The reset cannot be undone. You have been warned."),
+ confirm_text=tr("Confirm")
+ ), callback=_do_reset)
+
+ gui_app.set_modal_overlay(ConfirmDialog(
+ text=tr("Are you sure you want to reset all sunnypilot settings to default? Once the settings are reset, there is no going back."),
+ confirm_text=tr("Reset")
+ ), callback=_second_confirm)
+
+ @staticmethod
+ def _handle_always_offroad():
+ if ui_state.engaged:
+ gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Enter Always Offroad Mode")))
+ return
+
+ _offroad_mode_state = ui_state.params.get_bool("OffroadMode")
+ _offroad_mode_str = tr("Are you sure you want to exit Always Offroad mode?") if _offroad_mode_state else \
+ tr("Are you sure you want to enter Always Offroad mode?")
+
+ def _set_always_offroad(result: int):
+ if result == DialogResult.CONFIRM and not ui_state.engaged:
+ ui_state.params.put_bool("OffroadMode", not _offroad_mode_state)
+
+ gui_app.set_modal_overlay(ConfirmDialog(_offroad_mode_str, tr("Confirm")), callback=lambda result: _set_always_offroad(result))
+
+ @staticmethod
+ def _update_max_time_offroad_label(value: int) -> str:
+ label = tr("Always On") if value == 0 else f"{value}" + tr("m") if value < 60 else f"{value // 60}" + tr("h")
+ label += tr(" (Default)") if value == 1800 else ""
+ return label
+
+ def _update_state(self):
+ super()._update_state()
+
+ # Handle Always Offroad button
+ always_offroad = ui_state.params.get_bool("OffroadMode")
+
+ # Text & Color
+ offroad_mode_btn_text = tr("Exit Always Offroad") if always_offroad else tr("Enable Always Offroad")
+ offroad_mode_btn_style = ButtonStyle.NORMAL if always_offroad else ButtonStyle.DANGER
+ self._always_offroad_btn.action_item.left_button.set_text(offroad_mode_btn_text)
+ self._always_offroad_btn.action_item.left_button.set_button_style(offroad_mode_btn_style)
+
+ # Position
+ if self._scroller._items.__contains__(self._always_offroad_btn):
+ self._scroller._items.remove(self._always_offroad_btn)
+ if ui_state.is_offroad() and not always_offroad:
+ self._scroller._items.insert(len(self._scroller._items) - 1, self._always_offroad_btn)
+ elif not ui_state.is_offroad():
+ self._scroller._items.insert(0, self._always_offroad_btn)
+
+ # Quiet Mode button
+ self._quiet_mode_and_dcam.action_item.left_button.set_button_style(ButtonStyle.PRIMARY if ui_state.params.get_bool("QuietMode") else ButtonStyle.NORMAL)
+
+ # Onroad Uploads
+ self._onroad_uploads_and_reset_settings.action_item.left_button.set_button_style(
+ ButtonStyle.PRIMARY if ui_state.params.get_bool("OnroadUploads") else ButtonStyle.NORMAL
+ )
+
+ # Offroad only buttons
+ self._quiet_mode_and_dcam.action_item.right_button.set_enabled(ui_state.is_offroad())
+ self._reg_and_training.action_item.left_button.set_enabled(ui_state.is_offroad())
+ self._reg_and_training.action_item.right_button.set_enabled(ui_state.is_offroad())
+ self._onroad_uploads_and_reset_settings.action_item.right_button.set_enabled(ui_state.is_offroad())
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/settings.py b/selfdrive/ui/sunnypilot/layouts/settings/settings.py
index a0c7d7c2dd..bc83c82f85 100644
--- a/selfdrive/ui/sunnypilot/layouts/settings/settings.py
+++ b/selfdrive/ui/sunnypilot/layouts/settings/settings.py
@@ -9,29 +9,28 @@ from enum import IntEnum
import pyray as rl
from openpilot.selfdrive.ui.layouts.settings import settings as OP
-from openpilot.selfdrive.ui.sunnypilot.layouts.settings.device import DeviceLayoutSP
from openpilot.selfdrive.ui.layouts.settings.firehose import FirehoseLayout
-from openpilot.selfdrive.ui.sunnypilot.layouts.settings.software import SoftwareLayoutSP
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.sunnypilot.lib.styles import style
-from openpilot.system.ui.widgets.scroller_tici import Scroller
-from openpilot.system.ui.lib.text_measure import measure_text_cached
-from openpilot.system.ui.lib.wifi_manager import WifiManager
-from openpilot.system.ui.widgets import Widget
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.cruise import CruiseLayout
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.developer import DeveloperLayoutSP
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.device import DeviceLayoutSP
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.display import DisplayLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.models import ModelsLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.network import NetworkUISP
-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.software import SoftwareLayoutSP
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.steering import SteeringLayout
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.sunnylink import SunnylinkLayout
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 openpilot.selfdrive.ui.sunnypilot.layouts.settings.display import DisplayLayout
-from openpilot.selfdrive.ui.sunnypilot.layouts.settings.developer import DeveloperLayoutSP
-from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.ui.lib.application import gui_app, MousePos
+from openpilot.system.ui.lib.multilang import tr_noop
+from openpilot.system.ui.lib.text_measure import measure_text_cached
+from openpilot.system.ui.lib.wifi_manager import WifiManager
+from openpilot.system.ui.sunnypilot.lib.styles import style
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.scroller_tici import Scroller
# from openpilot.selfdrive.ui.sunnypilot.layouts.settings.navigation import NavigationLayout
@@ -197,10 +196,6 @@ class SettingsLayoutSP(OP.SettingsLayout):
return False
- def set_current_panel(self, panel_type: OP.PanelType):
- super().set_current_panel(panel_type)
- ui_state.set_active_layout(self._panels[self._current_panel].instance)
-
def show_event(self):
super().show_event()
self._panels[self._current_panel].instance.show_event()
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
index 20a8983c5a..0775cdd7f2 100644
--- a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
+++ b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
@@ -209,8 +209,8 @@ class SunnylinkLayout(Widget):
return items
@staticmethod
- def _get_sunnylink_dongle_id() -> str | None:
- return str(ui_state.params.get("SunnylinkDongleId") or (lambda: tr("N/A")))
+ def _get_sunnylink_dongle_id() -> str:
+ return ui_state.params.get("SunnylinkDongleId") or tr("N/A")
def _handle_pair_btn(self, sponsor_pairing: bool = False):
sunnylink_dongle_id = self._get_sunnylink_dongle_id()
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py
index 86d9e61695..0dd12d76f4 100644
--- a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py
@@ -23,7 +23,7 @@ class VehicleLayout(Widget):
self._current_brand = None
self._platform_selector = PlatformSelector(self._update_brand_settings)
- self._vehicle_item = ListItemSP(title=self._platform_selector.text, action_item=ButtonAction(text=tr("Select")),
+ self._vehicle_item = ListItemSP(title=self._platform_selector.text, action_item=ButtonAction(text=tr("SELECT")),
callback=self._platform_selector._on_clicked)
self._vehicle_item.title_color = self._platform_selector.color
self._legend_widget = LegendWidget(self._platform_selector)
@@ -42,7 +42,7 @@ class VehicleLayout(Widget):
def _update_brand_settings(self):
self._vehicle_item._title = self._platform_selector.text
self._vehicle_item.title_color = self._platform_selector.color
- vehicle_text = tr("Remove") if ui_state.params.get("CarPlatformBundle") else tr("Select")
+ vehicle_text = tr("REMOVE") if ui_state.params.get("CarPlatformBundle") else tr("SELECT")
self._vehicle_item.action_item.set_text(vehicle_text)
brand = self.get_brand()
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py
index e061a8a22a..ac3d04f367 100644
--- a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py
@@ -5,11 +5,55 @@ 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.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+from openpilot.selfdrive.ui.ui_state import ui_state
+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
+from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
+from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
+
+
+DESCRIPTIONS = {
+ 'enforce_stock_longitudinal': tr_noop(
+ 'sunnypilot will not take over control of gas and brakes. Factory Toyota longitudinal control will be used.'
+ ),
+}
class ToyotaSettings(BrandSettings):
def __init__(self):
super().__init__()
+ self.enforce_stock_longitudinal = toggle_item_sp(
+ lambda: tr("Enforce Factory Longitudinal Control"),
+ description=lambda: tr(DESCRIPTIONS["enforce_stock_longitudinal"]),
+ initial_state=ui_state.params.get_bool("ToyotaEnforceStockLongitudinal"),
+ callback=self._on_enable_enforce_stock_longitudinal,
+ enabled=lambda: not ui_state.engaged,
+ )
+
+ self.items = [self.enforce_stock_longitudinal, ]
+
+ def _on_enable_enforce_stock_longitudinal(self, state: bool):
+ if state:
+ def confirm_callback(result: int):
+ if result == DialogResult.CONFIRM:
+ ui_state.params.put_bool("ToyotaEnforceStockLongitudinal", True)
+ if ui_state.params.get_bool("AlphaLongitudinalEnabled"):
+ ui_state.params.put_bool("AlphaLongitudinalEnabled", False)
+ ui_state.params.put_bool("OnroadCycleRequested", True)
+ else:
+ self.enforce_stock_longitudinal.action_item.set_state(False)
+
+ content = (f"{self.enforce_stock_longitudinal.title}
" +
+ f"{self.enforce_stock_longitudinal.description}
")
+
+ dlg = ConfirmDialog(content, tr("Enable"), rich=True)
+ gui_app.set_modal_overlay(dlg, callback=confirm_callback)
+
+ else:
+ ui_state.params.put_bool("ToyotaEnforceStockLongitudinal", False)
+ ui_state.params.put_bool("OnroadCycleRequested", True)
+
def update_settings(self):
pass
diff --git a/selfdrive/ui/sunnypilot/mici/layouts/settings.py b/selfdrive/ui/sunnypilot/mici/layouts/settings.py
index c622005ecc..c6a2d58257 100644
--- a/selfdrive/ui/sunnypilot/mici/layouts/settings.py
+++ b/selfdrive/ui/sunnypilot/mici/layouts/settings.py
@@ -9,7 +9,6 @@ from enum import IntEnum
from openpilot.selfdrive.ui.mici.layouts.settings import settings as OP
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici
-from openpilot.selfdrive.ui.mici.layouts.settings.models import ModelsLayoutMici
ICON_SIZE = 70
@@ -17,7 +16,6 @@ OP.PanelType = IntEnum( # type: ignore
"PanelType",
[es.name for es in OP.PanelType] + [
"SUNNYLINK",
- "MODELS",
],
start=0,
)
@@ -26,20 +24,16 @@ OP.PanelType = IntEnum( # type: ignore
class SettingsLayoutSP(OP.SettingsLayout):
def __init__(self):
OP.SettingsLayout.__init__(self)
- models_btn = BigButton("models", "", "../../sunnypilot/selfdrive/assets/offroad/icon_models.png")
- models_btn.set_click_callback(lambda: self._set_current_panel(OP.PanelType.MODELS))
sunnylink_btn = BigButton("sunnylink", "", "icons_mici/settings/developer/ssh.png")
sunnylink_btn.set_click_callback(lambda: self._set_current_panel(OP.PanelType.SUNNYLINK))
self._panels.update({
OP.PanelType.SUNNYLINK: OP.PanelInfo("sunnylink", SunnylinkLayoutMici(back_callback=lambda: self._set_current_panel(None))),
- OP.PanelType.MODELS: OP.PanelInfo("models", ModelsLayoutMici(back_callback=lambda: self._set_current_panel(None))),
})
items = self._scroller._items.copy()
items.insert(1, sunnylink_btn)
- items.insert(2, models_btn)
self._scroller._items.clear()
for item in items:
self._scroller.add_widget(item)
diff --git a/selfdrive/ui/sunnypilot/onroad/chevron_metrics.py b/selfdrive/ui/sunnypilot/onroad/chevron_metrics.py
new file mode 100644
index 0000000000..a8a342c129
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/onroad/chevron_metrics.py
@@ -0,0 +1,147 @@
+"""
+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 numpy as np
+
+import pyray as rl
+from openpilot.common.constants import CV
+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
+
+
+class ChevronOptions:
+ OFF = 0
+ DISTANCE_ONLY = 1
+ SPEED_ONLY = 2
+ TTC_ONLY = 3
+ ALL = 4
+
+
+class ChevronMetrics:
+ def __init__(self):
+ self._lead_status_alpha: float = 0.0
+ self._font = gui_app.font(FontWeight.SEMI_BOLD)
+
+ def update_alpha(self, has_lead: bool):
+ """Update the alpha value for fade in/out animation"""
+ if not has_lead:
+ self._lead_status_alpha = max(0.0, self._lead_status_alpha - 0.05)
+ else:
+ self._lead_status_alpha = min(1.0, self._lead_status_alpha + 0.1)
+
+ def should_render(self) -> bool:
+ """Check if dev UI should be rendered"""
+ return ui_state.chevron_metrics != ChevronOptions.OFF and self._lead_status_alpha > 0.0
+
+ def _draw_lead(self, lead_data, lead_vehicle, v_ego: float, rect: rl.Rectangle):
+ """Draw lead vehicle status information (distance, speed, TTC)"""
+ if not self.should_render():
+ return
+
+ d_rel = lead_data.dRel
+ v_rel = lead_data.vRel
+
+ if not lead_vehicle.chevron or len(lead_vehicle.chevron) < 2:
+ return
+
+ chevron_x = lead_vehicle.chevron[1][0]
+ chevron_y = lead_vehicle.chevron[1][1]
+ sz = np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0) * 2.35
+
+ text_lines = self._build_text_lines(d_rel, v_rel, v_ego)
+ if not text_lines:
+ return
+
+ self._render_text_lines(text_lines, chevron_x, chevron_y, sz, rect)
+
+ @staticmethod
+ def _build_text_lines(d_rel: float, v_rel: float, v_ego: float) -> list[str]:
+ """Build text lines based on chevron info setting"""
+ text_lines = []
+
+ # Distance
+ if ui_state.chevron_metrics == ChevronOptions.DISTANCE_ONLY or ui_state.chevron_metrics == ChevronOptions.ALL:
+ val = max(0.0, d_rel)
+ unit = "m" if ui_state.is_metric else "ft"
+ if not ui_state.is_metric:
+ val *= 3.28084
+ text_lines.append(f"{val:.0f} {unit}")
+
+ # Speed
+ if ui_state.chevron_metrics == ChevronOptions.SPEED_ONLY or ui_state.chevron_metrics == ChevronOptions.ALL:
+ multiplier = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
+ val = max(0.0, (v_rel + v_ego) * multiplier)
+ unit = "km/h" if ui_state.is_metric else "mph"
+ text_lines.append(f"{val:.0f} {unit}")
+
+ # Time to collision
+ if ui_state.chevron_metrics == ChevronOptions.TTC_ONLY or ui_state.chevron_metrics == ChevronOptions.ALL:
+ val = (d_rel / v_ego) if (d_rel > 0 and v_ego > 0) else 0.0
+ ttc_text = f"{val:.1f} s" if (0 < val < 200) else "---"
+ text_lines.append(ttc_text)
+
+ return text_lines
+
+ def _render_text_lines(self, text_lines: list[str], chevron_x: float, chevron_y: float,
+ sz: float, rect: rl.Rectangle):
+ """Render text lines with proper centering and positioning"""
+ font_size = 40
+ line_height = 50
+ margin = 20
+
+ text_y = chevron_y + sz + 15
+ total_height = len(text_lines) * line_height
+
+ # Adjust Y position if text would go off screen
+ if text_y + total_height > rect.height - margin:
+ y_max = min(chevron_y, rect.height - margin)
+ text_y = y_max - 15 - total_height
+ text_y = max(margin, text_y)
+
+ alpha = int(255 * self._lead_status_alpha)
+ text_color = rl.Color(255, 255, 255, alpha)
+ shadow_color = rl.Color(0, 0, 0, int(200 * self._lead_status_alpha))
+
+ for i, line in enumerate(text_lines):
+ y = int(text_y + (i * line_height))
+ if y + line_height > rect.height - margin:
+ break
+
+ # Measure actual text width for proper centering
+ text_size = measure_text_cached(self._font, line, font_size, 0)
+ text_width = text_size.x
+
+ # Center the text horizontally on the chevron
+ x = int(chevron_x - text_width / 2)
+ x = int(np.clip(x, margin, rect.width - text_width - margin))
+
+ # Draw shadow
+ rl.draw_text_ex(self._font, line, rl.Vector2(x + 2, y + 2), font_size, 0, shadow_color)
+ # Draw text
+ rl.draw_text_ex(self._font, line, rl.Vector2(x, y), font_size, 0, text_color)
+
+ def draw_lead_status(self, sm, radar_state, rect, lead_vehicles):
+ lead_one = radar_state.leadOne
+ lead_two = radar_state.leadTwo
+
+ has_lead_one = lead_one.status if lead_one else False
+ has_lead_two = lead_two.status if lead_two else False
+
+ self.update_alpha(has_lead_one or has_lead_two)
+
+ if not self.should_render():
+ return
+
+ v_ego = sm['carState'].vEgo
+
+ if has_lead_one and lead_vehicles[0].chevron:
+ self._draw_lead(lead_one, lead_vehicles[0], v_ego, rect)
+
+ if has_lead_two and lead_vehicles[1].chevron:
+ d_rel_diff = abs(lead_one.dRel - lead_two.dRel) if has_lead_one else float('inf')
+ if d_rel_diff > 3.0:
+ self._draw_lead(lead_two, lead_vehicles[1], v_ego, rect)
diff --git a/selfdrive/ui/sunnypilot/onroad/model_renderer.py b/selfdrive/ui/sunnypilot/onroad/model_renderer.py
new file mode 100644
index 0000000000..5d78997662
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/onroad/model_renderer.py
@@ -0,0 +1,14 @@
+"""
+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.sunnypilot.onroad.chevron_metrics import ChevronMetrics
+from openpilot.selfdrive.ui.sunnypilot.onroad.rainbow_path import RainbowPath
+
+
+class ModelRendererSP:
+ def __init__(self):
+ self.rainbow_path = RainbowPath()
+ self.chevron_metrics = ChevronMetrics()
diff --git a/selfdrive/ui/sunnypilot/onroad/rainbow_path.py b/selfdrive/ui/sunnypilot/onroad/rainbow_path.py
new file mode 100644
index 0000000000..cd76261f89
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/onroad/rainbow_path.py
@@ -0,0 +1,78 @@
+"""
+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 time
+import colorsys
+import pyray as rl
+from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
+
+
+class RainbowPath:
+ DEFAULT_NUM_SEGMENTS = 8
+ DEFAULT_SPEED = 50.0 # degrees per second
+ DEFAULT_SATURATION = 0.9
+ DEFAULT_LIGHTNESS = 0.6
+ BASE_ALPHA = 0.8
+ ALPHA_FADE = 0.3 # Alpha reduction from bottom to top
+
+ def __init__(self, num_segments: int = None, speed: float = None, saturation: float = None, lightness: float = None):
+ self.num_segments = num_segments if num_segments is not None else self.DEFAULT_NUM_SEGMENTS
+ self.speed = speed if speed is not None else self.DEFAULT_SPEED
+ self.saturation = saturation if saturation is not None else self.DEFAULT_SATURATION
+ self.lightness = lightness if lightness is not None else self.DEFAULT_LIGHTNESS
+
+ def set_speed(self, speed: float):
+ self.speed = speed
+
+ def set_num_segments(self, num_segments: int):
+ self.num_segments = num_segments
+
+ def set_saturation(self, saturation: float):
+ self.saturation = max(0.0, min(1.0, saturation))
+
+ def set_lightness(self, lightness: float):
+ self.lightness = max(0.0, min(1.0, lightness))
+
+ def get_gradient(self) -> Gradient:
+ time_offset = time.monotonic()
+ hue_offset = (time_offset * self.speed) % 360.0
+
+ segment_colors = []
+ gradient_stops = []
+
+ for i in range(self.num_segments):
+ position = i / (self.num_segments - 1)
+ hue = (hue_offset + position * 360.0) % 360.0
+ alpha = self.BASE_ALPHA * (1.0 - position * self.ALPHA_FADE)
+ color = self._hsla_to_color(
+ hue / 360.0,
+ self.saturation,
+ self.lightness,
+ alpha
+ )
+ gradient_stops.append(position)
+ segment_colors.append(color)
+
+ return Gradient(
+ start=(0.0, 1.0), # Bottom of path
+ end=(0.0, 0.0), # Top of path
+ colors=segment_colors,
+ stops=gradient_stops,
+ )
+
+ @staticmethod
+ def _hsla_to_color(h: float, s: float, l: float, a: float) -> rl.Color:
+ rgb = colorsys.hls_to_rgb(h, l, s)
+ return rl.Color(
+ int(rgb[0] * 255),
+ int(rgb[1] * 255),
+ int(rgb[2] * 255),
+ int(a * 255)
+ )
+
+ def draw_rainbow_path(self, rect, path):
+ gradient = self.get_gradient()
+ draw_polygon(rect, path.projected_points, gradient=gradient)
diff --git a/selfdrive/ui/sunnypilot/ui_state.py b/selfdrive/ui/sunnypilot/ui_state.py
index 0ba696f56a..6212af4fa2 100644
--- a/selfdrive/ui/sunnypilot/ui_state.py
+++ b/selfdrive/ui/sunnypilot/ui_state.py
@@ -38,7 +38,10 @@ class UIStateSP:
self.changed_params.add(param_name)
def update(self) -> None:
- self.sunnylink_state.start()
+ if self.sunnylink_enabled:
+ self.sunnylink_state.start()
+ else:
+ self.sunnylink_state.stop()
if not self.params.is_watching():
cloudlog.warning("ParamWatcher thread died, restarting...")
@@ -51,6 +54,7 @@ class UIStateSP:
if self.active_layout:
sync_layout_params(self.active_layout, None, self.params)
+
@staticmethod
def update_status(ss, ss_sp, onroad_evt) -> str:
state = ss.state
@@ -64,7 +68,7 @@ class UIStateSP:
if not mads.available:
return "override"
- if any(e.overrideLongitudinal for e in onroad_evt) and not mads.enabled:
+ if any(e.overrideLongitudinal for e in onroad_evt):
return "override"
if mads_state in (MADSState.paused, MADSState.overriding):
@@ -94,3 +98,14 @@ class UIStateSP:
self.CP_SP = messaging.log_from_bytes(CP_SP_bytes, custom.CarParamsSP)
self.sunnylink_enabled = self.params.get_bool("SunnylinkEnabled")
self.developer_ui = self.params.get("DevUIInfo")
+ self.rainbow_path = self.params.get_bool("RainbowMode")
+ self.chevron_metrics = self.params.get("ChevronInfo")
+
+
+class DeviceSP:
+ def __init__(self):
+ self._params = Params()
+
+ def _set_awake(self, on: bool):
+ if on and self._params.get("DeviceBootMode", return_default=True) == 1:
+ self._params.put_bool("OffroadMode", True)
diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py
index 5bdf3d8088..c054ab3573 100644
--- a/selfdrive/ui/ui_state.py
+++ b/selfdrive/ui/ui_state.py
@@ -11,7 +11,7 @@ from openpilot.selfdrive.ui.lib.prime_state import PrimeState
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.hardware import HARDWARE, PC
-from openpilot.selfdrive.ui.sunnypilot.ui_state import UIStateSP
+from openpilot.selfdrive.ui.sunnypilot.ui_state import UIStateSP, DeviceSP
BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50
@@ -98,7 +98,7 @@ class UIState(UIStateSP):
@property
def engaged(self) -> bool:
- return self.started and self.sm["selfdriveState"].enabled
+ return self.started and (self.sm["selfdriveState"].enabled or self.sm["selfdriveStateSP"].mads.enabled)
def is_onroad(self) -> bool:
return self.started
@@ -190,8 +190,9 @@ class UIState(UIStateSP):
self._param_update_time = time.monotonic()
-class Device:
+class Device(DeviceSP):
def __init__(self):
+ DeviceSP.__init__(self)
self._ignition = False
self._interaction_time: float = -1
self._override_interactive_timeout: int | None = None
@@ -282,6 +283,7 @@ class Device:
def _set_awake(self, on: bool):
if on != self._awake:
+ DeviceSP._set_awake(self, on)
self._awake = on
cloudlog.debug(f"setting display power {int(on)}")
HARDWARE.set_display_power(on)
diff --git a/sunnypilot/modeld_v2/SConscript b/sunnypilot/modeld_v2/SConscript
index 4c04b7382f..750a242ad5 100644
--- a/sunnypilot/modeld_v2/SConscript
+++ b/sunnypilot/modeld_v2/SConscript
@@ -1,3 +1,4 @@
+import os
import glob
Import('env', 'envCython', 'arch', 'cereal', 'messaging', 'common', 'visionipc', 'transformations')
@@ -28,3 +29,38 @@ for pathdef, fn in {'TRANSFORM': 'transforms/transform.cl', 'LOADYUV': 'transfor
cython_libs = envCython["LIBS"] + libs
commonmodel_lib = lenv.Library('commonmodel', common_src)
lenvCython.Program('models/commonmodel_pyx.so', 'models/commonmodel_pyx.pyx', LIBS=[commonmodel_lib, *cython_libs], FRAMEWORKS=frameworks)
+tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=env.Dir("#").abspath) if 'pycache' not in x]
+
+# Get model metadata
+PC = not os.path.isfile('/TICI')
+if PC:
+ inputs = tinygrad_files + [File(Dir("#sunnypilot/modeld_v2").File("install_models_pc.py").abspath)]
+ outputs = []
+ model_dir = Dir("models").abspath
+ cmd = f'python3 {Dir("#sunnypilot/modeld_v2").abspath}/install_models_pc.py {model_dir}'
+
+ for model_name in ['supercombo', 'driving_vision', 'driving_policy']:
+ if File(f"models/{model_name}.onnx").exists():
+ inputs.append(File(f"models/{model_name}.onnx"))
+ inputs.append(File(f"models/{model_name}_tinygrad.pkl"))
+ outputs.append(File(f"models/{model_name}_metadata.pkl"))
+ if outputs:
+ lenv.Command(outputs, inputs, cmd)
+
+def tg_compile(flags, model_name):
+ pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + '"'
+ fn = File(f"models/{model_name}").abspath
+ return lenv.Command(
+ fn + "_tinygrad.pkl",
+ [fn + ".onnx"] + tinygrad_files,
+ f'{pythonpath_string} {flags} python3 {Dir("#tinygrad_repo").abspath}/examples/openpilot/compile3.py {fn}.onnx {fn}_tinygrad.pkl'
+ )
+
+# Compile small models
+for model_name in ['supercombo', 'driving_vision', 'driving_policy']:
+ if File(f"models/{model_name}.onnx").exists():
+ flags = {
+ 'larch64': 'DEV=QCOM',
+ 'Darwin': f'DEV=CPU HOME={os.path.expanduser("~")} IMAGE=0', # tinygrad calls brew which needs a $HOME in the env
+ }.get(arch, 'DEV=CPU CPU_LLVM=1 IMAGE=0')
+ tg_compile(flags, model_name)
diff --git a/sunnypilot/modeld_v2/install_models_pc.py b/sunnypilot/modeld_v2/install_models_pc.py
new file mode 100755
index 0000000000..3f964dc285
--- /dev/null
+++ b/sunnypilot/modeld_v2/install_models_pc.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+import sys
+import shutil
+import pickle
+import codecs
+import onnx
+from pathlib import Path
+
+from openpilot.system.hardware.hw import Paths
+
+
+def get_name_and_shape(value_info):
+ shape = tuple([int(dim.dim_value) for dim in value_info.type.tensor_type.shape.dim])
+ return value_info.name, shape
+
+
+def get_metadata_value_by_name(model, name):
+ for prop in model.metadata_props:
+ if prop.key == name:
+ return prop.value
+ return None
+
+
+def generate_metadata_pkl(model_path, output_path):
+ try:
+ model = onnx.load(str(model_path))
+ output_slices = get_metadata_value_by_name(model, 'output_slices')
+
+ if output_slices:
+ metadata = {
+ 'model_checkpoint': get_metadata_value_by_name(model, 'model_checkpoint'),
+ 'output_slices': pickle.loads(codecs.decode(output_slices.encode(), "base64")),
+ 'input_shapes': dict([get_name_and_shape(x) for x in model.graph.input]),
+ 'output_shapes': dict([get_name_and_shape(x) for x in model.graph.output])
+ }
+ with open(output_path, 'wb') as f:
+ pickle.dump(metadata, f)
+ return True
+ else:
+ return False
+ except Exception:
+ return False
+
+
+def install_models(model_dir):
+ model_dir = Path(model_dir)
+ models = ["driving_policy", "driving_vision"]
+ found_models = []
+
+ for model in models:
+ if (model_dir / f"{model}.onnx").exists():
+ found_models.append(model)
+
+ if not found_models:
+ return
+
+ try:
+ custom_name = input(f"Found models ({', '.join(found_models)}). Enter model short name (e.g. wmiv4): ").strip()
+ except EOFError:
+ return
+
+ if not custom_name:
+ print("No name provided, skipping installation.")
+ return
+
+ dest_dir = Path(Paths.model_root())
+ dest_dir.mkdir(parents=True, exist_ok=True)
+
+ for model in found_models:
+ onnx_path = model_dir / f"{model}.onnx"
+ tinygrad_pkl = model_dir / f"{model}_tinygrad.pkl"
+ metadata_pkl = model_dir / f"{model}_metadata.pkl"
+
+ if not metadata_pkl.exists():
+ generate_metadata_pkl(onnx_path, metadata_pkl)
+
+ dest_tinygrad = dest_dir / f"{model}_{custom_name}_tinygrad.pkl"
+ dest_metadata = dest_dir / f"{model}_{custom_name}_metadata.pkl"
+
+ if tinygrad_pkl.exists():
+ shutil.move(str(tinygrad_pkl), str(dest_tinygrad))
+ if metadata_pkl.exists():
+ shutil.move(str(metadata_pkl), str(dest_metadata))
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: install_models_pc.py ")
+ sys.exit(1)
+ install_models(sys.argv[1])
diff --git a/sunnypilot/models/runners/helpers.py b/sunnypilot/models/runners/helpers.py
index 6a128b340e..8f9d8fc2f5 100644
--- a/sunnypilot/models/runners/helpers.py
+++ b/sunnypilot/models/runners/helpers.py
@@ -2,25 +2,17 @@ from openpilot.sunnypilot.models.helpers import get_active_bundle
from openpilot.sunnypilot.models.runners.model_runner import ModelRunner
from openpilot.sunnypilot.models.runners.tinygrad.tinygrad_runner import TinygradRunner, TinygradSplitRunner
from openpilot.sunnypilot.models.runners.constants import ModelType
-from openpilot.system.hardware import TICI
-if not TICI:
- from openpilot.sunnypilot.models.runners.onnx.onnx_runner import ONNXRunner
def get_model_runner() -> ModelRunner:
"""
Factory function to create and return the appropriate ModelRunner instance.
- Selects between ONNXRunner (for non-TICI platforms) and TinygradRunner
- (for TICI platforms), choosing TinygradSplitRunner if separate vision/policy
+ Selects TinygradRunner, choosing TinygradSplitRunner if separate vision/policy
models are detected in the active bundle.
:return: An instance of a ModelRunner subclass (ONNXRunner, TinygradRunner, or TinygradSplitRunner).
"""
- if not TICI:
- return ONNXRunner()
-
- # On TICI platforms, use Tinygrad runners
bundle = get_active_bundle()
if bundle and bundle.models:
model_types = {m.type.raw for m in bundle.models}
diff --git a/sunnypilot/selfdrive/car/interfaces.py b/sunnypilot/selfdrive/car/interfaces.py
index 83114ac551..a93f5724b5 100644
--- a/sunnypilot/selfdrive/car/interfaces.py
+++ b/sunnypilot/selfdrive/car/interfaces.py
@@ -114,7 +114,7 @@ def initialize_params(params) -> list[dict[str, Any]]:
# hyundai
keys.extend([
- "HyundaiLongitudinalTuning"
+ "HyundaiLongitudinalTuning",
])
# subaru
@@ -128,4 +128,9 @@ def initialize_params(params) -> list[dict[str, Any]]:
"TeslaCoopSteering",
])
+ # toyota
+ keys.extend([
+ "ToyotaEnforceStockLongitudinal",
+ ])
+
return [{k: params.get(k, return_default=True)} for k in keys]
diff --git a/sunnypilot/sunnylink/athena/sunnylinkd.py b/sunnypilot/sunnylink/athena/sunnylinkd.py
index 3aeacf6a39..d1a03778c6 100755
--- a/sunnypilot/sunnylink/athena/sunnylinkd.py
+++ b/sunnypilot/sunnylink/athena/sunnylinkd.py
@@ -42,6 +42,12 @@ METADATA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__f
params = Params()
+# Parameters that should never be remotely modified for security reasons
+BLOCKED_PARAMS = {
+ "GithubUsername", # Could grant SSH access
+ "GithubSshKeys", # Direct SSH key injection
+}
+
def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None:
cloudlog.info("sunnylinkd.handle_long_poll started")
@@ -56,7 +62,7 @@ def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None:
threading.Thread(target=ws_ping, args=(ws, end_event), name='ws_ping'),
threading.Thread(target=ws_queue, args=(end_event,), name='ws_queue'),
threading.Thread(target=upload_handler, args=(end_event,), name='upload_handler'),
- # threading.Thread(target=sunny_log_handler, args=(end_event, comma_prime_cellular_end_event), name='log_handler'),
+ threading.Thread(target=sunny_log_handler, args=(end_event, comma_prime_cellular_end_event), name='log_handler'),
threading.Thread(target=stat_handler, args=(end_event, Paths.stats_sp_root(), True), name='stat_handler'),
] + [
threading.Thread(target=jsonrpc_handler, args=(end_event, partial(startLocalProxy, end_event),), name=f'worker_{x}')
@@ -248,6 +254,11 @@ def getParams(params_keys: list[str], compression: bool = False) -> str | dict[s
@dispatcher.add_method
def saveParams(params_to_update: dict[str, str], compression: bool = False) -> None:
for key, value in params_to_update.items():
+ # disallow modifications to blocked parameters
+ if key in BLOCKED_PARAMS:
+ cloudlog.warning(f"sunnylinkd.saveParams.blocked: Attempted to modify blocked parameter '{key}'")
+ continue
+
try:
save_param_from_base64_encoded_string(key, value, compression)
except Exception as e:
diff --git a/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py b/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py
new file mode 100644
index 0000000000..616bff037e
--- /dev/null
+++ b/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py
@@ -0,0 +1,59 @@
+"""
+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.sunnypilot.sunnylink.athena import sunnylinkd
+
+
+class TestSunnylinkdMethods:
+ def setup_method(self):
+ self.saved_params = []
+
+ self.original_save = sunnylinkd.save_param_from_base64_encoded_string
+
+ def mock_save_param(key, value, compression=False):
+ self.saved_params.append((key, value, compression))
+
+ sunnylinkd.save_param_from_base64_encoded_string = mock_save_param
+
+ def teardown_method(self):
+ sunnylinkd.save_param_from_base64_encoded_string = self.original_save
+
+ def test_saveParams_blocked(self):
+ blocked_params = {
+ "GithubUsername": "attacker",
+ "GithubSshKeys": "ssh-rsa attacker_key",
+ }
+
+ sunnylinkd.saveParams(blocked_params)
+
+ assert len(self.saved_params) == 0
+
+ def test_saveParams_allowed(self):
+ allowed_params = {
+ "SpeedLimitOffset": "5",
+ "MyCustomParam": "123"
+ }
+
+ sunnylinkd.saveParams(allowed_params)
+
+ # verify content
+ assert len(self.saved_params) == 2
+ keys_saved = [p[0] for p in self.saved_params]
+ assert "SpeedLimitOffset" in keys_saved
+ assert "MyCustomParam" in keys_saved
+
+ def test_saveParams_mixed(self):
+ mixed_params = {
+ "GithubUsername": "attacker",
+ "SpeedLimitOffset": "10"
+ }
+
+ sunnylinkd.saveParams(mixed_params)
+
+ # should save allowed one
+ assert len(self.saved_params) == 1
+ assert self.saved_params[0][0] == "SpeedLimitOffset"
+ assert self.saved_params[0][1] == "10"
diff --git a/sunnypilot/sunnylink/backups/manager.py b/sunnypilot/sunnylink/backups/manager.py
index 1b3c623fc4..cc38476041 100644
--- a/sunnypilot/sunnylink/backups/manager.py
+++ b/sunnypilot/sunnylink/backups/manager.py
@@ -19,7 +19,7 @@ from openpilot.system.version import get_version
from cereal import messaging, custom
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
-from openpilot.sunnypilot.sunnylink.backups.utils import decrypt_compressed_data, encrypt_compress_data, SnakeCaseEncoder
+from openpilot.sunnypilot.sunnylink.backups.utils import decrypt_compressed_data, encrypt_compressed_data, SnakeCaseEncoder
from openpilot.sunnypilot.sunnylink.utils import get_param_as_byte, save_param_from_base64_encoded_string
@@ -95,7 +95,7 @@ class BackupManagerSP:
# Serialize and encrypt config data
config_json = json.dumps(config_data)
- encrypted_config = encrypt_compress_data(config_json, use_aes_256=True)
+ encrypted_config = encrypt_compressed_data(config_json, use_aes_256=True)
self._update_progress(50.0, OperationType.BACKUP)
backup_info = custom.BackupManagerSP.BackupInfo()
diff --git a/sunnypilot/sunnylink/backups/utils.py b/sunnypilot/sunnylink/backups/utils.py
index 1734a7efcf..a81a13b2c7 100644
--- a/sunnypilot/sunnylink/backups/utils.py
+++ b/sunnypilot/sunnylink/backups/utils.py
@@ -4,9 +4,9 @@ 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 base64
import hashlib
+import os
import zlib
import re
import json
@@ -14,8 +14,9 @@ from pathlib import Path
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
-from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.primitives.asymmetric import rsa, ec
+from openpilot.common.api.base import KEYS
from openpilot.sunnypilot.sunnylink.backups.AESCipher import AESCipher
from openpilot.system.hardware.hw import Paths
@@ -27,37 +28,43 @@ class KeyDerivation:
return f.read()
@staticmethod
- def derive_aes_key_iv_from_rsa(key_path: str, use_aes_256: bool) -> tuple[bytes, bytes]:
- rsa_key_pem: bytes = KeyDerivation._load_key(key_path)
- key_plain = rsa_key_pem.decode(errors="ignore")
+ def derive_aes_key_iv(key_path: str, use_aes_256: bool) -> tuple[bytes, bytes]:
+ key_pem: bytes = KeyDerivation._load_key(key_path)
+ key_plain = key_pem.decode(errors="ignore")
if "private" in key_plain.lower():
- private_key = serialization.load_pem_private_key(rsa_key_pem, password=None, backend=default_backend())
- if not isinstance(private_key, rsa.RSAPrivateKey):
- raise ValueError("Invalid RSA key format: Unable to determine if key is public or private.")
-
- der_data = private_key.private_bytes(
- encoding=serialization.Encoding.DER,
- format=serialization.PrivateFormat.TraditionalOpenSSL,
- encryption_algorithm=serialization.NoEncryption()
- )
+ private_key = serialization.load_pem_private_key(key_pem, password=None, backend=default_backend())
+ if isinstance(private_key, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey)):
+ public_key = private_key.public_key()
+ else:
+ raise ValueError("Invalid key format: Unable to determine if key is public or private.")
elif "public" in key_plain.lower():
- public_key = serialization.load_pem_public_key(rsa_key_pem, backend=default_backend())
- if not isinstance(public_key, rsa.RSAPublicKey):
- raise ValueError("Invalid RSA key format: Unable to determine if key is public or private.")
-
- der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.PKCS1)
+ public_key = serialization.load_pem_public_key(key_pem, backend=default_backend()) # type: ignore[assignment]
+ if not isinstance(public_key, (rsa.RSAPublicKey, ec.EllipticCurvePublicKey)):
+ raise ValueError("Invalid key format: Unable to determine if key is public or private.")
else:
- raise ValueError("Unknown key format: Unable to determine if key is public or private.")
+ raise ValueError("Invalid key format: Unable to determine if key is public or private.")
- sha256_hash = hashlib.sha256(der_data).digest()
- aes_key = sha256_hash[:32] if use_aes_256 else sha256_hash[:16]
- aes_iv = sha256_hash[16:32]
+ if isinstance(public_key, rsa.RSAPublicKey):
+ der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.PKCS1)
+ elif isinstance(public_key, ec.EllipticCurvePublicKey):
+ der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo)
+ else:
+ raise ValueError("Unsupported key type.")
- return aes_key, aes_iv
+ if use_aes_256:
+ # AES-256-CBC
+ key = hashlib.sha256(der_data).digest()
+ iv = hashlib.md5(der_data).digest()
+ else:
+ # AES-128-CBC
+ key = hashlib.md5(der_data).digest()
+ iv = hashlib.md5(der_data).digest() # Insecure IV reuse, kept for compatibility
+
+ return key, iv
-def qUncompress(data):
+def uncompress_dat(data):
"""
Decompress data using zlib.
@@ -71,7 +78,7 @@ def qUncompress(data):
return zlib.decompress(data_stripped_4)
-def qCompress(data):
+def compress_dat(data):
"""
Compress data using zlib.
@@ -85,6 +92,19 @@ def qCompress(data):
return b"ZLIB" + compressed_data
+def get_key_path(use_aes_256=False) -> str:
+ key_path = ""
+ for key in KEYS:
+ if os.path.isfile(Paths.persist_root() + f'/comma/{key}') and os.path.isfile(Paths.persist_root() + f'/comma/{key}.pub'):
+ key_path = str(Path(Paths.persist_root() + f'/comma/{key}') if use_aes_256 else Path(Paths.persist_root() + f'/comma/{key}.pub'))
+ break
+
+ if not key_path:
+ raise FileNotFoundError("No valid key pair found in persist storage.")
+
+ return key_path
+
+
def decrypt_compressed_data(encrypted_base64, use_aes_256=False):
"""
Decrypt and decompress data from base64 string.
@@ -96,18 +116,17 @@ def decrypt_compressed_data(encrypted_base64, use_aes_256=False):
Returns:
str: Decrypted and decompressed string
"""
- key_path = Path(f"{Paths.persist_root()}/comma/id_rsa") if use_aes_256 else Path(f"{Paths.persist_root()}/comma/id_rsa.pub")
try:
# Decode base64
encrypted_data = base64.b64decode(encrypted_base64)
# Decrypt
- key, iv = KeyDerivation.derive_aes_key_iv_from_rsa(str(key_path), use_aes_256)
+ key, iv = KeyDerivation.derive_aes_key_iv(get_key_path(use_aes_256), use_aes_256)
cipher = AESCipher(key, iv)
decrypted_data = cipher.decrypt(encrypted_data)
# Decompress
- decompressed_data = qUncompress(decrypted_data)
+ decompressed_data = uncompress_dat(decrypted_data)
# Decode UTF-8
result = decompressed_data.decode('utf-8')
@@ -117,7 +136,7 @@ def decrypt_compressed_data(encrypted_base64, use_aes_256=False):
return ""
-def encrypt_compress_data(text, use_aes_256=True):
+def encrypt_compressed_data(text, use_aes_256=True):
"""
Compress and encrypt string data to base64.
@@ -128,16 +147,15 @@ def encrypt_compress_data(text, use_aes_256=True):
Returns:
str: Base64 encoded encrypted data
"""
- key_path = Path(f"{Paths.persist_root()}/comma/id_rsa") if use_aes_256 else Path(f"{Paths.persist_root()}/comma/id_rsa.pub")
try:
# Encode to UTF-8
text_bytes = text.encode('utf-8')
# Compress
- compressed_data = qCompress(text_bytes)
+ compressed_data = compress_dat(text_bytes)
# Encrypt
- key, iv = KeyDerivation.derive_aes_key_iv_from_rsa(str(key_path), use_aes_256)
+ key, iv = KeyDerivation.derive_aes_key_iv(get_key_path(use_aes_256), use_aes_256)
cipher = AESCipher(key, iv)
encrypted_data = cipher.encrypt(compressed_data)
diff --git a/sunnypilot/sunnylink/params_metadata.json b/sunnypilot/sunnylink/params_metadata.json
index cef1b6ee9b..f35bb792c3 100644
--- a/sunnypilot/sunnylink/params_metadata.json
+++ b/sunnypilot/sunnylink/params_metadata.json
@@ -1051,6 +1051,10 @@
"max": 5.0,
"step": 0.1
},
+ "ToyotaEnforceStockLongitudinal": {
+ "title": "Toyota: Enforce Factory Longitudinal Control",
+ "description": "When enabled, sunnypilot will not take over control of gas and brakes. Factory Toyota longitudinal control will be used."
+ },
"TrainingVersion": {
"title": "Training Version",
"description": ""
diff --git a/sunnypilot/sunnylink/sunnylink_state.py b/sunnypilot/sunnylink/sunnylink_state.py
index 13b5ad81ec..efdfa70715 100644
--- a/sunnypilot/sunnylink/sunnylink_state.py
+++ b/sunnypilot/sunnylink/sunnylink_state.py
@@ -136,10 +136,11 @@ class SunnylinkState:
token = self._api.get_token()
response = self._api.api_get(f"device/{self.sunnylink_dongle_id}/roles", method='GET', access_token=token)
if response.status_code == 200:
- self._roles = _parse_roles(response.text)
- self._params.put("SunnylinkCache_Roles", response.text)
- sponsor_tier = self._get_highest_tier()
+ roles = response.text
+ self._params.put("SunnylinkCache_Roles", roles)
with self._lock:
+ self._roles = _parse_roles(roles)
+ sponsor_tier = self._get_highest_tier()
if sponsor_tier != self.sponsor_tier:
self.sponsor_tier = sponsor_tier
cloudlog.info(f"Sunnylink sponsor tier updated to {sponsor_tier.name}")
@@ -157,7 +158,7 @@ class SunnylinkState:
users = response.text
self._params.put("SunnylinkCache_Users", users)
with self._lock:
- _parse_users(users)
+ self._users = _parse_users(users)
except Exception as e:
cloudlog.exception(f"Failed to fetch sunnylink users: {e} for dongle id {self.sunnylink_dongle_id}")
diff --git a/system/ui/sunnypilot/widgets/list_view.py b/system/ui/sunnypilot/widgets/list_view.py
index 41c2cba223..0a0cbbf867 100644
--- a/system/ui/sunnypilot/widgets/list_view.py
+++ b/system/ui/sunnypilot/widgets/list_view.py
@@ -11,15 +11,30 @@ from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.sunnypilot.widgets.toggle import ToggleSP
+from openpilot.system.ui.widgets 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.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, ButtonAction, \
- _resolve_value, BUTTON_WIDTH, BUTTON_HEIGHT, TEXT_PADDING
+ _resolve_value, BUTTON_WIDTH, BUTTON_HEIGHT, TEXT_PADDING, DualButtonAction
+from openpilot.system.ui.widgets.scroller_tici import LineSeparator, LINE_COLOR, LINE_PADDING
from openpilot.system.ui.sunnypilot.lib.styles import style
from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP, LABEL_WIDTH
from openpilot.selfdrive.ui.ui_state import ui_state
+class Spacer(Widget):
+ def __init__(self, height: int = 1):
+ super().__init__()
+ self._rect = rl.Rectangle(0, 0, 0, height)
+
+ def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
+ super().set_parent_rect(parent_rect)
+ self._rect.width = parent_rect.width
+
+ def _render(self, _):
+ rl.draw_rectangle(int(self._rect.x), int(self._rect.y), int(self._rect.x + self._rect.width), int(self._rect.y), rl.Color(0,0,0,0))
+
+
class ToggleActionSP(ToggleAction):
def __init__(self, initial_state: bool = False, width: int = style.TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True,
callback: Callable[[bool], None] | None = None, param: str | None = None):
@@ -84,6 +99,33 @@ class ButtonActionSP(ButtonAction):
return pressed
+class DualButtonActionSP(DualButtonAction):
+ def __init__(self, left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable = None,
+ right_callback: Callable = None, enabled: bool | Callable[[], bool] = True, border_radius: int = 15):
+ DualButtonAction.__init__(self, left_text, right_text, left_callback, right_callback, enabled)
+ self.left_button._border_radius = self.right_button._border_radius = border_radius
+
+ def _render(self, rect: rl.Rectangle):
+ button_spacing = 20
+ button_height = 150
+ button_width = (rect.width - button_spacing) / 2
+ button_y = rect.y + (rect.height - button_height) / 2
+
+ left_rect = rl.Rectangle(rect.x, button_y, button_width, button_height)
+ right_rect = rl.Rectangle(rect.x + button_width + button_spacing, button_y, button_width, button_height)
+
+ # expand one to full width if other is not visible
+ if not self.left_button.is_visible:
+ right_rect.x = rect.x
+ right_rect.width = rect.width
+ elif not self.right_button.is_visible:
+ left_rect.width = rect.width
+
+ # Render buttons
+ self.left_button.render(left_rect)
+ self.right_button.render(right_rect)
+
+
class MultipleButtonActionSP(MultipleButtonAction):
def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable = None,
param: str | None = None):
@@ -180,13 +222,8 @@ class ListItemSP(ListItem):
return rl.Rectangle(0, 0, 0, 0)
if not self.inline:
- has_description = bool(self.description) and self.description_visible
-
- if has_description:
- action_y = item_rect.y + self._text_size.y + style.ITEM_PADDING * 3
- else:
- action_y = item_rect.y + item_rect.height - style.BUTTON_HEIGHT - style.ITEM_PADDING * 1.5
-
+ text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
+ action_y = item_rect.y + text_size.y + style.ITEM_PADDING * 3
return rl.Rectangle(item_rect.x + style.ITEM_PADDING, action_y, item_rect.width - (style.ITEM_PADDING * 2), style.BUTTON_HEIGHT)
right_width = self.action_item.get_width_hint()
@@ -256,13 +293,13 @@ class ListItemSP(ListItem):
item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2 if self.inline else self._rect.y + style.ITEM_PADDING * 1.5
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, self.title_color)
- # Draw right item if present
- if self.action_item:
- right_rect = self.get_right_item_rect(self._rect)
- if self.action_item.render(right_rect) and self.action_item.enabled:
- # Right item was clicked/activated
- if self.callback:
- self.callback()
+ # Draw right item if present
+ if self.action_item:
+ right_rect = self.get_right_item_rect(self._rect)
+ if self.action_item.render(right_rect) and self.action_item.enabled:
+ # Right item was clicked/activated
+ if self.callback:
+ self.callback()
# Draw description if visible
if self.description_visible:
@@ -307,15 +344,34 @@ def option_item_sp(title: str | Callable[[], str], param: str,
value_change_step: int = 1, on_value_changed: Callable[[int], None] | None = None,
enabled: bool | Callable[[], bool] = True,
icon: str = "", label_width: int = LABEL_WIDTH, value_map: dict[int, int] | None = None,
- use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None) -> ListItemSP:
+ use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None, inline: bool = False) -> ListItemSP:
action = OptionControlSP(
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)
+ return ListItemSP(title=title, description=description, action_item=action, icon=icon, inline=inline)
def button_item_sp(title: str | Callable[[], str], button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItemSP:
action = ButtonActionSP(text=button_text, enabled=enabled)
return ListItemSP(title=title, description=description, action_item=action, callback=callback)
+
+
+def dual_button_item_sp(left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable = None,
+ right_callback: Callable = None, description: str | Callable[[], str] | None = None,
+ enabled: bool | Callable[[], bool] = True, border_radius: int = 15) -> ListItemSP:
+ action = DualButtonActionSP(left_text, right_text, left_callback, right_callback, enabled, border_radius)
+ return ListItemSP(title="", description=description, action_item=action)
+
+
+class LineSeparatorSP(LineSeparator):
+ def __init__(self, height: int = 1):
+ super().__init__()
+ self._rect = rl.Rectangle(0, 0, 0, height)
+
+ def _render(self, _):
+ line_y = int(self._rect.y + self._rect.height // 2)
+ rl.draw_line(int(self._rect.x) + LINE_PADDING, line_y,
+ int(self._rect.x + self._rect.width) - LINE_PADDING, line_y,
+ LINE_COLOR)