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/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 103dd99efd..bc83c82f85 100644
--- a/selfdrive/ui/sunnypilot/layouts/settings/settings.py
+++ b/selfdrive/ui/sunnypilot/layouts/settings/settings.py
@@ -9,28 +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.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
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
index 316d4d2240..2b5497fb56 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/ui_state.py b/selfdrive/ui/sunnypilot/ui_state.py
index fe90e3bdfa..ca8125512a 100644
--- a/selfdrive/ui/sunnypilot/ui_state.py
+++ b/selfdrive/ui/sunnypilot/ui_state.py
@@ -23,7 +23,10 @@ class UIStateSP:
self.sunnylink_state = SunnylinkState()
def update(self) -> None:
- self.sunnylink_state.start()
+ if self.sunnylink_enabled:
+ self.sunnylink_state.start()
+ else:
+ self.sunnylink_state.stop()
@staticmethod
def update_status(ss, ss_sp, onroad_evt) -> str:
@@ -70,3 +73,12 @@ class UIStateSP:
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 f44dc152bb..a86c84ada3 100644
--- a/selfdrive/ui/ui_state.py
+++ b/selfdrive/ui/ui_state.py
@@ -12,7 +12,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
@@ -100,7 +100,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
@@ -192,8 +192,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
@@ -284,6 +285,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/selfdrive/car/interfaces.py b/sunnypilot/selfdrive/car/interfaces.py
index f1c53d3881..bd920729e9 100644
--- a/sunnypilot/selfdrive/car/interfaces.py
+++ b/sunnypilot/selfdrive/car/interfaces.py
@@ -115,7 +115,7 @@ def initialize_params(params) -> list[dict[str, Any]]:
# hyundai
keys.extend([
"HyundaiLongitudinalTuning",
- "HyundaiRadar"
+ "HyundaiRadar",
])
# subaru
@@ -129,4 +129,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 9f70aead59..d1a03778c6 100755
--- a/sunnypilot/sunnylink/athena/sunnylinkd.py
+++ b/sunnypilot/sunnylink/athena/sunnylinkd.py
@@ -62,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}')
diff --git a/sunnypilot/sunnylink/params_metadata.json b/sunnypilot/sunnylink/params_metadata.json
index 707e0620f9..bbefc3d0c8 100644
--- a/sunnypilot/sunnylink/params_metadata.json
+++ b/sunnypilot/sunnylink/params_metadata.json
@@ -1037,6 +1037,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 2d7239ae66..bf78147a58 100644
--- a/system/ui/sunnypilot/widgets/list_view.py
+++ b/system/ui/sunnypilot/widgets/list_view.py
@@ -11,14 +11,29 @@ 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
+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):
@@ -83,6 +98,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):
@@ -179,13 +221,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()
@@ -255,13 +292,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:
@@ -300,15 +337,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)