From edf697392c0368a062e586019a103cf4609ac1cb Mon Sep 17 00:00:00 2001 From: discountchubbs Date: Mon, 29 Dec 2025 08:40:01 -0700 Subject: [PATCH] # Conflicts: # opendbc_repo # selfdrive/ui/sunnypilot/layouts/settings/settings.py # selfdrive/ui/sunnypilot/mici/layouts/settings.py # selfdrive/ui/sunnypilot/ui_state.py --- .../workflows/sunnypilot-master-dev-prep.yaml | 18 ++ .github/workflows/tests.yaml | 2 +- README.md | 60 +---- common/params_keys.h | 1 + selfdrive/monitoring/helpers.py | 2 +- selfdrive/monitoring/test_monitoring.py | 66 +++++- selfdrive/ui/mici/layouts/home.py | 2 +- selfdrive/ui/onroad/model_renderer.py | 13 +- .../ui/sunnypilot/layouts/settings/device.py | 208 ++++++++++++++++++ .../sunnypilot/layouts/settings/settings.py | 33 ++- .../sunnypilot/layouts/settings/sunnylink.py | 4 +- .../layouts/settings/vehicle/__init__.py | 4 +- .../layouts/settings/vehicle/brands/toyota.py | 44 ++++ .../ui/sunnypilot/mici/layouts/settings.py | 6 - .../ui/sunnypilot/onroad/chevron_metrics.py | 147 +++++++++++++ .../ui/sunnypilot/onroad/model_renderer.py | 14 ++ .../ui/sunnypilot/onroad/rainbow_path.py | 78 +++++++ selfdrive/ui/sunnypilot/ui_state.py | 19 +- selfdrive/ui/ui_state.py | 8 +- sunnypilot/modeld_v2/SConscript | 36 +++ sunnypilot/modeld_v2/install_models_pc.py | 89 ++++++++ sunnypilot/models/runners/helpers.py | 10 +- sunnypilot/selfdrive/car/interfaces.py | 7 +- sunnypilot/sunnylink/athena/sunnylinkd.py | 13 +- .../sunnylink/athena/tests/test_sunnylinkd.py | 59 +++++ sunnypilot/sunnylink/backups/manager.py | 4 +- sunnypilot/sunnylink/backups/utils.py | 84 ++++--- sunnypilot/sunnylink/params_metadata.json | 4 + sunnypilot/sunnylink/sunnylink_state.py | 9 +- system/ui/sunnypilot/widgets/list_view.py | 90 ++++++-- 30 files changed, 969 insertions(+), 165 deletions(-) create mode 100644 selfdrive/ui/sunnypilot/onroad/chevron_metrics.py create mode 100644 selfdrive/ui/sunnypilot/onroad/model_renderer.py create mode 100644 selfdrive/ui/sunnypilot/onroad/rainbow_path.py create mode 100755 sunnypilot/modeld_v2/install_models_pc.py create mode 100644 sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py diff --git a/.github/workflows/sunnypilot-master-dev-prep.yaml b/.github/workflows/sunnypilot-master-dev-prep.yaml index 10794bf0f7..8e8e3c3d9d 100644 --- a/.github/workflows/sunnypilot-master-dev-prep.yaml +++ b/.github/workflows/sunnypilot-master-dev-prep.yaml @@ -174,6 +174,24 @@ jobs: echo ' pushurl = ${{ env.LFS_PUSH_URL }}' >> .lfsconfig echo ' locksverify = false' >> .lfsconfig + - name: Restore workflows from source + run: | + TARGET_BRANCH="${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" + SOURCE_BRANCH="${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }}" + + # Ensure we are on the target branch + git checkout $TARGET_BRANCH + + echo "Restoring .github/workflows from $SOURCE_BRANCH" + git checkout origin/$SOURCE_BRANCH -- .github/workflows + + if ! git diff --cached --quiet; then + echo "Workflows differ. Committing restoration." + git commit -m "chore: restore .github/workflows from $SOURCE_BRANCH" + else + echo "Workflows match $SOURCE_BRANCH." + fi + - uses: actions/create-github-app-token@v2 id: ci-token with: diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 68dd5617a0..14fa6e1bd5 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -107,8 +107,8 @@ jobs: build_mac: name: build macOS - if: false # tmp disable due to brew install not working runs-on: ${{ ((github.repository == 'commaai/openpilot') && ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))) && 'namespace-profile-macos-8x14' || 'macos-latest' }} + if: false # There'll be one day that this works. That day is not today. steps: - uses: actions/checkout@v4 with: diff --git a/README.md b/README.md index 598e1273a7..71fbb00e4c 100644 --- a/README.md +++ b/README.md @@ -11,66 +11,10 @@ Join the official sunnypilot community forum to stay up to date with all the lat https://docs.sunnypilot.ai/ is your one stop shop for everything from features to installation to FAQ about the sunnypilot ## 🚘 Running on a dedicated device in a car -* A supported device to run this software - * a [comma three](https://comma.ai/shop/products/three) or a [C3X](https://comma.ai/shop/comma-3x) -* This software -* One of [the 325+ supported cars](https://github.com/sunnypilot/sunnypilot/blob/master/docs/CARS.md). We support Honda, Toyota, Hyundai, Nissan, Kia, Chrysler, Lexus, Acura, Audi, VW, Ford, and more. If your car is not supported but has adaptive cruise control and lane-keeping assist, it's likely able to run sunnypilot. -* A [car harness](https://comma.ai/shop/products/car-harness) to connect to your car - -Detailed instructions for [how to mount the device in a car](https://comma.ai/setup). +First, check out this list of items you'll need to [get started](https://community.sunnypilot.ai/t/getting-started-using-sunnypilot-in-your-supported-car/251). ## Installation -Please refer to [Recommended Branches](#recommended-branches) to find your preferred/supported branch. This guide will assume you want to install the latest `staging` branch. - -### If you want to use our newest branches (our rewrite) -> [!TIP] ->You can see the rewrite state on our [rewrite project board](https://github.com/orgs/sunnypilot/projects/2), and to install the new branches, you can use the following links - -* sunnypilot not installed or you installed a version before 0.8.17? - 1. [Factory reset/uninstall](https://github.com/commaai/openpilot/wiki/FAQ#how-can-i-reset-the-device) the previous software if you have another software/fork installed. - 2. After factory reset/uninstall and upon reboot, select `Custom Software` when given the option. - 3. Input the installation URL per [Recommended Branches](#recommended-branches). Example: ```https://staging.sunnypilot.ai```. - 4. Complete the rest of the installation following the onscreen instructions. - -* sunnypilot already installed and you installed a version after 0.8.17? - 1. On the comma three/3X, go to `Settings` ▶️ `Software`. - 2. At the `Download` option, press `CHECK`. This will fetch the list of latest branches from sunnypilot. - 3. At the `Target Branch` option, press `SELECT` to open the Target Branch selector. - 4. Scroll to select the desired branch per Recommended Branches (see below). Example: `staging` - -### Recommended Branches -| Branch | Installation URL | -|:---------------:|:---------------------------------------------:| -| `release` | `https://release.sunnypilot.ai` | -| `staging` | `https://staging.sunnypilot.ai` | -| `dev` | `https://dev.sunnypilot.ai` | -| `custom-branch` | `https://install.sunnypilot.ai/{branch_name}` | - -> [!TIP] -> You can use sunnypilot/targetbranch as an install URL. Example: 'sunnypilot/staging'. - -> [!NOTE] -> Do you require further assistance with software installation? Join the [sunnypilot community forum](https://community.sunnypilot.ai/new-topic?category=general/qa) and create a topic in the General/Q&A Category channel. - - -
- -Older legacy branches - -### If you want to use our older legacy branches (*not recommended*) - -> [**IMPORTANT**] -> It is recommended to [re-flash AGNOS](https://flash.comma.ai/) if you intend to downgrade from the new branches. -> You can still restore the latest sunnylink backup made on the old branches. - -| Branch | Installation URL | -|:------------:|:--------------------------------:| -| `release-c3` | https://release-c3.sunnypilot.ai | -| `staging-c3` | https://staging-c3.sunnypilot.ai | -| `dev-c3` | https://dev-c3.sunnypilot.ai | - -
- +Next, refer to the sunnypilot community forum for [installation instructions](https://community.sunnypilot.ai/t/read-before-installing-sunnypilot/254), as well as a complete list of [Recommended Branch Installations](https://community.sunnypilot.ai/t/recommended-branch-installations/235). ## 🎆 Pull Requests We welcome both pull requests and issues on GitHub. Bug fixes are encouraged. diff --git a/common/params_keys.h b/common/params_keys.h index 836313aee8..60f7a907b1 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -213,6 +213,7 @@ inline static std::unordered_map 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)