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/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/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py index 002e01ae39..4f068c4f5a 100644 --- a/selfdrive/monitoring/helpers.py +++ b/selfdrive/monitoring/helpers.py @@ -449,7 +449,6 @@ class DriverMonitoring: rpyCalib = [0., 0., 0.] else: highway_speed = sm['carState'].vEgo - # TODO-SP: unit test to assert both control checks are always present 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 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/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 0e1710c240..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 @@ -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/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/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 6ba41f1435..bf78147a58 100644 --- a/system/ui/sunnypilot/widgets/list_view.py +++ b/system/ui/sunnypilot/widgets/list_view.py @@ -11,15 +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): @@ -84,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): @@ -251,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: @@ -296,12 +337,12 @@ 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, @@ -310,6 +351,13 @@ def button_item_sp(title: str | Callable[[], str], button_text: str | Callable[[ 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__()